diff --git a/src/commands/annotations/create.ts b/src/commands/annotations/create.ts index 06f3586..5eed823 100644 --- a/src/commands/annotations/create.ts +++ b/src/commands/annotations/create.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; @@ -37,7 +38,7 @@ export const createCommand = new Command() const annotation = await mux.data.annotations.create(body as never); - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify(annotation, null, 2)); } else { console.log(`Annotation ID: ${annotation.id}`); diff --git a/src/commands/annotations/delete.ts b/src/commands/annotations/delete.ts index bab2265..a22ef63 100644 --- a/src/commands/annotations/delete.ts +++ b/src/commands/annotations/delete.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; import { confirmPrompt } from '@/lib/prompt.ts'; @@ -22,7 +23,7 @@ export const deleteCommand = new Command() // Confirm deletion unless --force flag is provided if (!options.force) { // For JSON mode, require explicit --force flag for safety - if (options.json) { + if (wantsJson(options)) { throw new Error( 'Deletion requires --force flag when using --json output', ); @@ -41,7 +42,7 @@ export const deleteCommand = new Command() await mux.data.annotations.delete(annotationId); - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify({ success: true, annotationId }, null, 2)); } else { console.log(`Annotation ${annotationId} deleted successfully.`); diff --git a/src/commands/annotations/get.ts b/src/commands/annotations/get.ts index 12f44f6..5299909 100644 --- a/src/commands/annotations/get.ts +++ b/src/commands/annotations/get.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; @@ -16,7 +17,7 @@ export const getCommand = new Command() const annotation = await mux.data.annotations.retrieve(annotationId); - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify(annotation, null, 2)); } else { console.log(`Annotation ID: ${annotation.id}`); diff --git a/src/commands/annotations/list.ts b/src/commands/annotations/list.ts index 4ea2656..3697105 100644 --- a/src/commands/annotations/list.ts +++ b/src/commands/annotations/list.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; @@ -56,7 +57,7 @@ export const listCommand = new Command() const response = await mux.data.annotations.list(params as never); - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify(response, null, 2)); return; } diff --git a/src/commands/annotations/update.ts b/src/commands/annotations/update.ts index 7eccfe5..c7f321c 100644 --- a/src/commands/annotations/update.ts +++ b/src/commands/annotations/update.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; @@ -41,7 +42,7 @@ export const updateCommand = new Command() body as never, ); - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify(annotation, null, 2)); } else { console.log('Annotation updated successfully.'); diff --git a/src/commands/assets/create.test.ts b/src/commands/assets/create.test.ts index e2908ad..68262aa 100644 --- a/src/commands/assets/create.test.ts +++ b/src/commands/assets/create.test.ts @@ -10,6 +10,7 @@ import { import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { setAgentMode } from '@/lib/context.ts'; import { createCommand } from './create.ts'; // Note: These tests focus on CLI flag parsing and input validation @@ -36,6 +37,7 @@ describe('mux assets create command', () => { await rm(tempDir, { recursive: true, force: true }); exitSpy?.mockRestore(); consoleErrorSpy?.mockRestore(); + setAgentMode(false); }); describe('Flag combinations and validation', () => { @@ -190,6 +192,83 @@ describe('mux assets create command', () => { const errorMessage = consoleErrorSpy.mock.calls[0][0]; expect(errorMessage).toMatch(/No files found matching pattern/i); }); + + test('fails fast in agent mode when multiple files need confirmation and -y is omitted', async () => { + setAgentMode(true); + const fileA = join(tempDir, 'a.mp4'); + const fileB = join(tempDir, 'b.mp4'); + await writeFile(fileA, 'fake video content'); + await writeFile(fileB, 'fake video content'); + + try { + await createCommand.parse(['--upload', fileA, '--upload', fileB]); + } catch (_error) { + // Expected to throw via mocked process.exit + } + + expect(exitSpy).toHaveBeenCalledWith(1); + const parsed = JSON.parse(String(consoleErrorSpy.mock.calls[0][0])); + expect(parsed.error).toMatch(/-y/); + expect(parsed.error).toMatch(/2 files/); + }); + + test('fails fast with --json when multiple files need confirmation and -y is omitted', async () => { + const fileA = join(tempDir, 'a.mp4'); + const fileB = join(tempDir, 'b.mp4'); + await writeFile(fileA, 'fake video content'); + await writeFile(fileB, 'fake video content'); + + try { + await createCommand.parse([ + '--upload', + fileA, + '--upload', + fileB, + '--json', + ]); + } catch (_error) { + // Expected to throw via mocked process.exit + } + + expect(exitSpy).toHaveBeenCalledWith(1); + const parsed = JSON.parse(String(consoleErrorSpy.mock.calls[0][0])); + expect(parsed.error).toMatch(/-y/); + }); + + test('does not require -y for a single file in agent mode', async () => { + // Isolate credentials so the command fails at the auth step, which + // comes after the confirmation check, without touching the network. + const originalXdg = process.env.XDG_CONFIG_HOME; + const originalTokenId = process.env.MUX_TOKEN_ID; + const originalTokenSecret = process.env.MUX_TOKEN_SECRET; + process.env.XDG_CONFIG_HOME = tempDir; + delete process.env.MUX_TOKEN_ID; + delete process.env.MUX_TOKEN_SECRET; + + try { + setAgentMode(true); + const fileA = join(tempDir, 'a.mp4'); + await writeFile(fileA, 'fake video content'); + + try { + await createCommand.parse(['--upload', fileA]); + } catch (_error) { + // Expected to fail at the auth step + } + + expect(exitSpy).toHaveBeenCalledWith(1); + const errorMessage = String(consoleErrorSpy.mock.calls[0]?.[0] || ''); + expect(errorMessage).not.toContain('-y'); + } finally { + if (originalXdg === undefined) delete process.env.XDG_CONFIG_HOME; + else process.env.XDG_CONFIG_HOME = originalXdg; + if (originalTokenId === undefined) delete process.env.MUX_TOKEN_ID; + else process.env.MUX_TOKEN_ID = originalTokenId; + if (originalTokenSecret === undefined) + delete process.env.MUX_TOKEN_SECRET; + else process.env.MUX_TOKEN_SECRET = originalTokenSecret; + } + }); }); describe('Output formatting flags', () => { @@ -206,6 +285,26 @@ describe('mux assets create command', () => { .find((opt) => opt.name === 'yes'); expect(yesOption).toBeDefined(); }); + + test('emits machine-readable JSON errors in agent mode without --json', async () => { + setAgentMode(true); + + try { + await createCommand.parse([ + '--url', + 'https://example.com/video.mp4', + '--file', + join(tempDir, 'config.json'), + ]); + } catch (_error) { + // Expected to throw via mocked process.exit + } + + expect(exitSpy).toHaveBeenCalledWith(1); + const errorMessage = String(consoleErrorSpy.mock.calls[0][0]); + const parsed = JSON.parse(errorMessage); + expect(parsed.error).toMatch(/Cannot use multiple input methods/); + }); }); describe('Optional flags', () => { diff --git a/src/commands/assets/create.ts b/src/commands/assets/create.ts index 9ab510b..93dbcf8 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 { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { expandGlobPattern, uploadFile } from '@/lib/file-upload.ts'; import { parseAssetConfig } from '@/lib/json-config.ts'; @@ -112,7 +113,7 @@ async function createFromUploads( // Show files and confirm (unless -y flag) if (!options.yes && files.length > 1) { - if (!options.json) { + if (!wantsJson(options)) { console.log(`Found ${files.length} files to upload:`); const totalSize = files.reduce((sum, f) => sum + f.size, 0); for (const file of files) { @@ -136,7 +137,7 @@ async function createFromUploads( // Upload each file for (const file of files) { - if (!options.json) { + if (!wantsJson(options)) { console.log(`Uploading ${file.name}...`); } @@ -187,7 +188,7 @@ async function createFromUploads( // Upload the file await uploadFile(file.path, upload.url, upload.id, (percent) => { - if (!options.json && percent === 100) { + if (!wantsJson(options) && percent === 100) { console.log(`${file.name} uploaded`); } }); @@ -348,6 +349,7 @@ export const createCommand = new Command() // biome-ignore lint: Cliffy infers upload as string but collect makes it string[] .action(async (options: any, ...args: unknown[]) => { const opts = options as CreateOptions; + const json = wantsJson(opts); // Merge positional args (from shell glob expansion) into upload list const extraFiles = args .flat() @@ -381,6 +383,14 @@ export const createCommand = new Command() `No files found matching pattern: ${opts.upload.join(', ')}`, ); } + // Interactive confirmation is not possible in machine-readable mode; + // fail fast with recovery guidance instead of blocking on stdin. + const uniqueFileCount = new Set(allFiles.map((f) => f.path)).size; + if (json && !opts.yes && uniqueFileCount > 1) { + throw new Error( + `Uploading ${uniqueFileCount} files requires confirmation, which is not available with --json or in agent mode. Re-run with -y/--yes to skip the prompt.`, + ); + } } // Initialize authenticated Mux client @@ -392,7 +402,7 @@ export const createCommand = new Command() if (opts.url) { result = await createFromUrl(mux, opts.url, opts); - if (opts.json) { + if (json) { console.log(JSON.stringify(result, null, 2)); } else { console.log(`Asset created: ${result.id}`); @@ -406,7 +416,7 @@ export const createCommand = new Command() } else if (opts.upload) { result = await createFromUploads(mux, opts.upload, opts); - if (opts.json) { + if (json) { console.log(JSON.stringify(result, null, 2)); } else { console.log(`\n${result.length} file(s) uploaded successfully`); @@ -417,7 +427,7 @@ export const createCommand = new Command() } else if (opts.file) { result = await createFromConfig(mux, opts.file, opts); - if (opts.json) { + if (json) { console.log(JSON.stringify(result, null, 2)); } else { console.log(`Asset created: ${result.id}`); @@ -435,7 +445,7 @@ export const createCommand = new Command() // Wait for asset processing if requested if (opts.wait && !Array.isArray(result) && result.id) { - if (!opts.json) { + if (!json) { console.log('\nWaiting for asset to be ready...'); } @@ -448,17 +458,17 @@ export const createCommand = new Command() asset = await mux.video.assets.retrieve(result.id); attempts++; - if (!opts.json) { + if (!json) { process.stdout.write('.'); } } - if (!opts.json) { + if (!json) { console.log(); } if (asset.status === 'ready') { - if (!opts.json) { + if (!json) { console.log('Asset is ready!'); } } else if (asset.status === 'errored') { @@ -466,14 +476,14 @@ export const createCommand = new Command() `Asset processing failed: ${asset.errors?.messages?.join(', ') || 'Unknown error'}`, ); } else { - if (!opts.json) { + if (!json) { console.log( `WARNING: Asset is still processing. Run 'mux assets get ${asset.id}' to check status.`, ); } } - if (opts.json) { + if (json) { console.log(JSON.stringify(asset, null, 2)); } } diff --git a/src/commands/assets/delete.ts b/src/commands/assets/delete.ts index 66526a4..316ef6a 100644 --- a/src/commands/assets/delete.ts +++ b/src/commands/assets/delete.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; import { confirmPrompt } from '@/lib/prompt.ts'; @@ -21,7 +22,7 @@ export const deleteCommand = new Command() // Confirm deletion unless --force flag is provided if (!options.force) { // For JSON mode, require explicit --force flag for safety - if (options.json) { + if (wantsJson(options)) { throw new Error( 'Deletion requires --force flag when using --json output', ); @@ -41,7 +42,7 @@ export const deleteCommand = new Command() // Delete the asset await mux.video.assets.delete(assetId); - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify({ success: true, assetId }, null, 2)); } else { console.log(`Asset ${assetId} deleted successfully`); diff --git a/src/commands/assets/get.ts b/src/commands/assets/get.ts index 19e32a9..79a5a72 100644 --- a/src/commands/assets/get.ts +++ b/src/commands/assets/get.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { formatAsset } from '@/lib/formatters.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; @@ -19,7 +20,7 @@ export const getCommand = new Command() // Fetch asset details const asset = await mux.video.assets.retrieve(assetId); - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify(asset, null, 2)); } else { formatAsset(asset); diff --git a/src/commands/assets/input-info.ts b/src/commands/assets/input-info.ts index 2ab9d7a..2d54bc3 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 { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; @@ -16,7 +17,7 @@ export const inputInfoCommand = new Command() const inputInfo = await mux.video.assets.retrieveInputInfo(assetId); - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify(inputInfo, null, 2)); } else { if (inputInfo.length === 0) { diff --git a/src/commands/assets/list.ts b/src/commands/assets/list.ts index 349a62f..8f5f90b 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 { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { formatAssetStatus, @@ -59,7 +60,7 @@ export const listCommand = new Command() // Fetch assets const response = await mux.video.assets.list(params); - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify(response, null, 2)); } else if (options.compact) { // Compact output - one line per asset, grep-friendly diff --git a/src/commands/assets/playback-ids/create.ts b/src/commands/assets/playback-ids/create.ts index c014b29..0793c0b 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 { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; import { createPlaybackId, type PlaybackIdPolicy } from '@/lib/playback-ids.ts'; @@ -35,7 +36,7 @@ export const createCommand = new Command() const playbackId = await createPlaybackId(mux, assetId, policy); - if (options.json) { + if (wantsJson(options)) { console.log( JSON.stringify( { diff --git a/src/commands/assets/playback-ids/delete.ts b/src/commands/assets/playback-ids/delete.ts index 38e7e21..af2459d 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 { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; import { deletePlaybackId } from '@/lib/playback-ids.ts'; @@ -20,7 +21,7 @@ export const deleteCommand = new Command() const mux = await createAuthenticatedMuxClient(); if (!options.force) { - if (options.json) { + if (wantsJson(options)) { throw new Error( 'Deletion requires --force flag when using --json output', ); @@ -39,7 +40,7 @@ export const deleteCommand = new Command() await deletePlaybackId(mux, assetId, playbackId); - if (options.json) { + if (wantsJson(options)) { console.log( JSON.stringify({ success: true, assetId, playbackId }, null, 2), ); diff --git a/src/commands/assets/playback-ids/list.ts b/src/commands/assets/playback-ids/list.ts index 1b7f1cc..d659ac6 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 { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; import { getPlayerUrl, getStreamUrl } from '@/lib/urls.ts'; @@ -18,7 +19,7 @@ export const listCommand = new Command() const asset = await mux.video.assets.retrieve(assetId); const playbackIds = asset.playback_ids ?? []; - if (options.json) { + if (wantsJson(options)) { const output = playbackIds.map((p) => ({ id: p.id, policy: p.policy, diff --git a/src/commands/assets/static-renditions/create.ts b/src/commands/assets/static-renditions/create.ts index 2cbf6b3..a6f94ae 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 { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; @@ -79,11 +80,11 @@ export const createCommand = new Command() mux, assetId, rendition.id as string, - options.json, + wantsJson(options), ); - outputRendition(finalRendition, options.json, false); + outputRendition(finalRendition, wantsJson(options), false); } else { - outputRendition(rendition, options.json, !options.wait); + outputRendition(rendition, wantsJson(options), !options.wait); } } catch (error) { 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 a41a5b3..406de39 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 { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; import { confirmPrompt } from '@/lib/prompt.ts'; @@ -19,7 +20,7 @@ export const deleteCommand = new Command() const mux = await createAuthenticatedMuxClient(); if (!options.force) { - if (options.json) { + if (wantsJson(options)) { throw new Error( 'Deletion requires --force flag when using --json output', ); @@ -38,7 +39,7 @@ export const deleteCommand = new Command() await mux.video.assets.deleteStaticRendition(assetId, renditionId); - if (options.json) { + if (wantsJson(options)) { console.log( JSON.stringify( { diff --git a/src/commands/assets/static-renditions/list.ts b/src/commands/assets/static-renditions/list.ts index c7de917..d100e7e 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 { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; @@ -23,7 +24,7 @@ export const listCommand = new Command() const staticRenditions = asset.static_renditions; const files = staticRenditions?.files ?? []; - if (options.json) { + if (wantsJson(options)) { console.log( JSON.stringify( files.map((file) => formatFileForJson(file)), diff --git a/src/commands/assets/tracks/create.ts b/src/commands/assets/tracks/create.ts index f283e39..8473d1e 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 { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; @@ -89,7 +90,7 @@ export const createCommand = new Command() params as never, ); - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify(track, null, 2)); } else { console.log('Track created successfully'); diff --git a/src/commands/assets/tracks/delete.ts b/src/commands/assets/tracks/delete.ts index 2a691e2..2c120e4 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 { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; import { confirmPrompt } from '@/lib/prompt.ts'; @@ -20,7 +21,7 @@ export const deleteCommand = new Command() const mux = await createAuthenticatedMuxClient(); if (!options.force) { - if (options.json) { + if (wantsJson(options)) { throw new Error( 'Deletion requires --force flag when using --json output', ); @@ -39,7 +40,7 @@ export const deleteCommand = new Command() await mux.video.assets.deleteTrack(assetId, trackId); - if (options.json) { + if (wantsJson(options)) { console.log( JSON.stringify({ success: true, assetId, trackId }, null, 2), ); diff --git a/src/commands/assets/tracks/generate-subtitles.ts b/src/commands/assets/tracks/generate-subtitles.ts index f39a471..d3008ff 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 { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; @@ -54,7 +55,7 @@ export const generateSubtitlesCommand = new Command() { generated_subtitles: [subtitle as never] }, ); - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify(result, null, 2)); } else { console.log('Subtitle generation started successfully'); diff --git a/src/commands/assets/update-master-access.ts b/src/commands/assets/update-master-access.ts index b8947e4..f0c7599 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 { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { formatAsset } from '@/lib/formatters.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; @@ -35,7 +36,7 @@ export const updateMasterAccessCommand = new Command() master_access: options.masterAccess as 'temporary' | 'none', }); - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify(asset, null, 2)); } else { console.log('Master access updated successfully.\n'); diff --git a/src/commands/assets/update.ts b/src/commands/assets/update.ts index 6e18ed9..a33c7b7 100644 --- a/src/commands/assets/update.ts +++ b/src/commands/assets/update.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { formatAsset } from '@/lib/formatters.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; @@ -72,7 +73,7 @@ export const updateCommand = new Command() const asset = await mux.video.assets.update(assetId, updateParams); - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify(asset, null, 2)); } else { console.log('Asset updated successfully.\n'); diff --git a/src/commands/delivery-usage/list.ts b/src/commands/delivery-usage/list.ts index 0786803..a6a7f6b 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 { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; @@ -48,7 +49,7 @@ export const listCommand = new Command() const reports = await mux.video.deliveryUsage.list(params as never); - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify(reports, null, 2)); return; } diff --git a/src/commands/dimensions/list.ts b/src/commands/dimensions/list.ts index 61c8cac..1703c17 100644 --- a/src/commands/dimensions/list.ts +++ b/src/commands/dimensions/list.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; @@ -15,7 +16,7 @@ export const listCommand = new Command() const response = await mux.data.dimensions.list(); - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify(response, null, 2)); return; } diff --git a/src/commands/dimensions/values.ts b/src/commands/dimensions/values.ts index 3384c28..389c6c0 100644 --- a/src/commands/dimensions/values.ts +++ b/src/commands/dimensions/values.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { wantsJson } from '@/lib/context.ts'; import { buildDataFilterParams } from '@/lib/data-filters.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; @@ -53,7 +54,7 @@ export const valuesCommand = new Command() params as never, ); - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify(response, null, 2)); return; } diff --git a/src/commands/drm-configurations/get.ts b/src/commands/drm-configurations/get.ts index 792e55f..bd6a812 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 { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; @@ -17,7 +18,7 @@ export const getCommand = new Command() const config = await mux.video.drmConfigurations.retrieve(drmConfigurationId); - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify(config, null, 2)); } else { console.log(`DRM Configuration ID: ${config.id}`); diff --git a/src/commands/drm-configurations/list.ts b/src/commands/drm-configurations/list.ts index cb0e846..ba78e74 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 { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; @@ -26,7 +27,7 @@ export const listCommand = new Command() page: options.page, }); - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify(configurations, null, 2)); return; } diff --git a/src/commands/errors/list.ts b/src/commands/errors/list.ts index 87ed592..07ccc85 100644 --- a/src/commands/errors/list.ts +++ b/src/commands/errors/list.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { wantsJson } from '@/lib/context.ts'; import { buildDataFilterParams } from '@/lib/data-filters.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; @@ -40,7 +41,7 @@ export const listCommand = new Command() const response = await mux.data.errors.list(params as never); - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify(response, null, 2)); return; } diff --git a/src/commands/exports/list.ts b/src/commands/exports/list.ts index 7817563..44ab917 100644 --- a/src/commands/exports/list.ts +++ b/src/commands/exports/list.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; @@ -15,7 +16,7 @@ export const listCommand = new Command() const response = await mux.data.exports.listVideoViews(); - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify(response, null, 2)); return; } diff --git a/src/commands/incidents/get.ts b/src/commands/incidents/get.ts index c24a4a7..91e1e1b 100644 --- a/src/commands/incidents/get.ts +++ b/src/commands/incidents/get.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; @@ -17,7 +18,7 @@ export const getCommand = new Command() const response = await mux.data.incidents.retrieve(incidentId); const incident = response.data; - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify(response, null, 2)); } else { console.log(`Incident ID: ${incident.id}`); diff --git a/src/commands/incidents/list.ts b/src/commands/incidents/list.ts index 1978ce2..464f2ad 100644 --- a/src/commands/incidents/list.ts +++ b/src/commands/incidents/list.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; @@ -88,7 +89,7 @@ export const listCommand = new Command() const response = await mux.data.incidents.list(params as never); - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify(response, null, 2)); return; } diff --git a/src/commands/incidents/related.ts b/src/commands/incidents/related.ts index 0385ae3..1d9b34c 100644 --- a/src/commands/incidents/related.ts +++ b/src/commands/incidents/related.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; @@ -56,7 +57,7 @@ export const relatedCommand = new Command() params as never, ); - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify(response, null, 2)); return; } diff --git a/src/commands/live/complete.ts b/src/commands/live/complete.ts index 85f9cd7..c25c7a1 100644 --- a/src/commands/live/complete.ts +++ b/src/commands/live/complete.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; @@ -18,7 +19,7 @@ export const completeCommand = new Command() await mux.video.liveStreams.complete(streamId); - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify({ success: true, streamId }, null, 2)); } else { console.log(`Live stream ${streamId} completed successfully`); diff --git a/src/commands/live/create.test.ts b/src/commands/live/create.test.ts index 41aabad..831806a 100644 --- a/src/commands/live/create.test.ts +++ b/src/commands/live/create.test.ts @@ -19,6 +19,7 @@ describe('mux live create command', () => { let exitSpy: Mock; let consoleErrorSpy: Mock; let consoleLogSpy: Mock; + let muxClientSpy: Mock; let mockMuxClient: { video: { liveStreams: { @@ -61,15 +62,18 @@ describe('mux live create command', () => { }; // Mock createAuthenticatedMuxClient - spyOn(muxModule, 'createAuthenticatedMuxClient').mockResolvedValue( - mockMuxClient as unknown as Mux, - ); + muxClientSpy = spyOn( + muxModule, + 'createAuthenticatedMuxClient', + ).mockResolvedValue(mockMuxClient as unknown as Mux); }); afterEach(() => { exitSpy?.mockRestore(); consoleErrorSpy?.mockRestore(); consoleLogSpy?.mockRestore(); + // Restore the module spy so later test files get the real client + muxClientSpy?.mockRestore(); }); describe('Command metadata', () => { diff --git a/src/commands/live/create.ts b/src/commands/live/create.ts index b673aaa..5b04cd1 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 { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; @@ -102,7 +103,7 @@ export const createCommand = new Command() params as Mux.Video.LiveStreamCreateParams, ); - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify(liveStream, null, 2)); } else { console.log(`Live stream created: ${liveStream.id}`); diff --git a/src/commands/live/delete-new-asset-static-renditions.ts b/src/commands/live/delete-new-asset-static-renditions.ts index e57b426..cb2f772 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 { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; import { confirmPrompt } from '@/lib/prompt.ts'; @@ -24,7 +25,7 @@ export const deleteNewAssetStaticRenditionsCommand = new Command() const mux = await createAuthenticatedMuxClient(); if (!options.force) { - if (options.json) { + if (wantsJson(options)) { throw new Error( 'Deletion requires --force flag when using --json output', ); @@ -45,7 +46,7 @@ export const deleteNewAssetStaticRenditionsCommand = new Command() streamId, ); - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify({ success: true, streamId }, null, 2)); } else { console.log( diff --git a/src/commands/live/delete.ts b/src/commands/live/delete.ts index e3ebd08..c928753 100644 --- a/src/commands/live/delete.ts +++ b/src/commands/live/delete.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; import { confirmPrompt } from '@/lib/prompt.ts'; @@ -21,7 +22,7 @@ export const deleteCommand = new Command() // Confirm deletion unless --force flag is provided if (!options.force) { // For JSON mode, require explicit --force flag for safety - if (options.json) { + if (wantsJson(options)) { throw new Error( 'Deletion requires --force flag when using --json output', ); @@ -41,7 +42,7 @@ export const deleteCommand = new Command() // Delete the live stream await mux.video.liveStreams.delete(streamId); - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify({ success: true, streamId }, null, 2)); } else { console.log(`Live stream ${streamId} deleted successfully`); diff --git a/src/commands/live/disable.ts b/src/commands/live/disable.ts index 29323d2..662d640 100644 --- a/src/commands/live/disable.ts +++ b/src/commands/live/disable.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; @@ -18,7 +19,7 @@ export const disableCommand = new Command() await mux.video.liveStreams.disable(streamId); - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify({ success: true, streamId }, null, 2)); } else { console.log(`Live stream ${streamId} disabled successfully`); diff --git a/src/commands/live/enable.ts b/src/commands/live/enable.ts index b673cc2..1ee9c1d 100644 --- a/src/commands/live/enable.ts +++ b/src/commands/live/enable.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; @@ -18,7 +19,7 @@ export const enableCommand = new Command() await mux.video.liveStreams.enable(streamId); - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify({ success: true, streamId }, null, 2)); } else { console.log(`Live stream ${streamId} enabled successfully`); diff --git a/src/commands/live/get.ts b/src/commands/live/get.ts index 1033d4d..fd6d7a9 100644 --- a/src/commands/live/get.ts +++ b/src/commands/live/get.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { formatLiveStream } from '@/lib/formatters.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; @@ -19,7 +20,7 @@ export const getCommand = new Command() // Fetch live stream details const stream = await mux.video.liveStreams.retrieve(streamId); - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify(stream, null, 2)); } else { formatLiveStream(stream); diff --git a/src/commands/live/list.ts b/src/commands/live/list.ts index 9873f61..6d4638e 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 { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { formatCreatedAt, @@ -43,7 +44,7 @@ export const listCommand = new Command() // Fetch live streams const response = await mux.video.liveStreams.list(params); - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify(response, null, 2)); } else if (options.compact) { // Compact output - one line per stream, grep-friendly diff --git a/src/commands/live/playback-ids/create.ts b/src/commands/live/playback-ids/create.ts index 9652b44..ebf593b 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 { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; import { @@ -42,7 +43,7 @@ export const createCommand = new Command() policy, ); - if (options.json) { + if (wantsJson(options)) { console.log( JSON.stringify( { diff --git a/src/commands/live/playback-ids/delete.ts b/src/commands/live/playback-ids/delete.ts index ea87790..c4de771 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 { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; import { deleteLiveStreamPlaybackId } from '@/lib/playback-ids.ts'; @@ -24,7 +25,7 @@ export const deleteCommand = new Command() const mux = await createAuthenticatedMuxClient(); if (!options.force) { - if (options.json) { + if (wantsJson(options)) { throw new Error( 'Deletion requires --force flag when using --json output', ); @@ -43,7 +44,7 @@ export const deleteCommand = new Command() await deleteLiveStreamPlaybackId(mux, liveStreamId, playbackId); - if (options.json) { + if (wantsJson(options)) { console.log( JSON.stringify( { success: true, liveStreamId, playbackId }, diff --git a/src/commands/live/playback-ids/list.ts b/src/commands/live/playback-ids/list.ts index 58cbd15..ebeb15e 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 { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; import { getPlayerUrl, getStreamUrl } from '@/lib/urls.ts'; @@ -18,7 +19,7 @@ export const listCommand = new Command() const liveStream = await mux.video.liveStreams.retrieve(liveStreamId); const playbackIds = liveStream.playback_ids ?? []; - if (options.json) { + if (wantsJson(options)) { const output = playbackIds.map((p) => ({ id: p.id, policy: p.policy, diff --git a/src/commands/live/reset-stream-key.ts b/src/commands/live/reset-stream-key.ts index 9ccdd01..6527411 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 { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { formatLiveStream } from '@/lib/formatters.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; @@ -21,7 +22,7 @@ export const resetStreamKeyCommand = new Command() const mux = await createAuthenticatedMuxClient(); if (!options.force) { - if (options.json) { + if (wantsJson(options)) { throw new Error( 'Resetting stream key requires --force flag when using --json output', ); @@ -40,7 +41,7 @@ export const resetStreamKeyCommand = new Command() const stream = await mux.video.liveStreams.resetStreamKey(streamId); - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify(stream, null, 2)); } else { console.log('Stream key reset successfully.\n'); diff --git a/src/commands/live/simulcast-targets/create.ts b/src/commands/live/simulcast-targets/create.ts index 72a7530..1a967a8 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 { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; @@ -46,7 +47,7 @@ export const createCommand = new Command() params, ); - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify(target, null, 2)); } else { console.log(`Simulcast target created successfully`); diff --git a/src/commands/live/simulcast-targets/delete.ts b/src/commands/live/simulcast-targets/delete.ts index 0293b04..fff6a86 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 { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; import { confirmPrompt } from '@/lib/prompt.ts'; @@ -21,7 +22,7 @@ export const deleteCommand = new Command() const mux = await createAuthenticatedMuxClient(); if (!options.force) { - if (options.json) { + if (wantsJson(options)) { throw new Error( 'Deletion requires --force flag when using --json output', ); @@ -40,7 +41,7 @@ export const deleteCommand = new Command() await mux.video.liveStreams.deleteSimulcastTarget(streamId, targetId); - if (options.json) { + if (wantsJson(options)) { console.log( JSON.stringify({ success: true, streamId, targetId }, null, 2), ); diff --git a/src/commands/live/simulcast-targets/get.ts b/src/commands/live/simulcast-targets/get.ts index befbff5..c57a5f3 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 { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; @@ -19,7 +20,7 @@ export const getCommand = new Command() targetId, ); - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify(target, null, 2)); } else { console.log(`Simulcast Target ID: ${target.id}`); diff --git a/src/commands/live/update-embedded-subtitles.ts b/src/commands/live/update-embedded-subtitles.ts index 48bc013..9f4dda0 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 { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { formatLiveStream } from '@/lib/formatters.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; @@ -72,7 +73,7 @@ export const updateEmbeddedSubtitlesCommand = new Command() { embedded_subtitles: embeddedSubtitles as never }, ); - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify(stream, null, 2)); } else { if (options.clear) { diff --git a/src/commands/live/update-generated-subtitles.ts b/src/commands/live/update-generated-subtitles.ts index 5477bdd..e25ae54 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 { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { formatLiveStream } from '@/lib/formatters.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; @@ -70,7 +71,7 @@ export const updateGeneratedSubtitlesCommand = new Command() { generated_subtitles: generatedSubtitles as never }, ); - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify(stream, null, 2)); } else { if (options.clear) { diff --git a/src/commands/live/update-new-asset-static-renditions.ts b/src/commands/live/update-new-asset-static-renditions.ts index 14858c9..0cc46a4 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 { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { formatLiveStream } from '@/lib/formatters.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; @@ -68,7 +69,7 @@ export const updateNewAssetStaticRenditionsCommand = new Command() { static_renditions: staticRenditions }, ); - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify(stream, null, 2)); } else { console.log('Static rendition settings updated.\n'); diff --git a/src/commands/live/update.ts b/src/commands/live/update.ts index 4ca8e23..e08f36f 100644 --- a/src/commands/live/update.ts +++ b/src/commands/live/update.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { formatLiveStream } from '@/lib/formatters.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; @@ -99,7 +100,7 @@ export const updateCommand = new Command() const stream = await mux.video.liveStreams.update(streamId, updateParams); - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify(stream, null, 2)); } else { console.log('Live stream updated successfully.\n'); diff --git a/src/commands/login.test.ts b/src/commands/login.test.ts index 89c808e..56f8dce 100644 --- a/src/commands/login.test.ts +++ b/src/commands/login.test.ts @@ -1,8 +1,18 @@ -import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { + afterEach, + beforeEach, + describe, + expect, + it, + type Mock, + spyOn, +} from 'bun:test'; import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { parseEnvFile } from './login.ts'; +import { getEnvironment } from '../lib/config.ts'; +import { setAgentMode } from '../lib/context.ts'; +import { credentialsFromEnv, loginCommand, parseEnvFile } from './login.ts'; describe('Login command - parseEnvFile', () => { let testDir: string; @@ -217,3 +227,243 @@ MUX_TOKEN_SECRET=secret=with=equals`, expect(result.MUX_TOKEN_SECRET).toBeUndefined(); }); }); + +describe('Login command - credentialsFromEnv', () => { + it('returns credentials when both token vars are present', () => { + const result = credentialsFromEnv({ + MUX_TOKEN_ID: 'env_id', + MUX_TOKEN_SECRET: 'env_secret', + }); + + expect(result).not.toBeNull(); + expect(result?.MUX_TOKEN_ID).toBe('env_id'); + expect(result?.MUX_TOKEN_SECRET).toBe('env_secret'); + }); + + it('includes MUX_BASE_URL when present', () => { + const result = credentialsFromEnv({ + MUX_TOKEN_ID: 'env_id', + MUX_TOKEN_SECRET: 'env_secret', + MUX_BASE_URL: 'https://api.example.com', + }); + + expect(result?.MUX_BASE_URL).toBe('https://api.example.com'); + }); + + it('returns null when MUX_TOKEN_ID is missing', () => { + expect(credentialsFromEnv({ MUX_TOKEN_SECRET: 'env_secret' })).toBeNull(); + }); + + it('returns null when MUX_TOKEN_SECRET is missing', () => { + expect(credentialsFromEnv({ MUX_TOKEN_ID: 'env_id' })).toBeNull(); + }); + + it('returns null when either var is an empty string', () => { + expect( + credentialsFromEnv({ MUX_TOKEN_ID: '', MUX_TOKEN_SECRET: 'secret' }), + ).toBeNull(); + expect( + credentialsFromEnv({ MUX_TOKEN_ID: 'id', MUX_TOKEN_SECRET: '' }), + ).toBeNull(); + }); + + it('defaults to process.env when no argument is given', () => { + const originalId = process.env.MUX_TOKEN_ID; + const originalSecret = process.env.MUX_TOKEN_SECRET; + process.env.MUX_TOKEN_ID = 'process_env_id'; + process.env.MUX_TOKEN_SECRET = 'process_env_secret'; + + try { + const result = credentialsFromEnv(); + expect(result?.MUX_TOKEN_ID).toBe('process_env_id'); + expect(result?.MUX_TOKEN_SECRET).toBe('process_env_secret'); + } finally { + if (originalId === undefined) delete process.env.MUX_TOKEN_ID; + else process.env.MUX_TOKEN_ID = originalId; + if (originalSecret === undefined) delete process.env.MUX_TOKEN_SECRET; + else process.env.MUX_TOKEN_SECRET = originalSecret; + } + }); +}); + +describe('Login command - action', () => { + let testConfigDir: string; + let originalXdgConfigHome: string | undefined; + let originalTokenId: string | undefined; + let originalTokenSecret: string | undefined; + let logSpy: Mock; + let errorSpy: Mock; + let exitSpy: Mock; + let fetchSpy: Mock; + + beforeEach(async () => { + testConfigDir = await mkdtemp(join(tmpdir(), 'mux-cli-test-')); + originalXdgConfigHome = process.env.XDG_CONFIG_HOME; + originalTokenId = process.env.MUX_TOKEN_ID; + originalTokenSecret = process.env.MUX_TOKEN_SECRET; + process.env.XDG_CONFIG_HOME = testConfigDir; + delete process.env.MUX_TOKEN_ID; + delete process.env.MUX_TOKEN_SECRET; + + logSpy = spyOn(console, 'log').mockImplementation(() => {}); + errorSpy = spyOn(console, 'error').mockImplementation(() => {}); + exitSpy = spyOn(process, 'exit').mockImplementation((() => { + throw new Error('process.exit called'); + }) as never); + // Mock the credential validation call (/system/v1/whoami) + fetchSpy = spyOn(globalThis, 'fetch').mockImplementation( + (async () => + new Response( + JSON.stringify({ data: { environment_id: 'env_mock_123' } }), + { status: 200 }, + )) as unknown as typeof fetch, + ); + }); + + afterEach(async () => { + if (originalXdgConfigHome === undefined) { + delete process.env.XDG_CONFIG_HOME; + } else { + process.env.XDG_CONFIG_HOME = originalXdgConfigHome; + } + if (originalTokenId === undefined) delete process.env.MUX_TOKEN_ID; + else process.env.MUX_TOKEN_ID = originalTokenId; + if (originalTokenSecret === undefined) delete process.env.MUX_TOKEN_SECRET; + else process.env.MUX_TOKEN_SECRET = originalTokenSecret; + logSpy?.mockRestore(); + errorSpy?.mockRestore(); + exitSpy?.mockRestore(); + fetchSpy?.mockRestore(); + setAgentMode(false); + await rm(testConfigDir, { recursive: true, force: true }); + }); + + it('has a --json option', () => { + const opt = loginCommand.getOptions().find((o) => o.name === 'json'); + expect(opt).toBeDefined(); + }); + + it('uses MUX_TOKEN_ID/MUX_TOKEN_SECRET from the environment without prompting', async () => { + process.env.MUX_TOKEN_ID = 'env_id'; + process.env.MUX_TOKEN_SECRET = 'env_secret'; + + await loginCommand.parse([]); + + const saved = await getEnvironment('default'); + expect(saved?.tokenId).toBe('env_id'); + expect(saved?.tokenSecret).toBe('env_secret'); + }); + + it('prefers --env-file over environment variables', async () => { + process.env.MUX_TOKEN_ID = 'env_id'; + process.env.MUX_TOKEN_SECRET = 'env_secret'; + const envPath = join(testConfigDir, '.env'); + await Bun.write( + envPath, + 'MUX_TOKEN_ID=file_id\nMUX_TOKEN_SECRET=file_secret', + ); + + await loginCommand.parse(['--env-file', envPath]); + + const saved = await getEnvironment('default'); + expect(saved?.tokenId).toBe('file_id'); + expect(saved?.tokenSecret).toBe('file_secret'); + }); + + it('outputs machine-readable JSON with --json', async () => { + process.env.MUX_TOKEN_ID = 'env_id'; + process.env.MUX_TOKEN_SECRET = 'env_secret'; + + await loginCommand.parse(['--json']); + + const output = logSpy.mock.calls.map((c) => String(c[0])).join('\n'); + const parsed = JSON.parse(output); + expect(parsed.success).toBe(true); + expect(parsed.environment).toBe('default'); + expect(parsed.config_path).toBeDefined(); + }); + + it('accepts --json in agent mode without erroring (regression: --agent used to break login)', async () => { + process.env.MUX_TOKEN_ID = 'env_id'; + process.env.MUX_TOKEN_SECRET = 'env_secret'; + setAgentMode(true); + + await loginCommand.parse([]); + + const output = logSpy.mock.calls.map((c) => String(c[0])).join('\n'); + expect(JSON.parse(output).success).toBe(true); + }); + + it('fails fast with a JSON error on --json when no credentials are available', async () => { + try { + await loginCommand.parse(['--json']); + } catch (_error) { + // handleCommandError exits; the mocked process.exit throws to halt parse + } + + expect(exitSpy).toHaveBeenCalledWith(1); + const parsed = JSON.parse(String(errorSpy.mock.calls[0][0])); + expect(parsed.error).toMatch(/MUX_TOKEN_ID and MUX_TOKEN_SECRET/); + }); + + it('fails fast with a JSON error in agent mode instead of prompting when no credentials are available', async () => { + setAgentMode(true); + + try { + await loginCommand.parse([]); + } catch (_error) { + // handleCommandError exits; the mocked process.exit throws to halt parse + } + + expect(exitSpy).toHaveBeenCalledWith(1); + const parsed = JSON.parse(String(errorSpy.mock.calls[0][0])); + expect(parsed.error).toMatch(/Interactive login is not available/); + }); + + it('reports credential validation failures as JSON in agent mode', async () => { + process.env.MUX_TOKEN_ID = 'bad_id'; + process.env.MUX_TOKEN_SECRET = 'bad_secret'; + setAgentMode(true); + fetchSpy.mockImplementation( + (async () => + new Response(JSON.stringify({ error: 'unauthorized' }), { + status: 401, + })) as unknown as typeof fetch, + ); + + try { + await loginCommand.parse([]); + } catch (_error) { + // handleCommandError exits; the mocked process.exit throws to halt parse + } + + expect(exitSpy).toHaveBeenCalledWith(1); + const parsed = JSON.parse(String(errorSpy.mock.calls[0][0])); + expect(parsed.error).toBeDefined(); + }); + + it('keeps human-readable errors when not in JSON or agent mode', async () => { + const missingFile = join(testConfigDir, 'missing.env'); + + try { + await loginCommand.parse(['--env-file', missingFile]); + } catch (_error) { + // handleCommandError exits; the mocked process.exit throws to halt parse + } + + expect(exitSpy).toHaveBeenCalledWith(1); + const message = String(errorSpy.mock.calls[0][0]); + expect(message).toMatch(/^Error: /); + expect(message).toMatch(/File not found/); + }); + + it('respects --name when logging in from environment variables', async () => { + process.env.MUX_TOKEN_ID = 'env_id'; + process.env.MUX_TOKEN_SECRET = 'env_secret'; + + await loginCommand.parse(['--name', 'staging']); + + const saved = await getEnvironment('staging'); + expect(saved?.tokenId).toBe('env_id'); + }); +}); diff --git a/src/commands/login.ts b/src/commands/login.ts index ef2a87a..c42dcaf 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -2,6 +2,8 @@ import { existsSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; import { Command } from '@cliffy/command'; import { listEnvironments, setEnvironment } from '../lib/config.ts'; +import { wantsJson } from '../lib/context.ts'; +import { handleCommandError } from '../lib/errors.ts'; import { DEFAULT_BASE_URL, getMuxBaseUrl, @@ -62,6 +64,25 @@ export async function parseEnvFile(filePath: string): Promise { return envVars; } +/** + * Read credentials from environment variables. + * Returns null unless both MUX_TOKEN_ID and MUX_TOKEN_SECRET are set + * and non-empty. + */ +export function credentialsFromEnv( + env: Record = process.env, +): EnvVars | null { + if (!env.MUX_TOKEN_ID || !env.MUX_TOKEN_SECRET) { + return null; + } + + return { + MUX_TOKEN_ID: env.MUX_TOKEN_ID, + MUX_TOKEN_SECRET: env.MUX_TOKEN_SECRET, + ...(env.MUX_BASE_URL && { MUX_BASE_URL: env.MUX_BASE_URL }), + }; +} + export const loginCommand = new Command() .description( 'Authenticate with Mux API credentials (Token ID and Secret from dashboard.mux.com)', @@ -74,85 +95,139 @@ export const loginCommand = new Command() '-n, --name ', "Name for this environment (default: 'default')", ) + .option('--json', 'Output JSON instead of pretty format') .action(async (options) => { - let tokenId: string; - let tokenSecret: string; - const envName = options.name || 'default'; - - // Check if environment already exists - const existingEnvs = await listEnvironments(); - if (existingEnvs.includes(envName)) { - console.log( - `⚠️ Environment "${envName}" already exists. It will be overwritten.`, - ); - } + const json = wantsJson(options); + try { + let tokenId: string; + let tokenSecret: string; + let source: 'env-file' | 'env' | 'interactive'; + const envName = options.name || 'default'; + + // Check if environment already exists + const existingEnvs = await listEnvironments(); + if (existingEnvs.includes(envName) && !json) { + console.log( + `⚠️ Environment "${envName}" already exists. It will be overwritten.`, + ); + } - let baseUrl: string; + let baseUrl: string; + const envCredentials = credentialsFromEnv(); + + if (options.envFile) { + // Read from .env file + if (!json) { + console.log(`Reading credentials from ${options.envFile}...`); + } + + const envVars = await parseEnvFile(options.envFile); + + if (!envVars.MUX_TOKEN_ID || !envVars.MUX_TOKEN_SECRET) { + throw new Error( + 'Missing required variables in .env file. Expected: MUX_TOKEN_ID and MUX_TOKEN_SECRET.\n' + + 'Generate API credentials here: https://dashboard.mux.com/settings/access-tokens', + ); + } + + tokenId = envVars.MUX_TOKEN_ID; + tokenSecret = envVars.MUX_TOKEN_SECRET; + baseUrl = getMuxBaseUrl({ + environment: { baseUrl: envVars.MUX_BASE_URL }, + }); + source = 'env-file'; + } else if ( + envCredentials?.MUX_TOKEN_ID && + envCredentials.MUX_TOKEN_SECRET + ) { + // Read from environment variables + if (!json) { + console.log( + 'Using MUX_TOKEN_ID and MUX_TOKEN_SECRET from environment variables...', + ); + } + + tokenId = envCredentials.MUX_TOKEN_ID; + tokenSecret = envCredentials.MUX_TOKEN_SECRET; + baseUrl = getMuxBaseUrl({ + environment: { baseUrl: envCredentials.MUX_BASE_URL }, + }); + source = 'env'; + } else { + // Interactive prompts are not possible in machine-readable mode; + // fail fast with recovery guidance instead of blocking on stdin. + if (json) { + throw new Error( + 'No credentials provided. Set MUX_TOKEN_ID and MUX_TOKEN_SECRET environment variables, or pass --env-file . Interactive login is not available with --json or in agent mode.', + ); + } + + console.log('Enter your Mux API credentials.'); + console.log( + 'Get your Token ID and Secret from https://dashboard.mux.com/settings/access-tokens\n', + ); - if (options.envFile) { - // Read from .env file - console.log(`Reading credentials from ${options.envFile}...`); + tokenId = await inputPrompt({ message: 'Mux Token ID:' }); + if (!tokenId.trim()) { + throw new Error('Token ID is required'); + } - const envVars = await parseEnvFile(options.envFile); + tokenSecret = await secretPrompt({ message: 'Mux Token Secret:' }); + if (!tokenSecret.trim()) { + throw new Error('Token Secret is required'); + } - if (!envVars.MUX_TOKEN_ID || !envVars.MUX_TOKEN_SECRET) { - throw new Error( - 'Missing required variables in .env file. Expected: MUX_TOKEN_ID and MUX_TOKEN_SECRET.\n' + - 'Generate API credentials here: https://dashboard.mux.com/settings/access-tokens', - ); + baseUrl = getMuxBaseUrl(null); + source = 'interactive'; } - tokenId = envVars.MUX_TOKEN_ID; - tokenSecret = envVars.MUX_TOKEN_SECRET; - baseUrl = getMuxBaseUrl({ - environment: { baseUrl: envVars.MUX_BASE_URL }, - }); - } else { - // Interactive prompts - console.log('Enter your Mux API credentials.'); - console.log( - 'Get your Token ID and Secret from https://dashboard.mux.com/settings/access-tokens\n', - ); - - tokenId = await inputPrompt({ message: 'Mux Token ID:' }); - if (!tokenId.trim()) { - throw new Error('Token ID is required'); + if (!json) { + console.log('Validating credentials...'); } + const validation = await validateCredentials( + tokenId.trim(), + tokenSecret.trim(), + baseUrl, + ); - tokenSecret = await secretPrompt({ message: 'Mux Token Secret:' }); - if (!tokenSecret.trim()) { - throw new Error('Token Secret is required'); + if (!validation.valid) { + throw new Error(validation.error || 'Invalid credentials'); } - baseUrl = getMuxBaseUrl(null); - } - - console.log('Validating credentials...'); - const validation = await validateCredentials( - tokenId.trim(), - tokenSecret.trim(), - baseUrl, - ); - - if (!validation.valid) { - throw new Error(validation.error || 'Invalid credentials'); - } - - console.log('✅ Credentials validated successfully'); + // Save to config + await setEnvironment(envName, { + tokenId: tokenId.trim(), + tokenSecret: tokenSecret.trim(), + environmentId: validation.environmentId, + ...(baseUrl !== DEFAULT_BASE_URL && { baseUrl }), + }); - // Save to config - await setEnvironment(envName, { - tokenId: tokenId.trim(), - tokenSecret: tokenSecret.trim(), - environmentId: validation.environmentId, - ...(baseUrl !== DEFAULT_BASE_URL && { baseUrl }), - }); + if (json) { + console.log( + JSON.stringify( + { + success: true, + environment: envName, + environment_id: validation.environmentId, + config_path: getConfigPath(), + source, + }, + null, + 2, + ), + ); + return; + } - console.log( - `✅ Credentials saved to ${getConfigPath()} for environment: ${envName}`, - ); + console.log('✅ Credentials validated successfully'); + console.log( + `✅ Credentials saved to ${getConfigPath()} for environment: ${envName}`, + ); - if (existingEnvs.length === 0) { - console.log(`✅ Set as default environment`); + if (existingEnvs.length === 0) { + console.log(`✅ Set as default environment`); + } + } catch (error) { + await handleCommandError(error, 'login', 'login', options); } }); diff --git a/src/commands/metrics/breakdown.ts b/src/commands/metrics/breakdown.ts index 9dacfcc..2f4d79e 100644 --- a/src/commands/metrics/breakdown.ts +++ b/src/commands/metrics/breakdown.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { wantsJson } from '@/lib/context.ts'; import { buildDataFilterParams } from '@/lib/data-filters.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; @@ -100,7 +101,7 @@ export const breakdownCommand = new Command() params as never, ); - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify(response, null, 2)); return; } diff --git a/src/commands/metrics/insights.ts b/src/commands/metrics/insights.ts index fd1284b..660eb94 100644 --- a/src/commands/metrics/insights.ts +++ b/src/commands/metrics/insights.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { wantsJson } from '@/lib/context.ts'; import { buildDataFilterParams } from '@/lib/data-filters.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; @@ -64,7 +65,7 @@ export const insightsCommand = new Command() params as never, ); - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify(response, null, 2)); return; } diff --git a/src/commands/metrics/list.ts b/src/commands/metrics/list.ts index 4dc0af2..142846e 100644 --- a/src/commands/metrics/list.ts +++ b/src/commands/metrics/list.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { wantsJson } from '@/lib/context.ts'; import { buildDataFilterParams } from '@/lib/data-filters.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; @@ -49,7 +50,7 @@ export const listCommand = new Command() const response = await mux.data.metrics.list(params as never); - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify(response, null, 2)); return; } diff --git a/src/commands/metrics/overall.ts b/src/commands/metrics/overall.ts index bf998a8..463c033 100644 --- a/src/commands/metrics/overall.ts +++ b/src/commands/metrics/overall.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { wantsJson } from '@/lib/context.ts'; import { buildDataFilterParams } from '@/lib/data-filters.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; @@ -62,7 +63,7 @@ export const overallCommand = new Command() params as never, ); - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify(response, null, 2)); return; } diff --git a/src/commands/metrics/timeseries.ts b/src/commands/metrics/timeseries.ts index a1d6cc6..1ecf1f8 100644 --- a/src/commands/metrics/timeseries.ts +++ b/src/commands/metrics/timeseries.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { wantsJson } from '@/lib/context.ts'; import { buildDataFilterParams } from '@/lib/data-filters.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; @@ -81,7 +82,7 @@ export const timeseriesCommand = new Command() params as never, ); - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify(response, null, 2)); return; } diff --git a/src/commands/monitoring/breakdown-timeseries.ts b/src/commands/monitoring/breakdown-timeseries.ts index 26006fe..0577012 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 { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; @@ -75,7 +76,7 @@ export const breakdownTimeseriesCommand = new Command() params as never, ); - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify(response, null, 2)); return; } diff --git a/src/commands/monitoring/breakdown.ts b/src/commands/monitoring/breakdown.ts index 70bf512..6dd1e37 100644 --- a/src/commands/monitoring/breakdown.ts +++ b/src/commands/monitoring/breakdown.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; @@ -64,7 +65,7 @@ export const breakdownCommand = new Command() params as never, ); - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify(response, null, 2)); return; } diff --git a/src/commands/monitoring/dimensions.ts b/src/commands/monitoring/dimensions.ts index b8ae790..ca0aa42 100644 --- a/src/commands/monitoring/dimensions.ts +++ b/src/commands/monitoring/dimensions.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; @@ -15,7 +16,7 @@ export const dimensionsCommand = new Command() const response = await mux.data.monitoring.listDimensions(); - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify(response, null, 2)); return; } diff --git a/src/commands/monitoring/histogram-timeseries.ts b/src/commands/monitoring/histogram-timeseries.ts index 6fed0d0..76fe70e 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 { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; @@ -30,7 +31,7 @@ export const histogramTimeseriesCommand = new Command() params as never, ); - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify(response, null, 2)); return; } diff --git a/src/commands/monitoring/metrics.ts b/src/commands/monitoring/metrics.ts index 972a742..d8e4261 100644 --- a/src/commands/monitoring/metrics.ts +++ b/src/commands/monitoring/metrics.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; @@ -15,7 +16,7 @@ export const metricsListCommand = new Command() const response = await mux.data.monitoring.metrics.list(); - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify(response, null, 2)); return; } diff --git a/src/commands/monitoring/timeseries.ts b/src/commands/monitoring/timeseries.ts index 0f2b85f..a7b8f5f 100644 --- a/src/commands/monitoring/timeseries.ts +++ b/src/commands/monitoring/timeseries.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; @@ -36,7 +37,7 @@ export const timeseriesCommand = new Command() params as never, ); - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify(response, null, 2)); return; } diff --git a/src/commands/playback-ids/index.ts b/src/commands/playback-ids/index.ts index 938e9bf..f4cebe8 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 { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { formatAsset, formatLiveStream } from '@/lib/formatters.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; @@ -26,7 +27,7 @@ export const playbackIdsCommand = new Command() const asset = await mux.video.assets.retrieve( playbackIdInfo.object.id, ); - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify(asset, null, 2)); } else { formatAsset(asset); @@ -35,7 +36,7 @@ export const playbackIdsCommand = new Command() const stream = await mux.video.liveStreams.retrieve( playbackIdInfo.object.id, ); - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify(stream, null, 2)); } else { formatLiveStream(stream); @@ -47,7 +48,7 @@ export const playbackIdsCommand = new Command() } } else { // Just return the playback ID info - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify(playbackIdInfo, null, 2)); } else { console.log(`Playback ID: ${playbackIdInfo.id}`); diff --git a/src/commands/playback-restrictions/create.ts b/src/commands/playback-restrictions/create.ts index 23b1181..ba8b848 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 { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; @@ -41,7 +42,7 @@ export const createCommand = new Command() }, }); - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify(restriction, null, 2)); } else { console.log('Playback restriction created successfully'); diff --git a/src/commands/playback-restrictions/delete.ts b/src/commands/playback-restrictions/delete.ts index be82a0e..56451dc 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 { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; import { confirmPrompt } from '@/lib/prompt.ts'; @@ -18,7 +19,7 @@ export const deleteCommand = new Command() const mux = await createAuthenticatedMuxClient(); if (!options.force) { - if (options.json) { + if (wantsJson(options)) { throw new Error( 'Deletion requires --force flag when using --json output', ); @@ -37,7 +38,7 @@ export const deleteCommand = new Command() await mux.video.playbackRestrictions.delete(restrictionId); - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify({ success: true, restrictionId }, null, 2)); } else { console.log( diff --git a/src/commands/playback-restrictions/get.ts b/src/commands/playback-restrictions/get.ts index 6cd0813..e0b1fba 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 { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; @@ -17,7 +18,7 @@ export const getCommand = new Command() const restriction = await mux.video.playbackRestrictions.retrieve(restrictionId); - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify(restriction, null, 2)); } else { console.log(`Restriction ID: ${restriction.id}`); diff --git a/src/commands/playback-restrictions/list.ts b/src/commands/playback-restrictions/list.ts index 3d3f6dd..5b03353 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 { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; @@ -26,7 +27,7 @@ export const listCommand = new Command() page: options.page, }); - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify(restrictions, null, 2)); return; } diff --git a/src/commands/playback-restrictions/update-referrer.ts b/src/commands/playback-restrictions/update-referrer.ts index 97730ab..61e5ba9 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 { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; @@ -32,7 +33,7 @@ export const updateReferrerCommand = new Command() }, ); - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify(restriction, null, 2)); } else { console.log('Referrer restriction updated successfully'); diff --git a/src/commands/playback-restrictions/update-user-agent.ts b/src/commands/playback-restrictions/update-user-agent.ts index bcf5d1d..7a35897 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 { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; @@ -34,7 +35,7 @@ export const updateUserAgentCommand = new Command() }, ); - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify(restriction, null, 2)); } else { console.log('User agent restriction updated successfully'); diff --git a/src/commands/robots/_shared.test.ts b/src/commands/robots/_shared.test.ts index 17ee577..4ba01c1 100644 --- a/src/commands/robots/_shared.test.ts +++ b/src/commands/robots/_shared.test.ts @@ -1,6 +1,16 @@ -import { describe, expect, test } from 'bun:test'; +import { + afterEach, + beforeEach, + describe, + expect, + type Mock, + mock, + spyOn, + test, +} from 'bun:test'; +import type Mux from '@mux/mux-node'; import type { AnyRobotsJob } from './_shared.ts'; -import { assertJobCompleted } from './_shared.ts'; +import { assertJobCompleted, pollForRobotsJob } from './_shared.ts'; const baseJob = { id: 'rjob_xyz', @@ -54,3 +64,91 @@ describe('assertJobCompleted', () => { ).toThrow(/rjob_abc123/); }); }); + +function makeMuxStub(statuses: string[]): Mux { + let call = 0; + const retrieve = mock(() => { + const status = statuses[Math.min(call, statuses.length - 1)]; + call++; + return Promise.resolve({ + ...baseJob, + id: 'rjob_test123', + status, + } as AnyRobotsJob); + }); + return { + robotsPreview: { + jobs: { summarize: { retrieve } }, + }, + } as unknown as Mux; +} + +describe('pollForRobotsJob', () => { + let stderrSpy: Mock; + + beforeEach(() => { + stderrSpy = spyOn(process.stderr, 'write').mockImplementation(() => true); + }); + + afterEach(() => { + stderrSpy?.mockRestore(); + }); + + test('returns the job once it reaches a terminal status', async () => { + const mux = makeMuxStub(['pending', 'processing', 'completed']); + + const job = await pollForRobotsJob(mux, 'summarize', 'rjob_test123', { + json: true, + pollIntervalMs: 1, + }); + + expect(job.status).toBe('completed'); + }); + + test('in JSON mode, polling is silent so stdout stays a single JSON result', async () => { + const stdoutSpy = spyOn(process.stdout, 'write').mockImplementation( + () => true, + ); + const logSpy = spyOn(console, 'log').mockImplementation(() => {}); + try { + const mux = makeMuxStub(['pending', 'processing', 'completed']); + await pollForRobotsJob(mux, 'summarize', 'rjob_test123', { + json: true, + pollIntervalMs: 1, + }); + + expect(stderrSpy).not.toHaveBeenCalled(); + expect(stdoutSpy).not.toHaveBeenCalled(); + expect(logSpy).not.toHaveBeenCalled(); + } finally { + stdoutSpy.mockRestore(); + logSpy.mockRestore(); + } + }); + + test('in pretty mode, keeps the existing dots progress on stderr', async () => { + const mux = makeMuxStub(['processing', 'completed']); + + await pollForRobotsJob(mux, 'summarize', 'rjob_test123', { + json: false, + pollIntervalMs: 1, + }); + + const output = stderrSpy.mock.calls.map((c) => String(c[0])).join(''); + expect(output).toContain('Waiting for job to complete'); + expect(output).toContain('.'); + expect(output).toContain('completed!'); + }); + + test('resolves immediately when the job is already terminal', async () => { + const mux = makeMuxStub(['errored']); + + const job = await pollForRobotsJob(mux, 'summarize', 'rjob_test123', { + json: true, + pollIntervalMs: 1, + }); + + expect(job.status).toBe('errored'); + expect(stderrSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/src/commands/robots/_shared.ts b/src/commands/robots/_shared.ts index 80c99d3..9f76ec0 100644 --- a/src/commands/robots/_shared.ts +++ b/src/commands/robots/_shared.ts @@ -89,32 +89,36 @@ export function assertJobCompleted(job: AnyRobotsJob): void { throw new Error(`Job ${job.id} ended with status "${job.status}"${details}`); } +export interface PollOptions { + json: boolean; + pollIntervalMs?: number; +} + export async function pollForRobotsJob( mux: Mux, workflow: RobotsWorkflow, jobId: string, - jsonOutput: boolean, + { json, pollIntervalMs = 3000 }: PollOptions, ): Promise { - const POLL_INTERVAL_MS = 3000; const MAX_POLL_TIME_MS = 15 * 60 * 1000; const start = Date.now(); - if (!jsonOutput) { + if (!json) { process.stderr.write('Waiting for job to complete'); } while (Date.now() - start < MAX_POLL_TIME_MS) { const job = await retrieveRobotsJob(mux, workflow, jobId); if (isTerminalStatus(job.status)) { - if (!jsonOutput) { + if (!json) { process.stderr.write(` ${job.status}!\n`); } return job; } - if (!jsonOutput) { + if (!json) { process.stderr.write('.'); } - await sleep(POLL_INTERVAL_MS); + await sleep(pollIntervalMs); } throw new Error(`Timed out waiting for job ${jobId} to complete`); diff --git a/src/commands/robots/ask-questions.ts b/src/commands/robots/ask-questions.ts index 069ec97..27e0933 100644 --- a/src/commands/robots/ask-questions.ts +++ b/src/commands/robots/ask-questions.ts @@ -3,6 +3,7 @@ import type { AskQuestionCreateParams, AskQuestionsJobParameters, } from '@mux/mux-node/resources/robots-preview/jobs'; +import { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; import { @@ -119,22 +120,19 @@ export const askQuestionsCommand: Command = new Command() const mux = await createAuthenticatedMuxClient(); let job = await mux.robotsPreview.jobs.askQuestions.create(body); - if (!options.json) { + if (!wantsJson(options)) { console.log('Ask questions job created'); console.log(` Job ID: ${job.id}`); console.log(` Status: ${job.status}`); } if (options.wait) { - job = (await pollForRobotsJob( - mux, - 'ask-questions', - job.id, - Boolean(options.json), - )) as typeof job; + job = (await pollForRobotsJob(mux, 'ask-questions', job.id, { + json: wantsJson(options), + })) as typeof job; } - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify(job, null, 2)); } else if (options.wait && job.outputs) { console.log('Outputs:'); diff --git a/src/commands/robots/cancel.ts b/src/commands/robots/cancel.ts index eeb3143..4e24cd4 100644 --- a/src/commands/robots/cancel.ts +++ b/src/commands/robots/cancel.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; @@ -15,7 +16,7 @@ export const cancelCommand = new Command() const mux = await createAuthenticatedMuxClient(); const job = await mux.robotsPreview.jobs.cancel(jobId); - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify(job, null, 2)); return; } diff --git a/src/commands/robots/find-key-moments.ts b/src/commands/robots/find-key-moments.ts index 0e9d811..01ca029 100644 --- a/src/commands/robots/find-key-moments.ts +++ b/src/commands/robots/find-key-moments.ts @@ -3,6 +3,7 @@ import type { FindKeyMomentCreateParams, FindKeyMomentsJobParameters, } from '@mux/mux-node/resources/robots-preview/jobs'; +import { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; import { @@ -103,22 +104,19 @@ export const findKeyMomentsCommand: Command = new Command() const mux = await createAuthenticatedMuxClient(); let job = await mux.robotsPreview.jobs.findKeyMoments.create(body); - if (!options.json) { + if (!wantsJson(options)) { console.log('Find key moments job created'); console.log(` Job ID: ${job.id}`); console.log(` Status: ${job.status}`); } if (options.wait) { - job = (await pollForRobotsJob( - mux, - 'find-key-moments', - job.id, - Boolean(options.json), - )) as typeof job; + job = (await pollForRobotsJob(mux, 'find-key-moments', job.id, { + json: wantsJson(options), + })) as typeof job; } - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify(job, null, 2)); } else if (options.wait && job.outputs) { console.log('Outputs:'); diff --git a/src/commands/robots/generate-chapters.ts b/src/commands/robots/generate-chapters.ts index 69ecfce..9be7e6c 100644 --- a/src/commands/robots/generate-chapters.ts +++ b/src/commands/robots/generate-chapters.ts @@ -3,6 +3,7 @@ import type { GenerateChapterCreateParams, GenerateChaptersJobParameters, } from '@mux/mux-node/resources/robots-preview/jobs'; +import { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; import { @@ -101,22 +102,19 @@ export const generateChaptersCommand: Command = new Command() const mux = await createAuthenticatedMuxClient(); let job = await mux.robotsPreview.jobs.generateChapters.create(body); - if (!options.json) { + if (!wantsJson(options)) { console.log('Generate chapters job created'); console.log(` Job ID: ${job.id}`); console.log(` Status: ${job.status}`); } if (options.wait) { - job = (await pollForRobotsJob( - mux, - 'generate-chapters', - job.id, - Boolean(options.json), - )) as typeof job; + job = (await pollForRobotsJob(mux, 'generate-chapters', job.id, { + json: wantsJson(options), + })) as typeof job; } - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify(job, null, 2)); } else if (options.wait && job.outputs) { console.log('Outputs:'); diff --git a/src/commands/robots/get.ts b/src/commands/robots/get.ts index 0dd95fb..e967062 100644 --- a/src/commands/robots/get.ts +++ b/src/commands/robots/get.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { formatCreatedAt } from '@/lib/formatters.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; @@ -42,7 +43,7 @@ export const getCommand = new Command() const mux = await createAuthenticatedMuxClient(); const job = await retrieveRobotsJob(mux, workflow, jobId); - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify(job, null, 2)); return; } diff --git a/src/commands/robots/list.ts b/src/commands/robots/list.ts index a26280f..cce597e 100644 --- a/src/commands/robots/list.ts +++ b/src/commands/robots/list.ts @@ -1,5 +1,6 @@ import { Command } from '@cliffy/command'; import type { JobListParams } from '@mux/mux-node/resources/robots-preview/jobs'; +import { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { formatCreatedAt } from '@/lib/formatters.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; @@ -46,7 +47,7 @@ export const listCommand = new Command() const response = await mux.robotsPreview.jobs.list(params); const data = response.data ?? []; - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify({ data }, null, 2)); return; } diff --git a/src/commands/robots/moderate.ts b/src/commands/robots/moderate.ts index 572a1c3..09c40fd 100644 --- a/src/commands/robots/moderate.ts +++ b/src/commands/robots/moderate.ts @@ -3,6 +3,7 @@ import type { ModerateCreateParams, ModerateJobParameters, } from '@mux/mux-node/resources/robots-preview/jobs'; +import { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; import { @@ -133,22 +134,19 @@ export const moderateCommand: Command = new Command() const mux = await createAuthenticatedMuxClient(); let job = await mux.robotsPreview.jobs.moderate.create(body); - if (!options.json) { + if (!wantsJson(options)) { console.log('Moderate job created'); console.log(` Job ID: ${job.id}`); console.log(` Status: ${job.status}`); } if (options.wait) { - job = (await pollForRobotsJob( - mux, - 'moderate', - job.id, - Boolean(options.json), - )) as typeof job; + job = (await pollForRobotsJob(mux, 'moderate', job.id, { + json: wantsJson(options), + })) as typeof job; } - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify(job, null, 2)); } else if (options.wait && job.outputs) { console.log('Outputs:'); diff --git a/src/commands/robots/summarize.ts b/src/commands/robots/summarize.ts index 5839929..d0719fb 100644 --- a/src/commands/robots/summarize.ts +++ b/src/commands/robots/summarize.ts @@ -3,6 +3,7 @@ import type { SummarizeCreateParams, SummarizeJobParameters, } from '@mux/mux-node/resources/robots-preview/jobs'; +import { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; import { @@ -149,22 +150,19 @@ export const summarizeCommand: Command = new Command() const mux = await createAuthenticatedMuxClient(); let job = await mux.robotsPreview.jobs.summarize.create(body); - if (!options.json) { + if (!wantsJson(options)) { console.log('Summarize job created'); console.log(` Job ID: ${job.id}`); console.log(` Status: ${job.status}`); } if (options.wait) { - job = (await pollForRobotsJob( - mux, - 'summarize', - job.id, - Boolean(options.json), - )) as typeof job; + job = (await pollForRobotsJob(mux, 'summarize', job.id, { + json: wantsJson(options), + })) as typeof job; } - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify(job, null, 2)); } else if (options.wait && job.outputs) { console.log('Outputs:'); diff --git a/src/commands/robots/translate-captions.ts b/src/commands/robots/translate-captions.ts index 20eb466..5edf62b 100644 --- a/src/commands/robots/translate-captions.ts +++ b/src/commands/robots/translate-captions.ts @@ -3,6 +3,7 @@ import type { TranslateCaptionCreateParams, TranslateCaptionsJobParameters, } from '@mux/mux-node/resources/robots-preview/jobs'; +import { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; import { @@ -93,22 +94,19 @@ export const translateCaptionsCommand: Command = new Command() const mux = await createAuthenticatedMuxClient(); let job = await mux.robotsPreview.jobs.translateCaptions.create(body); - if (!options.json) { + if (!wantsJson(options)) { console.log('Translate captions job created'); console.log(` Job ID: ${job.id}`); console.log(` Status: ${job.status}`); } if (options.wait) { - job = (await pollForRobotsJob( - mux, - 'translate-captions', - job.id, - Boolean(options.json), - )) as typeof job; + job = (await pollForRobotsJob(mux, 'translate-captions', job.id, { + json: wantsJson(options), + })) as typeof job; } - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify(job, null, 2)); } else if (options.wait && job.outputs) { console.log('Outputs:'); diff --git a/src/commands/sign.test.ts b/src/commands/sign.test.ts index b7698ad..c612f28 100644 --- a/src/commands/sign.test.ts +++ b/src/commands/sign.test.ts @@ -7,10 +7,15 @@ import { spyOn, test, } from 'bun:test'; +import { generateKeyPairSync } from 'node:crypto'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { setEnvironment } from '@/lib/config.ts'; import { signCommand } from './sign.ts'; -// Note: These tests focus on CLI flag parsing and basic command structure -// They do NOT test the actual JWT signing logic +// Note: Command execution tests sign real JWTs with a generated RSA key; +// signing is local, so no network access is involved. describe('mux sign command', () => { let exitSpy: Mock; @@ -153,17 +158,132 @@ describe('mux sign command', () => { }); describe('Command execution', () => { - test('fails when signing keys not configured', async () => { - // This will fail at the signing key check - // We're just verifying the command structure is correct + let testConfigDir: string; + let originalXdgConfigHome: string | undefined; + const originalEnvVars: Record = {}; + const ENV_KEYS = [ + 'MUX_TOKEN_ID', + 'MUX_TOKEN_SECRET', + 'MUX_SIGNING_KEY', + 'MUX_PRIVATE_KEY', + ]; + + // A real RSA key so mux.jwt can sign locally (no network involved) + const { privateKey: privateKeyPem } = generateKeyPairSync('rsa', { + modulusLength: 2048, + publicKeyEncoding: { type: 'pkcs1', format: 'pem' }, + privateKeyEncoding: { type: 'pkcs1', format: 'pem' }, + }); + const privateKeyBase64 = Buffer.from(privateKeyPem).toString('base64'); + + // The Mux SDK places the key id in the payload's kid claim + function decodeJwtPayload(token: string): { kid?: string } { + return JSON.parse( + Buffer.from(token.split('.')[1], 'base64url').toString(), + ); + } + + function tokenFromOutput(): string { + return String(consoleLogSpy.mock.calls.at(-1)?.[0]).trim(); + } + + beforeEach(async () => { + testConfigDir = await mkdtemp(join(tmpdir(), 'mux-cli-test-')); + originalXdgConfigHome = process.env.XDG_CONFIG_HOME; + process.env.XDG_CONFIG_HOME = testConfigDir; + for (const key of ENV_KEYS) { + originalEnvVars[key] = process.env[key]; + delete process.env[key]; + } + }); + + afterEach(async () => { + if (originalXdgConfigHome === undefined) { + delete process.env.XDG_CONFIG_HOME; + } else { + process.env.XDG_CONFIG_HOME = originalXdgConfigHome; + } + for (const key of ENV_KEYS) { + if (originalEnvVars[key] === undefined) delete process.env[key]; + else process.env[key] = originalEnvVars[key]; + } + await rm(testConfigDir, { recursive: true, force: true }); + }); + + test('error mentions env vars when signing keys are not configured anywhere', async () => { + await setEnvironment('default', { + tokenId: 'stored_id', + tokenSecret: 'stored_secret', + }); + try { await signCommand.parse(['test-playback-id']); } catch (_error) { - // Expected to fail + // Expected to fail via mocked process.exit } - // The command should fail during execution, not during parsing - expect(true).toBe(true); + expect(exitSpy).toHaveBeenCalledWith(1); + const errorMessage = String(consoleErrorSpy.mock.calls[0][0]); + expect(errorMessage).toContain('mux signing-keys create'); + expect(errorMessage).toContain('MUX_SIGNING_KEY'); + expect(errorMessage).toContain('MUX_PRIVATE_KEY'); + }); + + test('signs with MUX_SIGNING_KEY/MUX_PRIVATE_KEY env vars and no stored config', async () => { + process.env.MUX_TOKEN_ID = 'env_id'; + process.env.MUX_TOKEN_SECRET = 'env_secret'; + process.env.MUX_SIGNING_KEY = 'key_from_env'; + process.env.MUX_PRIVATE_KEY = privateKeyBase64; + + await signCommand.parse(['test-playback-id', '--token-only']); + + const payload = decodeJwtPayload(tokenFromOutput()); + expect(payload.kid).toBe('key_from_env'); + }); + + test('env var signing keys take precedence over stored config keys', async () => { + await setEnvironment('default', { + tokenId: 'stored_id', + tokenSecret: 'stored_secret', + signingKeyId: 'key_from_config', + signingPrivateKey: privateKeyBase64, + }); + process.env.MUX_SIGNING_KEY = 'key_from_env'; + process.env.MUX_PRIVATE_KEY = privateKeyBase64; + + await signCommand.parse(['test-playback-id', '--token-only']); + + const payload = decodeJwtPayload(tokenFromOutput()); + expect(payload.kid).toBe('key_from_env'); + }); + + test('still signs with stored config keys when env vars are absent', async () => { + await setEnvironment('default', { + tokenId: 'stored_id', + tokenSecret: 'stored_secret', + signingKeyId: 'key_from_config', + signingPrivateKey: privateKeyBase64, + }); + + await signCommand.parse(['test-playback-id', '--token-only']); + + const payload = decodeJwtPayload(tokenFromOutput()); + expect(payload.kid).toBe('key_from_config'); + }); + + test('ignores env signing keys when only one of the pair is set', async () => { + await setEnvironment('default', { + tokenId: 'stored_id', + tokenSecret: 'stored_secret', + signingKeyId: 'key_from_config', + signingPrivateKey: privateKeyBase64, + }); + process.env.MUX_SIGNING_KEY = 'key_from_env'; + + await signCommand.parse(['test-playback-id', '--token-only']); + + const payload = decodeJwtPayload(tokenFromOutput()); + expect(payload.kid).toBe('key_from_config'); }); }); }); diff --git a/src/commands/sign.ts b/src/commands/sign.ts index 68f3e79..17eab1b 100644 --- a/src/commands/sign.ts +++ b/src/commands/sign.ts @@ -1,6 +1,7 @@ import { Command } from '@cliffy/command'; -import Mux from '@mux/mux-node'; +import { wantsJson } from '@/lib/context.ts'; import { getCurrentEnvironment } from '../lib/config.ts'; +import { createAuthenticatedMuxClient } from '../lib/mux.ts'; import { getSignedUrl } from '../lib/urls.ts'; interface SignOptions { @@ -120,34 +121,36 @@ export const signCommand = new Command() ) .action(async (options: SignOptions, playbackId: string) => { try { - // Get current environment to retrieve signing keys - const currentEnv = await getCurrentEnvironment(); - if (!currentEnv) { - throw new Error( - 'No environment configured.\n\n' + - 'Signing requires an authenticated environment.\n' + - "Please run 'mux login' first.", - ); + // Resolve signing key material. + // Priority: MUX_SIGNING_KEY/MUX_PRIVATE_KEY env vars > stored config + // (consistent with how MUX_TOKEN_ID/MUX_TOKEN_SECRET take precedence). + // Env vars are only used when both are set and non-empty. + let keyId: string | undefined; + let keySecret: string | undefined; + const envSigningKey = process.env.MUX_SIGNING_KEY; + const envPrivateKey = process.env.MUX_PRIVATE_KEY; + if (envSigningKey && envPrivateKey) { + keyId = envSigningKey; + keySecret = envPrivateKey; + } else { + const currentEnv = await getCurrentEnvironment(); + keyId = currentEnv?.environment.signingKeyId; + keySecret = currentEnv?.environment.signingPrivateKey; } - // Check if signing keys are configured - if ( - !currentEnv.environment.signingKeyId || - !currentEnv.environment.signingPrivateKey - ) { + if (!keyId || !keySecret) { throw new Error( - 'Signing keys not configured for this environment.\n\n' + + 'Signing keys not configured.\n\n' + 'To create and configure a signing key, run:\n' + ' mux signing-keys create\n\n' + - 'This will create a new signing key and automatically configure it for your current environment.', + 'This creates a new signing key and saves it to your current environment.\n' + + 'Alternatively, set the MUX_SIGNING_KEY and MUX_PRIVATE_KEY environment variables.', ); } - // Create Mux client - const mux = new Mux({ - tokenId: currentEnv.environment.tokenId, - tokenSecret: currentEnv.environment.tokenSecret, - }); + // Create Mux client (signing is local; credentials resolve from env + // vars or stored config like every other command) + const mux = await createAuthenticatedMuxClient(); const tokenType = options.type && isValidTokenType(options.type) ? options.type : 'video'; @@ -156,8 +159,8 @@ export const signCommand = new Command() // Build sign options, casting to work around SDK's strict Record typing const signOptions = { - keyId: currentEnv.environment.signingKeyId, - keySecret: currentEnv.environment.signingPrivateKey, + keyId, + keySecret, type: tokenType, expiration: options.expiration || '7d', ...(params ? { params } : {}), @@ -173,7 +176,7 @@ export const signCommand = new Command() // Output based on format options if (options.tokenOnly) { console.log(token); - } else if (options.json) { + } else if (wantsJson(options)) { console.log( JSON.stringify( { @@ -195,7 +198,7 @@ export const signCommand = new Command() const errorMessage = error instanceof Error ? error.message : String(error); - if (options.json) { + if (wantsJson(options)) { console.error(JSON.stringify({ error: errorMessage }, null, 2)); } else { console.error(`Error: ${errorMessage}`); diff --git a/src/commands/signing-keys/create.test.ts b/src/commands/signing-keys/create.test.ts index fecb31e..4dc7092 100644 --- a/src/commands/signing-keys/create.test.ts +++ b/src/commands/signing-keys/create.test.ts @@ -1,11 +1,18 @@ -import { describe, expect, test } from 'bun:test'; +import { + afterEach, + beforeEach, + describe, + expect, + type Mock, + spyOn, + test, +} from 'bun:test'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { getEnvironment, setEnvironment } from '@/lib/config.ts'; import { createCommand } from './create.ts'; -// Note: These tests focus on CLI flag parsing and basic command structure -// They do NOT test the actual Mux API integration or config file writes -// Execution tests are omitted because the command uses interactive prompts -// that depend on local config state - describe('mux signing-keys create command', () => { describe('Command metadata', () => { test('has correct command description', () => { @@ -27,4 +34,172 @@ describe('mux signing-keys create command', () => { expect(args.length).toBe(0); }); }); + + describe('Action', () => { + let testConfigDir: string; + let originalXdgConfigHome: string | undefined; + let originalTokenId: string | undefined; + let originalTokenSecret: string | undefined; + let logSpy: Mock; + let errorSpy: Mock; + let exitSpy: Mock; + let fetchSpy: Mock; + + function mockApi(whoamiEnvironmentId: string) { + fetchSpy = spyOn(globalThis, 'fetch').mockImplementation((async ( + input: string | URL | Request, + ) => { + const url = String(input instanceof Request ? input.url : input); + const jsonHeaders = { 'Content-Type': 'application/json' }; + if (url.includes('/system/v1/whoami')) { + return new Response( + JSON.stringify({ + data: { environment_id: whoamiEnvironmentId }, + }), + { status: 200, headers: jsonHeaders }, + ); + } + if (url.includes('signing-keys')) { + return new Response( + JSON.stringify({ + data: { + id: 'key_new_123', + private_key: 'cHJpdmF0ZS1rZXktcGVt', + created_at: '1721500000', + }, + }), + { status: 200, headers: jsonHeaders }, + ); + } + throw new Error(`Unexpected fetch in test: ${url}`); + }) as unknown as typeof fetch); + } + + function jsonOutput(): Record { + const output = logSpy.mock.calls.map((c) => String(c[0])).join('\n'); + return JSON.parse(output); + } + + beforeEach(async () => { + testConfigDir = await mkdtemp(join(tmpdir(), 'mux-cli-test-')); + originalXdgConfigHome = process.env.XDG_CONFIG_HOME; + originalTokenId = process.env.MUX_TOKEN_ID; + originalTokenSecret = process.env.MUX_TOKEN_SECRET; + process.env.XDG_CONFIG_HOME = testConfigDir; + delete process.env.MUX_TOKEN_ID; + delete process.env.MUX_TOKEN_SECRET; + + logSpy = spyOn(console, 'log').mockImplementation(() => {}); + errorSpy = spyOn(console, 'error').mockImplementation(() => {}); + exitSpy = spyOn(process, 'exit').mockImplementation((() => { + throw new Error('process.exit called'); + }) as never); + }); + + afterEach(async () => { + if (originalXdgConfigHome === undefined) { + delete process.env.XDG_CONFIG_HOME; + } else { + process.env.XDG_CONFIG_HOME = originalXdgConfigHome; + } + if (originalTokenId === undefined) delete process.env.MUX_TOKEN_ID; + else process.env.MUX_TOKEN_ID = originalTokenId; + if (originalTokenSecret === undefined) + delete process.env.MUX_TOKEN_SECRET; + else process.env.MUX_TOKEN_SECRET = originalTokenSecret; + logSpy?.mockRestore(); + errorSpy?.mockRestore(); + exitSpy?.mockRestore(); + fetchSpy?.mockRestore(); + await rm(testConfigDir, { recursive: true, force: true }); + }); + + test('saves the key to the stored environment when credentials come from config', async () => { + await setEnvironment('default', { + tokenId: 'stored_id', + tokenSecret: 'stored_secret', + environmentId: 'env_stored_123', + }); + mockApi('env_stored_123'); + + await createCommand.parse(['--json']); + + const saved = await getEnvironment('default'); + expect(saved?.signingKeyId).toBe('key_new_123'); + expect(saved?.signingPrivateKey).toBe('cHJpdmF0ZS1rZXktcGVt'); + const parsed = jsonOutput(); + expect(parsed.id).toBe('key_new_123'); + expect(parsed.saved).toBe(true); + expect(parsed.private_key).toBeUndefined(); + }); + + test('emits the private key once with saved: false when only env vars are set', async () => { + process.env.MUX_TOKEN_ID = 'env_id'; + process.env.MUX_TOKEN_SECRET = 'env_secret'; + mockApi('env_from_vars'); + + await createCommand.parse(['--json']); + + const parsed = jsonOutput(); + expect(parsed.id).toBe('key_new_123'); + expect(parsed.saved).toBe(false); + expect(parsed.private_key).toBe('cHJpdmF0ZS1rZXktcGVt'); + expect(String(parsed.note)).toContain('MUX_SIGNING_KEY'); + }); + + test('does not save to a stored environment that env var credentials do not match', async () => { + await setEnvironment('default', { + tokenId: 'stored_id', + tokenSecret: 'stored_secret', + environmentId: 'env_stored_123', + signingKeyId: 'key_existing', + signingPrivateKey: 'existing_private_key', + }); + process.env.MUX_TOKEN_ID = 'env_id'; + process.env.MUX_TOKEN_SECRET = 'env_secret'; + mockApi('env_other_456'); + + await createCommand.parse(['--json']); + + const saved = await getEnvironment('default'); + expect(saved?.signingKeyId).toBe('key_existing'); + expect(saved?.signingPrivateKey).toBe('existing_private_key'); + const parsed = jsonOutput(); + expect(parsed.saved).toBe(false); + expect(parsed.private_key).toBe('cHJpdmF0ZS1rZXktcGVt'); + }); + + test('saves to the stored environment when env var credentials match it', async () => { + await setEnvironment('default', { + tokenId: 'stored_id', + tokenSecret: 'stored_secret', + environmentId: 'env_same_123', + }); + process.env.MUX_TOKEN_ID = 'env_id'; + process.env.MUX_TOKEN_SECRET = 'env_secret'; + mockApi('env_same_123'); + + await createCommand.parse(['--json']); + + const saved = await getEnvironment('default'); + expect(saved?.signingKeyId).toBe('key_new_123'); + const parsed = jsonOutput(); + expect(parsed.saved).toBe(true); + }); + + test('prints the private key with guidance in pretty mode when not saved', async () => { + process.env.MUX_TOKEN_ID = 'env_id'; + process.env.MUX_TOKEN_SECRET = 'env_secret'; + mockApi('env_from_vars'); + + await createCommand.parse([]); + + const output = logSpy.mock.calls.map((c) => String(c[0])).join('\n'); + expect(output).toContain('key_new_123'); + expect(output).toContain('cHJpdmF0ZS1rZXktcGVt'); + expect(output).toContain('MUX_SIGNING_KEY'); + expect(output).toContain('MUX_PRIVATE_KEY'); + expect(output).toContain('not saved'); + }); + }); }); diff --git a/src/commands/signing-keys/create.ts b/src/commands/signing-keys/create.ts index d60de97..3680298 100644 --- a/src/commands/signing-keys/create.ts +++ b/src/commands/signing-keys/create.ts @@ -1,13 +1,20 @@ import { Command } from '@cliffy/command'; -import { getCurrentEnvironment, setEnvironment } from '@/lib/config.ts'; +import { setEnvironment } from '@/lib/config.ts'; +import { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; -import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; +import { + createAuthenticatedMuxClient, + resolveActiveEnvironment, +} from '@/lib/mux.ts'; import { confirmPrompt } from '@/lib/prompt.ts'; interface CreateOptions { json?: boolean; } +const NOT_SAVED_NOTE = + 'No stored environment matches the active credentials, so the private key was not saved. Set MUX_SIGNING_KEY and MUX_PRIVATE_KEY to sign URLs with it.'; + export const createCommand = new Command() .description( 'Create a signing key and save to current environment (private key only available at creation)', @@ -18,18 +25,16 @@ export const createCommand = new Command() // Initialize authenticated Mux client const mux = await createAuthenticatedMuxClient(); - // Get current environment - const currentEnv = await getCurrentEnvironment(); - if (!currentEnv) { - throw new Error( - "No environment configured. Please run 'mux login' first.", - ); - } + // The key is only saved when the stored environment matches the active + // credentials; otherwise it would desync from the environment the key + // was actually created in. + const active = await resolveActiveEnvironment(); + const target = active.stored; // Check if a signing key already exists - if (currentEnv.environment.signingKeyId && !options.json) { + if (target?.environment.signingKeyId && !wantsJson(options)) { const confirmed = await confirmPrompt({ - message: `Environment '${currentEnv.name}' already has a signing key (${currentEnv.environment.signingKeyId}). Replace it?`, + message: `Environment '${target.name}' already has a signing key (${target.environment.signingKeyId}). Replace it?`, default: false, }); @@ -47,37 +52,67 @@ export const createCommand = new Command() const privateKey = signingKey.private_key; const createdAt = signingKey.created_at; - // Store the signing key in the current environment config - // Wrap in try-catch to prevent privateKey from leaking in error messages - try { - await setEnvironment(currentEnv.name, { - ...currentEnv.environment, - signingKeyId: keyId, - signingPrivateKey: privateKey, - }); - } catch (err) { - throw new Error( - `Failed to save signing key to config: ${err instanceof Error ? err.message : 'Unknown error'}`, - ); + if (target) { + // Store the signing key in the matching environment config + // Wrap in try-catch to prevent privateKey from leaking in error messages + try { + await setEnvironment(target.name, { + ...target.environment, + signingKeyId: keyId, + signingPrivateKey: privateKey, + }); + } catch (err) { + throw new Error( + `Failed to save signing key to config: ${err instanceof Error ? err.message : 'Unknown error'}`, + ); + } + + if (wantsJson(options)) { + console.log( + JSON.stringify( + { + id: keyId, + created_at: createdAt, + environment: target.name, + saved: true, + }, + null, + 2, + ), + ); + } else { + console.log( + `Signing key created and saved to environment: ${target.name}`, + ); + console.log(`Key ID: ${keyId}`); + } + return; } - if (options.json) { + // No matching stored environment: emit the private key once instead of + // persisting it. The API only returns it at creation time. + if (wantsJson(options)) { console.log( JSON.stringify( { id: keyId, created_at: createdAt, - environment: currentEnv.name, + private_key: privateKey, + saved: false, + note: NOT_SAVED_NOTE, }, null, 2, ), ); } else { - console.log( - `Signing key created and saved to environment: ${currentEnv.name}`, - ); - console.log(`Key ID: ${keyId}`); + console.log(`Signing key created: ${keyId}`); + console.log('Private key (base64, shown once, not saved):'); + console.log(privateKey); + console.log(); + console.log(NOT_SAVED_NOTE); + console.log(` export MUX_SIGNING_KEY=${keyId}`); + console.log(' export MUX_PRIVATE_KEY='); } } catch (error) { await handleCommandError(error, 'signing-keys', 'create', options); diff --git a/src/commands/signing-keys/delete.ts b/src/commands/signing-keys/delete.ts index 745edc1..9ebf31a 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 { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; import { confirmPrompt } from '@/lib/prompt.ts'; @@ -32,7 +33,7 @@ export const deleteCommand = new Command() } // Show warning if key is in use - if (affectedEnvironments.length > 0 && !options.json) { + if (affectedEnvironments.length > 0 && !wantsJson(options)) { console.log( `WARNING: This signing key is currently configured in environment${affectedEnvironments.length > 1 ? 's' : ''}: ${affectedEnvironments.join(', ')}`, ); @@ -45,7 +46,7 @@ export const deleteCommand = new Command() // Confirm deletion unless --force flag is provided if (!options.force) { // For JSON mode, require explicit --force flag for safety - if (options.json) { + if (wantsJson(options)) { throw new Error( 'Deletion requires --force flag when using --json output', ); @@ -57,7 +58,7 @@ export const deleteCommand = new Command() }); if (!confirmed) { - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify({ cancelled: true }, null, 2)); } else { console.log('Deletion cancelled.'); @@ -81,7 +82,7 @@ export const deleteCommand = new Command() } } - if (options.json) { + if (wantsJson(options)) { console.log( JSON.stringify( { diff --git a/src/commands/signing-keys/get.ts b/src/commands/signing-keys/get.ts index 77ad568..1a14b3d 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 { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; @@ -31,7 +32,7 @@ export const getCommand = new Command() } } - if (options.json) { + if (wantsJson(options)) { console.log( JSON.stringify( { diff --git a/src/commands/signing-keys/list.ts b/src/commands/signing-keys/list.ts index a0a66ab..2e6359c 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 { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; @@ -35,7 +36,7 @@ export const listCommand = new Command() } } - if (options.json) { + if (wantsJson(options)) { const data = []; for await (const key of signingKeys) { const envs = activeKeys.get(key.id); diff --git a/src/commands/transcription-vocabularies/create.ts b/src/commands/transcription-vocabularies/create.ts index cf8986e..42c763c 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 { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; @@ -44,7 +45,7 @@ export const createCommand = new Command() const vocabulary = await mux.video.transcriptionVocabularies.create(params); - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify(vocabulary, null, 2)); } else { console.log('Transcription vocabulary created successfully'); diff --git a/src/commands/transcription-vocabularies/delete.ts b/src/commands/transcription-vocabularies/delete.ts index 132dd6a..cc67717 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 { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; import { confirmPrompt } from '@/lib/prompt.ts'; @@ -20,7 +21,7 @@ export const deleteCommand = new Command() const mux = await createAuthenticatedMuxClient(); if (!options.force) { - if (options.json) { + if (wantsJson(options)) { throw new Error( 'Deletion requires --force flag when using --json output', ); @@ -39,7 +40,7 @@ export const deleteCommand = new Command() await mux.video.transcriptionVocabularies.delete(vocabularyId); - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify({ success: true, vocabularyId }, null, 2)); } else { console.log( diff --git a/src/commands/transcription-vocabularies/get.ts b/src/commands/transcription-vocabularies/get.ts index 996cd57..5297a3c 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 { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; @@ -17,7 +18,7 @@ export const getCommand = new Command() const vocabulary = await mux.video.transcriptionVocabularies.retrieve(vocabularyId); - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify(vocabulary, null, 2)); } else { console.log(`Vocabulary ID: ${vocabulary.id}`); diff --git a/src/commands/transcription-vocabularies/list.ts b/src/commands/transcription-vocabularies/list.ts index 101786a..aba7b0e 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 { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; @@ -26,7 +27,7 @@ export const listCommand = new Command() page: options.page, }); - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify(vocabularies, null, 2)); return; } diff --git a/src/commands/transcription-vocabularies/update.ts b/src/commands/transcription-vocabularies/update.ts index fd0c035..3b3cbde 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 { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; @@ -47,7 +48,7 @@ export const updateCommand = new Command() params, ); - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify(vocabulary, null, 2)); } else { console.log('Transcription vocabulary updated successfully'); diff --git a/src/commands/uploads/cancel.ts b/src/commands/uploads/cancel.ts index 928802f..7605546 100644 --- a/src/commands/uploads/cancel.ts +++ b/src/commands/uploads/cancel.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; import { confirmPrompt } from '@/lib/prompt.ts'; @@ -18,7 +19,7 @@ export const cancelCommand = new Command() const mux = await createAuthenticatedMuxClient(); if (!options.force) { - if (options.json) { + if (wantsJson(options)) { throw new Error( 'Cancellation requires --force flag when using --json output', ); @@ -37,7 +38,7 @@ export const cancelCommand = new Command() const upload = await mux.video.uploads.cancel(uploadId); - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify(upload, null, 2)); } else { console.log(`Upload ${uploadId} cancelled successfully`); diff --git a/src/commands/uploads/create.ts b/src/commands/uploads/create.ts index fc6c505..dd5c729 100644 --- a/src/commands/uploads/create.ts +++ b/src/commands/uploads/create.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; @@ -64,7 +65,7 @@ export const createCommand = new Command() const upload = await mux.video.uploads.create(params as never); - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify(upload, null, 2)); } else { console.log(`Upload created successfully`); diff --git a/src/commands/uploads/get.ts b/src/commands/uploads/get.ts index 11d4b2c..465f1b5 100644 --- a/src/commands/uploads/get.ts +++ b/src/commands/uploads/get.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; @@ -16,7 +17,7 @@ export const getCommand = new Command() const upload = await mux.video.uploads.retrieve(uploadId); - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify(upload, null, 2)); } else { console.log(`Upload ID: ${upload.id}`); diff --git a/src/commands/uploads/list.ts b/src/commands/uploads/list.ts index b7ff3f9..d225e70 100644 --- a/src/commands/uploads/list.ts +++ b/src/commands/uploads/list.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; @@ -26,7 +27,7 @@ export const listCommand = new Command() page: options.page, }); - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify(uploads, null, 2)); return; } diff --git a/src/commands/video-views/get.ts b/src/commands/video-views/get.ts index 228d866..5b8495a 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 { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; @@ -16,7 +17,7 @@ export const getCommand = new Command() const view = await mux.data.videoViews.retrieve(viewId); - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify(view, null, 2)); return; } diff --git a/src/commands/video-views/list.ts b/src/commands/video-views/list.ts index 52d9b24..fb76b7d 100644 --- a/src/commands/video-views/list.ts +++ b/src/commands/video-views/list.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { wantsJson } from '@/lib/context.ts'; import { buildDataFilterParams } from '@/lib/data-filters.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; @@ -77,7 +78,7 @@ export const listCommand = new Command() const response = await mux.data.videoViews.list(params as never); - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify(response, null, 2)); return; } diff --git a/src/commands/webhooks/events/EventsBrowserApp.tsx b/src/commands/webhooks/events/EventsBrowserApp.tsx index 5492c22..a68bfae 100644 --- a/src/commands/webhooks/events/EventsBrowserApp.tsx +++ b/src/commands/webhooks/events/EventsBrowserApp.tsx @@ -14,15 +14,14 @@ import { SelectList, type SelectListItem, } from '@/lib/tui/index.ts'; -import { - buildSignedHeaders, - getSigningSecretForCurrentEnv, -} from '@/lib/webhook-signing.ts'; +import { buildSignedHeaders, getSigningSecret } from '@/lib/webhook-signing.ts'; type View = 'list' | 'detail' | 'filter-type' | 'message'; interface EventsBrowserAppProps { environmentId: string; + /** Key for the local webhook signing secret (environment name or id). */ + signingSecretKey: string; forwardTo?: string; } @@ -35,6 +34,7 @@ function formatEventLabel(event: StoredEvent): string { export function EventsBrowserApp({ environmentId, + signingSecretKey, forwardTo, }: EventsBrowserAppProps) { const renderer = useRenderer(); @@ -73,7 +73,7 @@ export function EventsBrowserApp({ async (event: StoredEvent) => { if (!forwardTo) return; try { - const signingSecret = await getSigningSecretForCurrentEnv(); + const signingSecret = await getSigningSecret(signingSecretKey); const body = JSON.stringify(event.payload); const response = await fetch(forwardTo, { method: 'POST', @@ -97,7 +97,7 @@ export function EventsBrowserApp({ ); } }, - [forwardTo, showMessage], + [forwardTo, signingSecretKey, showMessage], ); useKeyboard((key) => { diff --git a/src/commands/webhooks/events/browse.ts b/src/commands/webhooks/events/browse.ts index 57175ae..d039d00 100644 --- a/src/commands/webhooks/events/browse.ts +++ b/src/commands/webhooks/events/browse.ts @@ -1,5 +1,6 @@ import { Command } from '@cliffy/command'; -import { getCurrentEnvironment, updateEnvironment } from '@/lib/config.ts'; +import { updateEnvironment } from '@/lib/config.ts'; +import { resolveActiveEnvironment } from '@/lib/mux.ts'; export const browseCommand = new Command() .description('Interactively browse, filter, and replay stored webhook events') @@ -17,23 +18,21 @@ export const browseCommand = new Command() process.exit(1); } - const env = await getCurrentEnvironment(); - if (!env) { - console.error("Not logged in. Please run 'mux login' to authenticate."); - process.exit(1); - } - - const environmentId = env.environment.environmentId ?? env.name; + const active = await resolveActiveEnvironment(); + const environmentId = active.environmentId; // Use provided --forward-to, or fall back to the saved URL - const forwardTo = options.forwardTo ?? env.environment.forwardUrl; + const forwardTo = + options.forwardTo ?? active.stored?.environment.forwardUrl; - // Save the forward URL if a new one was provided + // Save the forward URL if a new one was provided. Only persisted when + // the stored environment matches the active credentials. if ( options.forwardTo && - options.forwardTo !== env.environment.forwardUrl + active.stored && + options.forwardTo !== active.stored.environment.forwardUrl ) { - await updateEnvironment(env.name, { + await updateEnvironment(active.stored.name, { forwardUrl: options.forwardTo, }); } @@ -49,6 +48,7 @@ export const browseCommand = new Command() root.render( React.createElement(EventsBrowserApp, { environmentId, + signingSecretKey: active.stored?.name ?? active.environmentId, forwardTo, }), ); diff --git a/src/commands/webhooks/events/list.test.ts b/src/commands/webhooks/events/list.test.ts new file mode 100644 index 0000000..e6056e3 --- /dev/null +++ b/src/commands/webhooks/events/list.test.ts @@ -0,0 +1,152 @@ +import { + afterEach, + beforeEach, + describe, + expect, + type Mock, + spyOn, + test, +} from 'bun:test'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { setEnvironment } from '@/lib/config.ts'; +import { appendEvent, closeDb } from '@/lib/events-store.ts'; +import { listCommand } from './list.ts'; + +function makeEvent(id: string, environmentId: string) { + return { + id, + type: 'video.asset.ready', + timestamp: new Date().toISOString(), + environmentId, + payload: { id }, + }; +} + +describe('mux webhooks events list command', () => { + let testDir: string; + let originalXdgConfigHome: string | undefined; + let originalXdgDataHome: string | undefined; + let originalTokenId: string | undefined; + let originalTokenSecret: string | undefined; + let logSpy: Mock; + let errorSpy: Mock; + let exitSpy: Mock; + let fetchSpy: Mock | undefined; + + function mockWhoami(environmentId: string) { + fetchSpy = spyOn(globalThis, 'fetch').mockImplementation( + (async () => + new Response( + JSON.stringify({ data: { environment_id: environmentId } }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + )) as unknown as typeof fetch, + ); + } + + beforeEach(async () => { + testDir = await mkdtemp(join(tmpdir(), 'mux-cli-test-')); + originalXdgConfigHome = process.env.XDG_CONFIG_HOME; + originalXdgDataHome = process.env.XDG_DATA_HOME; + originalTokenId = process.env.MUX_TOKEN_ID; + originalTokenSecret = process.env.MUX_TOKEN_SECRET; + process.env.XDG_CONFIG_HOME = testDir; + process.env.XDG_DATA_HOME = testDir; + delete process.env.MUX_TOKEN_ID; + delete process.env.MUX_TOKEN_SECRET; + closeDb(); + + logSpy = spyOn(console, 'log').mockImplementation(() => {}); + errorSpy = spyOn(console, 'error').mockImplementation(() => {}); + exitSpy = spyOn(process, 'exit').mockImplementation((() => { + throw new Error('process.exit called'); + }) as never); + }); + + afterEach(async () => { + if (originalXdgConfigHome === undefined) { + delete process.env.XDG_CONFIG_HOME; + } else { + process.env.XDG_CONFIG_HOME = originalXdgConfigHome; + } + if (originalXdgDataHome === undefined) { + delete process.env.XDG_DATA_HOME; + } else { + process.env.XDG_DATA_HOME = originalXdgDataHome; + } + if (originalTokenId === undefined) delete process.env.MUX_TOKEN_ID; + else process.env.MUX_TOKEN_ID = originalTokenId; + if (originalTokenSecret === undefined) delete process.env.MUX_TOKEN_SECRET; + else process.env.MUX_TOKEN_SECRET = originalTokenSecret; + logSpy?.mockRestore(); + errorSpy?.mockRestore(); + exitSpy?.mockRestore(); + fetchSpy?.mockRestore(); + fetchSpy = undefined; + closeDb(); + await rm(testDir, { recursive: true, force: true }); + }); + + function jsonOutput(): Array<{ id: string }> { + const output = logSpy.mock.calls.map((c) => String(c[0])).join('\n'); + return JSON.parse(output); + } + + test('lists events for the stored environment id when logged in', async () => { + await setEnvironment('default', { + tokenId: 'stored_id', + tokenSecret: 'stored_secret', + environmentId: 'env_stored_123', + }); + appendEvent(makeEvent('evt_stored', 'env_stored_123')); + appendEvent(makeEvent('evt_other', 'env_other_456')); + + await listCommand.parse(['--json']); + + const events = jsonOutput(); + expect(events.map((e) => e.id)).toEqual(['evt_stored']); + }); + + test('lists events for the env var credentials environment, not the stored one', async () => { + await setEnvironment('default', { + tokenId: 'stored_id', + tokenSecret: 'stored_secret', + environmentId: 'env_stored_123', + }); + appendEvent(makeEvent('evt_stored', 'env_stored_123')); + appendEvent(makeEvent('evt_other', 'env_other_456')); + process.env.MUX_TOKEN_ID = 'env_id'; + process.env.MUX_TOKEN_SECRET = 'env_secret'; + mockWhoami('env_other_456'); + + await listCommand.parse(['--json']); + + const events = jsonOutput(); + expect(events.map((e) => e.id)).toEqual(['evt_other']); + }); + + test('works with env var credentials and no stored config', async () => { + appendEvent(makeEvent('evt_env_only', 'env_from_vars')); + process.env.MUX_TOKEN_ID = 'env_id'; + process.env.MUX_TOKEN_SECRET = 'env_secret'; + mockWhoami('env_from_vars'); + + await listCommand.parse(['--json']); + + const events = jsonOutput(); + expect(events.map((e) => e.id)).toEqual(['evt_env_only']); + }); + + test('emits a JSON error when no credentials are available', async () => { + try { + await listCommand.parse(['--json']); + } catch (_error) { + // Expected to throw via mocked process.exit + } + + expect(exitSpy).toHaveBeenCalledWith(1); + const parsed = JSON.parse(String(errorSpy.mock.calls[0][0])); + expect(parsed.error).toMatch(/MUX_TOKEN_ID/); + }); +}); diff --git a/src/commands/webhooks/events/list.ts b/src/commands/webhooks/events/list.ts index 5b8813b..ceed613 100644 --- a/src/commands/webhooks/events/list.ts +++ b/src/commands/webhooks/events/list.ts @@ -1,6 +1,7 @@ import { Command } from '@cliffy/command'; -import { getCurrentEnvironment } from '@/lib/config.ts'; +import { wantsJson } from '@/lib/context.ts'; import { listEvents } from '@/lib/events-store.ts'; +import { resolveActiveEnvironment } from '@/lib/mux.ts'; interface ListOptions { limit?: number; @@ -15,16 +16,11 @@ export const listCommand = new Command() .option('--json', 'Output JSON instead of pretty format') .action(async (options: ListOptions) => { try { - const env = await getCurrentEnvironment(); - if (!env) { - console.error("Not logged in. Please run 'mux login' to authenticate."); - process.exit(1); - } - const environmentId = env.environment.environmentId ?? env.name; - const events = listEvents(environmentId, options.limit); + const active = await resolveActiveEnvironment(); + const events = listEvents(active.environmentId, options.limit); if (events.length === 0) { - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify([], null, 2)); } else { console.log( @@ -34,7 +30,7 @@ export const listCommand = new Command() return; } - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify(events, null, 2)); return; } @@ -46,7 +42,7 @@ export const listCommand = new Command() } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); - if (options.json) { + if (wantsJson(options)) { console.error(JSON.stringify({ error: errorMessage }, null, 2)); } else { console.error(`Error: ${errorMessage}`); diff --git a/src/commands/webhooks/events/replay.ts b/src/commands/webhooks/events/replay.ts index 0dc1f7c..0e11038 100644 --- a/src/commands/webhooks/events/replay.ts +++ b/src/commands/webhooks/events/replay.ts @@ -1,11 +1,10 @@ import { colors } from '@cliffy/ansi/colors'; import { Command } from '@cliffy/command'; -import { getCurrentEnvironment, updateEnvironment } from '@/lib/config.ts'; +import { updateEnvironment } from '@/lib/config.ts'; +import { wantsJson } from '@/lib/context.ts'; import { getEventById, getRecentEvents } from '@/lib/events-store.ts'; -import { - buildSignedHeaders, - getSigningSecretForCurrentEnv, -} from '@/lib/webhook-signing.ts'; +import { resolveActiveEnvironment } from '@/lib/mux.ts'; +import { buildSignedHeaders, getSigningSecret } from '@/lib/webhook-signing.ts'; interface ReplayOptions { forwardTo?: string; @@ -47,24 +46,23 @@ export const replayCommand = new Command() process.exit(1); } - const env = await getCurrentEnvironment(); - if (!env) { - console.error("Not logged in. Please run 'mux login' to authenticate."); - process.exit(1); - } - const environmentId = env.environment.environmentId ?? env.name; + const active = await resolveActiveEnvironment(); + const environmentId = active.environmentId; + const signingSecretKey = active.stored?.name ?? active.environmentId; // Use provided --forward-to, or fall back to the saved URL - if (!options.forwardTo && env.environment.forwardUrl) { - options.forwardTo = env.environment.forwardUrl; + if (!options.forwardTo && active.stored?.environment.forwardUrl) { + options.forwardTo = active.stored.environment.forwardUrl; } - // Save the forward URL if a new one was provided + // Save the forward URL if a new one was provided. Only persisted when + // the stored environment matches the active credentials. if ( options.forwardTo && - options.forwardTo !== env.environment.forwardUrl + active.stored && + options.forwardTo !== active.stored.environment.forwardUrl ) { - await updateEnvironment(env.name, { + await updateEnvironment(active.stored.name, { forwardUrl: options.forwardTo, }); } @@ -82,13 +80,13 @@ export const replayCommand = new Command() return; } - const signingSecret = await getSigningSecretForCurrentEnv(); + const signingSecret = await getSigningSecret(signingSecretKey); const { status } = await forwardEvent( options.forwardTo, event.payload, signingSecret, ); - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify({ status, eventId: event.id }, null, 2)); } else if (status >= 200 && status < 300) { console.log( @@ -111,7 +109,7 @@ export const replayCommand = new Command() } if (!options.forwardTo) { - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify(events, null, 2)); } else { for (const event of events) { @@ -121,7 +119,7 @@ export const replayCommand = new Command() return; } - const signingSecret = await getSigningSecretForCurrentEnv(); + const signingSecret = await getSigningSecret(signingSecretKey); let forwarded = 0; let failed = 0; @@ -134,14 +132,14 @@ export const replayCommand = new Command() ); if (status >= 200 && status < 300) { forwarded++; - if (!options.json) { + if (!wantsJson(options)) { console.log( `${colors.green(`[${status}]`)} ${event.type.padEnd(30)} ${event.id}`, ); } } else { failed++; - if (!options.json) { + if (!wantsJson(options)) { console.log( `${colors.red(`[${status}]`)} ${event.type.padEnd(30)} ${event.id}`, ); @@ -149,7 +147,7 @@ export const replayCommand = new Command() } } catch { failed++; - if (!options.json) { + if (!wantsJson(options)) { console.log( `${colors.red('[ERR]')} ${event.type.padEnd(30)} ${event.id}`, ); @@ -157,7 +155,7 @@ export const replayCommand = new Command() } } - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify({ forwarded, failed }, null, 2)); } else { console.log( @@ -167,7 +165,7 @@ export const replayCommand = new Command() } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); - if (options.json) { + if (wantsJson(options)) { console.error(JSON.stringify({ error: errorMessage }, null, 2)); } else { console.error(`Error: ${errorMessage}`); diff --git a/src/commands/webhooks/listen.ts b/src/commands/webhooks/listen.ts index d7985fd..100b68a 100644 --- a/src/commands/webhooks/listen.ts +++ b/src/commands/webhooks/listen.ts @@ -1,9 +1,10 @@ import { colors } from '@cliffy/ansi/colors'; import { Command } from '@cliffy/command'; -import { getCurrentEnvironment, updateEnvironment } from '@/lib/config.ts'; +import { updateEnvironment } from '@/lib/config.ts'; +import { wantsJson } from '@/lib/context.ts'; import { checkFetchPermissionError } from '@/lib/errors.ts'; import { appendEvent, type StoredEvent } from '@/lib/events-store.ts'; -import { getAuthHeaders, getMuxBaseUrl } from '@/lib/mux.ts'; +import { getAuthHeaders, resolveActiveEnvironment } from '@/lib/mux.ts'; import { parseSSEStream } from '@/lib/sse.ts'; import { buildSignedHeaders, getSigningSecret } from '@/lib/webhook-signing.ts'; @@ -32,7 +33,7 @@ export const listenCommand = new Command() let backoffMs = INITIAL_BACKOFF_MS; function printSummary() { - if (options.json) return; + if (wantsJson(options)) return; console.log(`\n${eventCount} event(s) received.`); if (options.forwardTo) { console.log( @@ -47,29 +48,42 @@ export const listenCommand = new Command() process.exit(0); }); - const env = await getCurrentEnvironment(); - if (!env) { - console.error("Not logged in. Please run 'mux login' to authenticate."); + let active: Awaited>; + try { + active = await resolveActiveEnvironment(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (wantsJson(options)) { + console.error(JSON.stringify({ error: message }, null, 2)); + } else { + console.error(`Error: ${message}`); + } process.exit(1); } - // Save the forward URL for use by replay/browse - if (options.forwardTo && options.forwardTo !== env.environment.forwardUrl) { - await updateEnvironment(env.name, { + // Save the forward URL for use by replay/browse. Only persisted when the + // stored environment matches the active credentials. + if ( + options.forwardTo && + active.stored && + options.forwardTo !== active.stored.environment.forwardUrl + ) { + await updateEnvironment(active.stored.name, { forwardUrl: options.forwardTo, }); } const authHeaders = await getAuthHeaders(); - const baseUrl = getMuxBaseUrl(env); - const url = `${baseUrl}/system/v1/webhook-events/stream`; + const url = `${active.baseUrl}/system/v1/webhook-events/stream`; let signingSecret: string | undefined; if (options.forwardTo) { - signingSecret = await getSigningSecret(env.name); + signingSecret = await getSigningSecret( + active.stored?.name ?? active.environmentId, + ); } - if (!options.json) { + if (!wantsJson(options)) { console.log(`Connecting...`); if (options.forwardTo) { console.log(`Forwarding events to ${options.forwardTo}`); @@ -101,7 +115,7 @@ export const listenCommand = new Command() // Permission errors are not transient and should not be retried. const permError = await checkFetchPermissionError(response); if (permError) { - if (options.json) { + if (wantsJson(options)) { console.error(JSON.stringify({ error: permError })); } else { console.error(`Error: ${permError}`); @@ -123,7 +137,7 @@ export const listenCommand = new Command() )) { if (sseEvent.event === 'connected') { backoffMs = INITIAL_BACKOFF_MS; - if (!options.json) { + if (!wantsJson(options)) { console.log(colors.dim('Connected to event stream.\n')); } continue; @@ -144,7 +158,7 @@ export const listenCommand = new Command() id: eventId, type: eventType, timestamp, - environmentId: env.environment.environmentId ?? env.name, + environmentId: active.environmentId, payload: parsed, }; @@ -174,7 +188,7 @@ export const listenCommand = new Command() } } - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify(parsed)); } else { const time = new Date().toLocaleTimeString(); @@ -195,7 +209,7 @@ export const listenCommand = new Command() } // Stream ended naturally (server closed connection) — reconnect - if (!options.json) { + if (!wantsJson(options)) { console.log(colors.dim('\nStream ended.')); } } catch (error) { @@ -210,13 +224,13 @@ export const listenCommand = new Command() errorMessage.includes('getaddrinfo') ) { console.error( - `Error: Could not resolve hostname for ${baseUrl}\n` + + `Error: Could not resolve hostname for ${active.baseUrl}\n` + 'Check that MUX_BASE_URL is set correctly and the host is reachable.', ); process.exit(1); } - if (!options.json) { + if (!wantsJson(options)) { if ( errorMessage.includes('ECONNREFUSED') || errorMessage.includes('ECONNRESET') || @@ -233,7 +247,7 @@ export const listenCommand = new Command() // Backoff before reconnecting if (!controller.signal.aborted) { - if (!options.json) { + if (!wantsJson(options)) { console.log(colors.dim(`Reconnecting in ${backoffMs / 1000}s...`)); } await new Promise((resolve) => { diff --git a/src/commands/webhooks/trigger.ts b/src/commands/webhooks/trigger.ts index 62a0459..d5aab1a 100644 --- a/src/commands/webhooks/trigger.ts +++ b/src/commands/webhooks/trigger.ts @@ -1,11 +1,12 @@ import { colors } from '@cliffy/ansi/colors'; import { Command } from '@cliffy/command'; -import { getCurrentEnvironment } from '@/lib/config.ts'; +import { wantsJson } from '@/lib/context.ts'; import { generateExampleWebhook, WEBHOOK_EVENT_TYPES, type WebhookEventType, } from '@/lib/example-webhooks.ts'; +import { resolveActiveEnvironment } from '@/lib/mux.ts'; import { buildSignedHeaders, getSigningSecret } from '@/lib/webhook-signing.ts'; interface TriggerOptions { @@ -35,20 +36,19 @@ export const triggerCommand = new Command() process.exit(1); } - const env = await getCurrentEnvironment(); - if (!env) { - console.error("Not logged in. Please run 'mux login' to authenticate."); - process.exit(1); - } + const active = await resolveActiveEnvironment(); + const envLabel = active.stored?.name ?? active.environmentId; + const tokenId = + active.stored?.environment.tokenId ?? process.env.MUX_TOKEN_ID ?? ''; const payload = generateExampleWebhook( eventType as WebhookEventType, - env.name, - env.environment.tokenId.slice(0, 6), + envLabel, + tokenId.slice(0, 6), ); const body = JSON.stringify(payload); - const signingSecret = await getSigningSecret(env.name); + const signingSecret = await getSigningSecret(envLabel); const response = await fetch(options.forwardTo, { method: 'POST', @@ -58,7 +58,7 @@ export const triggerCommand = new Command() const status = response.status; - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify({ status, eventType, payload }, null, 2)); } else if (status >= 200 && status < 300) { console.log( @@ -72,7 +72,7 @@ export const triggerCommand = new Command() } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); - if (options.json) { + if (wantsJson(options)) { console.error(JSON.stringify({ error: errorMessage }, null, 2)); } else { console.error(`Error: ${errorMessage}`); diff --git a/src/commands/whoami.ts b/src/commands/whoami.ts index 91554b4..03b2a36 100644 --- a/src/commands/whoami.ts +++ b/src/commands/whoami.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { wantsJson } from '@/lib/context.ts'; import { handleCommandError } from '@/lib/errors.ts'; import { DEFAULT_BASE_URL, getAuthContext } from '../lib/mux.ts'; @@ -21,7 +22,7 @@ export const whoamiCommand = new Command() const body = (await response.json()) as { data: Record }; const data = body.data; - if (options.json) { + if (wantsJson(options)) { console.log(JSON.stringify(data, null, 2)); return; } diff --git a/src/index.ts b/src/index.ts index f3670bc..1c539e4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -27,31 +27,11 @@ import { uploadsCommand } from './commands/uploads/index.ts'; 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 { isAgentMode, preprocessArgs } from './lib/context.ts'; import { checkForUpdate, refreshUpdateCache } from './lib/update-notifier.ts'; const VERSION = pkg.version; -/** - * Preprocess argv to handle --agent flag before Cliffy parses it. - * Strips --agent from args, enables agent mode, and injects --json. - */ -function preprocessArgs(argv: string[]): string[] { - const agentIndex = argv.indexOf('--agent'); - if (agentIndex === -1) { - return argv; - } - - setAgentMode(true); - const args = argv.filter((_, i) => i !== agentIndex); - - if (!args.includes('--json')) { - args.push('--json'); - } - - return args; -} - // Main CLI command const cli = new Command() .name('mux') @@ -108,10 +88,15 @@ if (import.meta.main) { try { await cli.parse(preprocessArgs(Bun.argv.slice(2))); } catch (error) { - if (error instanceof Error) { - console.error(`Error: ${error.message}`); + const message = + error instanceof Error ? error.message : 'An unknown error occurred'; + // Commands format their own errors via handleCommandError; this is the + // fallback for errors that escape them, so it must stay machine-readable + // for agent and --json callers. + if (isAgentMode() || Bun.argv.includes('--json')) { + console.error(JSON.stringify({ error: message }, null, 2)); } else { - console.error('An unknown error occurred'); + console.error(`Error: ${message}`); } process.exit(1); } diff --git a/src/lib/context.test.ts b/src/lib/context.test.ts new file mode 100644 index 0000000..0955038 --- /dev/null +++ b/src/lib/context.test.ts @@ -0,0 +1,67 @@ +import { afterEach, describe, expect, it } from 'bun:test'; +import { + isAgentMode, + preprocessArgs, + setAgentMode, + wantsJson, +} from './context.ts'; + +describe('wantsJson', () => { + afterEach(() => { + setAgentMode(false); + }); + + it('returns false when --json is not set and agent mode is off', () => { + expect(wantsJson({})).toBe(false); + expect(wantsJson({ json: undefined })).toBe(false); + }); + + it('returns true when --json is set', () => { + expect(wantsJson({ json: true })).toBe(true); + }); + + it('returns true in agent mode even without --json', () => { + setAgentMode(true); + expect(wantsJson({})).toBe(true); + }); + + it('returns true when both --json and agent mode are set', () => { + setAgentMode(true); + expect(wantsJson({ json: true })).toBe(true); + }); +}); + +describe('preprocessArgs', () => { + afterEach(() => { + setAgentMode(false); + }); + + it('passes args through unchanged when --agent is absent', () => { + const args = ['assets', 'list', '--json']; + expect(preprocessArgs(args)).toEqual(['assets', 'list', '--json']); + expect(isAgentMode()).toBe(false); + }); + + it('strips --agent and enables agent mode', () => { + const args = ['assets', 'list', '--agent']; + expect(preprocessArgs(args)).toEqual(['assets', 'list']); + expect(isAgentMode()).toBe(true); + }); + + it('does NOT inject --json (commands consult agent mode instead)', () => { + const result = preprocessArgs(['login', '--agent']); + expect(result).toEqual(['login']); + expect(result).not.toContain('--json'); + }); + + it('leaves an explicit --json in place alongside --agent', () => { + const result = preprocessArgs(['assets', 'list', '--agent', '--json']); + expect(result).toEqual(['assets', 'list', '--json']); + }); + + it('strips --agent regardless of position', () => { + const result = preprocessArgs(['--agent', 'whoami']); + expect(result).toEqual(['whoami']); + expect(isAgentMode()).toBe(true); + }); +}); diff --git a/src/lib/context.ts b/src/lib/context.ts index 684211f..b29f720 100644 --- a/src/lib/context.ts +++ b/src/lib/context.ts @@ -7,3 +7,27 @@ export function setAgentMode(value: boolean) { export function isAgentMode(): boolean { return agentMode; } + +/** + * Whether a command should produce machine-readable JSON output. + * True when the command was invoked with --json or when agent mode is active. + */ +export function wantsJson(options: { json?: boolean }): boolean { + return Boolean(options.json) || agentMode; +} + +/** + * Preprocess argv to handle the --agent flag before Cliffy parses it. + * Strips --agent from args and enables agent mode. Commands consult + * agent mode via wantsJson()/isAgentMode() to decide output format, so + * no --json flag is injected and commands without one still work. + */ +export function preprocessArgs(argv: string[]): string[] { + const agentIndex = argv.indexOf('--agent'); + if (agentIndex === -1) { + return argv; + } + + setAgentMode(true); + return argv.filter((_, i) => i !== agentIndex); +} diff --git a/src/lib/errors.ts b/src/lib/errors.ts index 52be4ee..0bc90df 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -1,4 +1,5 @@ import { AuthenticationError, NotFoundError } from '@mux/mux-node'; +import { wantsJson } from '@/lib/context.ts'; import { getAuthContext } from './mux.ts'; /** @@ -74,11 +75,13 @@ export async function handleCommandError( _action: string, options: { json?: boolean }, ): Promise { + const json = wantsJson(options); + // 401: invalid/expired credentials if (error instanceof AuthenticationError) { const message = "Authentication failed. Please run 'mux login' to re-authenticate."; - if (options.json) { + if (json) { console.error(JSON.stringify({ error: message }, null, 2)); } else { console.error(`Error: ${message}`); @@ -96,7 +99,7 @@ export async function handleCommandError( tokenInfo.tokenName, error.error, ); - if (options.json) { + if (json) { console.error( JSON.stringify( { @@ -119,7 +122,7 @@ export async function handleCommandError( // Default: generic error formatting const errorMessage = error instanceof Error ? error.message : String(error); - if (options.json) { + if (json) { console.error(JSON.stringify({ error: errorMessage }, null, 2)); } else { console.error(`Error: ${errorMessage}`); diff --git a/src/lib/mux.test.ts b/src/lib/mux.test.ts index 2cc34fd..48914c9 100644 --- a/src/lib/mux.test.ts +++ b/src/lib/mux.test.ts @@ -1,9 +1,23 @@ -import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { + afterEach, + beforeEach, + describe, + expect, + it, + type Mock, + spyOn, +} from 'bun:test'; import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { getCurrentEnvironment, setEnvironment } from './config.ts'; -import { DEFAULT_BASE_URL, getMuxBaseUrl } from './mux.ts'; +import { + createAuthenticatedMuxClient, + DEFAULT_BASE_URL, + getAuthContext, + getMuxBaseUrl, + resolveActiveEnvironment, +} from './mux.ts'; describe('getMuxBaseUrl', () => { let testConfigDir: string; @@ -70,3 +84,286 @@ describe('getMuxBaseUrl', () => { expect(getMuxBaseUrl(env)).toBe(DEFAULT_BASE_URL); }); }); + +describe('auth fallback to environment variables', () => { + let testConfigDir: string; + let originalXdgConfigHome: string | undefined; + let originalTokenId: string | undefined; + let originalTokenSecret: string | undefined; + + beforeEach(async () => { + testConfigDir = await mkdtemp(join(tmpdir(), 'mux-cli-test-')); + originalXdgConfigHome = process.env.XDG_CONFIG_HOME; + originalTokenId = process.env.MUX_TOKEN_ID; + originalTokenSecret = process.env.MUX_TOKEN_SECRET; + process.env.XDG_CONFIG_HOME = testConfigDir; + delete process.env.MUX_TOKEN_ID; + delete process.env.MUX_TOKEN_SECRET; + }); + + afterEach(async () => { + if (originalXdgConfigHome === undefined) { + delete process.env.XDG_CONFIG_HOME; + } else { + process.env.XDG_CONFIG_HOME = originalXdgConfigHome; + } + if (originalTokenId === undefined) { + delete process.env.MUX_TOKEN_ID; + } else { + process.env.MUX_TOKEN_ID = originalTokenId; + } + if (originalTokenSecret === undefined) { + delete process.env.MUX_TOKEN_SECRET; + } else { + process.env.MUX_TOKEN_SECRET = originalTokenSecret; + } + await rm(testConfigDir, { recursive: true, force: true }); + }); + + describe('getAuthContext', () => { + it('uses MUX_TOKEN_ID/MUX_TOKEN_SECRET env vars when not logged in', async () => { + process.env.MUX_TOKEN_ID = 'env_token_id'; + process.env.MUX_TOKEN_SECRET = 'env_token_secret'; + + const { headers } = await getAuthContext(); + expect(headers.Authorization).toBe( + `Basic ${btoa('env_token_id:env_token_secret')}`, + ); + }); + + it('prefers env vars over stored config (consistent with MUX_BASE_URL)', async () => { + await setEnvironment('default', { + tokenId: 'stored_id', + tokenSecret: 'stored_secret', + }); + process.env.MUX_TOKEN_ID = 'env_token_id'; + process.env.MUX_TOKEN_SECRET = 'env_token_secret'; + + const { headers } = await getAuthContext(); + expect(headers.Authorization).toBe( + `Basic ${btoa('env_token_id:env_token_secret')}`, + ); + }); + + it('uses stored config when env vars are absent', async () => { + await setEnvironment('default', { + tokenId: 'stored_id', + tokenSecret: 'stored_secret', + }); + + const { headers } = await getAuthContext(); + expect(headers.Authorization).toBe( + `Basic ${btoa('stored_id:stored_secret')}`, + ); + }); + + it('does not inherit the stored config baseUrl when credentials come from env vars', async () => { + await setEnvironment('default', { + tokenId: 'stored_id', + tokenSecret: 'stored_secret', + baseUrl: 'https://stored.example.com', + }); + process.env.MUX_TOKEN_ID = 'env_token_id'; + process.env.MUX_TOKEN_SECRET = 'env_token_secret'; + + const { baseUrl } = await getAuthContext(); + expect(baseUrl).toBe(DEFAULT_BASE_URL); + }); + + it('uses the stored config baseUrl when credentials come from config', async () => { + await setEnvironment('default', { + tokenId: 'stored_id', + tokenSecret: 'stored_secret', + baseUrl: 'https://stored.example.com', + }); + + const { baseUrl } = await getAuthContext(); + expect(baseUrl).toBe('https://stored.example.com'); + }); + + it('ignores env vars when only one of the pair is set', async () => { + process.env.MUX_TOKEN_ID = 'env_token_id'; + + expect(getAuthContext()).rejects.toThrow(/not logged in/i); + }); + + it('error message mentions both env vars and mux login', async () => { + try { + await getAuthContext(); + expect.unreachable('should have thrown'); + } catch (error) { + const message = (error as Error).message; + expect(message).toContain('MUX_TOKEN_ID'); + expect(message).toContain('MUX_TOKEN_SECRET'); + expect(message).toContain('mux login'); + } + }); + }); + + describe('createAuthenticatedMuxClient', () => { + it('creates a client from env vars when not logged in', async () => { + process.env.MUX_TOKEN_ID = 'env_token_id'; + process.env.MUX_TOKEN_SECRET = 'env_token_secret'; + + const client = await createAuthenticatedMuxClient(); + expect(client.tokenId).toBe('env_token_id'); + expect(client.tokenSecret).toBe('env_token_secret'); + }); + + it('throws the updated error when no credentials anywhere', async () => { + expect(createAuthenticatedMuxClient()).rejects.toThrow(/MUX_TOKEN_ID/); + }); + }); + + describe('resolveActiveEnvironment', () => { + let fetchSpy: Mock; + + function mockWhoami(environmentId: string | undefined, status = 200) { + fetchSpy = spyOn(globalThis, 'fetch').mockImplementation( + (async () => + new Response( + JSON.stringify({ data: { environment_id: environmentId } }), + { status }, + )) as unknown as typeof fetch, + ); + } + + afterEach(() => { + fetchSpy?.mockRestore(); + }); + + it('uses the stored environment without calling the API when env vars are absent', async () => { + await setEnvironment('default', { + tokenId: 'stored_id', + tokenSecret: 'stored_secret', + environmentId: 'env_stored_123', + }); + fetchSpy = spyOn(globalThis, 'fetch').mockImplementation((async () => { + throw new Error('unexpected fetch'); + }) as unknown as typeof fetch); + + const active = await resolveActiveEnvironment(); + + expect(active.source).toBe('config'); + expect(active.environmentId).toBe('env_stored_123'); + expect(active.stored?.name).toBe('default'); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('falls back to the environment name when stored config has no environmentId', async () => { + await setEnvironment('legacy', { + tokenId: 'stored_id', + tokenSecret: 'stored_secret', + }); + + const active = await resolveActiveEnvironment(); + + expect(active.source).toBe('config'); + expect(active.environmentId).toBe('legacy'); + expect(active.stored?.name).toBe('legacy'); + }); + + it('resolves the environment id via whoami when only env vars are set', async () => { + process.env.MUX_TOKEN_ID = 'env_token_id'; + process.env.MUX_TOKEN_SECRET = 'env_token_secret'; + mockWhoami('env_from_whoami'); + + const active = await resolveActiveEnvironment(); + + expect(active.source).toBe('env'); + expect(active.environmentId).toBe('env_from_whoami'); + expect(active.stored).toBeNull(); + }); + + it('returns the stored environment when env var credentials match it', async () => { + await setEnvironment('default', { + tokenId: 'stored_id', + tokenSecret: 'stored_secret', + environmentId: 'env_same_123', + }); + process.env.MUX_TOKEN_ID = 'env_token_id'; + process.env.MUX_TOKEN_SECRET = 'env_token_secret'; + mockWhoami('env_same_123'); + + const active = await resolveActiveEnvironment(); + + expect(active.source).toBe('env'); + expect(active.environmentId).toBe('env_same_123'); + expect(active.stored?.name).toBe('default'); + }); + + it('drops the stored environment when env var credentials point elsewhere', async () => { + await setEnvironment('default', { + tokenId: 'stored_id', + tokenSecret: 'stored_secret', + environmentId: 'env_stored_123', + }); + process.env.MUX_TOKEN_ID = 'env_token_id'; + process.env.MUX_TOKEN_SECRET = 'env_token_secret'; + mockWhoami('env_other_456'); + + const active = await resolveActiveEnvironment(); + + expect(active.source).toBe('env'); + expect(active.environmentId).toBe('env_other_456'); + expect(active.stored).toBeNull(); + }); + + it('drops the stored environment when it has no environmentId to compare', async () => { + await setEnvironment('default', { + tokenId: 'stored_id', + tokenSecret: 'stored_secret', + }); + process.env.MUX_TOKEN_ID = 'env_token_id'; + process.env.MUX_TOKEN_SECRET = 'env_token_secret'; + mockWhoami('env_other_456'); + + const active = await resolveActiveEnvironment(); + + expect(active.stored).toBeNull(); + }); + + it('resolves the base URL from the credential source, not the stored config', async () => { + await setEnvironment('default', { + tokenId: 'stored_id', + tokenSecret: 'stored_secret', + environmentId: 'env_stored_123', + baseUrl: 'https://stored.example.com', + }); + process.env.MUX_TOKEN_ID = 'env_token_id'; + process.env.MUX_TOKEN_SECRET = 'env_token_secret'; + mockWhoami('env_other_456'); + + const active = await resolveActiveEnvironment(); + + expect(active.baseUrl).toBe(DEFAULT_BASE_URL); + const whoamiUrl = String(fetchSpy.mock.calls[0][0]); + expect(whoamiUrl.startsWith(DEFAULT_BASE_URL)).toBe(true); + }); + + it('returns the stored baseUrl when credentials come from config', async () => { + await setEnvironment('default', { + tokenId: 'stored_id', + tokenSecret: 'stored_secret', + environmentId: 'env_stored_123', + baseUrl: 'https://stored.example.com', + }); + + const active = await resolveActiveEnvironment(); + + expect(active.baseUrl).toBe('https://stored.example.com'); + }); + + it('throws when whoami rejects the env var credentials', async () => { + process.env.MUX_TOKEN_ID = 'env_token_id'; + process.env.MUX_TOKEN_SECRET = 'bad_secret'; + mockWhoami(undefined, 401); + + expect(resolveActiveEnvironment()).rejects.toThrow(/credentials/i); + }); + + it('throws when no credentials are available anywhere', async () => { + expect(resolveActiveEnvironment()).rejects.toThrow(/MUX_TOKEN_ID/); + }); + }); +}); diff --git a/src/lib/mux.ts b/src/lib/mux.ts index 2c4bd4a..83b7bf6 100644 --- a/src/lib/mux.ts +++ b/src/lib/mux.ts @@ -1,6 +1,6 @@ import Mux from '@mux/mux-node'; import pkg from '../../package.json'; -import { getCurrentEnvironment } from './config.ts'; +import { type Environment, getCurrentEnvironment } from './config.ts'; import { isAgentMode } from './context.ts'; export const DEFAULT_BASE_URL = 'https://api.mux.com'; @@ -23,27 +23,137 @@ export function getMuxBaseUrl( ); } +export const NOT_LOGGED_IN_MESSAGE = + "Not logged in. Set MUX_TOKEN_ID and MUX_TOKEN_SECRET environment variables, or run 'mux login' to authenticate."; + /** - * Get auth headers and base URL in a single config read. + * Resolve API credentials. + * Priority: MUX_TOKEN_ID/MUX_TOKEN_SECRET env vars > stored config + * (consistent with how MUX_BASE_URL takes precedence over config). + * Env vars are only used when both are set and non-empty. */ -export async function getAuthContext(): Promise<{ - headers: Record; +async function resolveCredentials(): Promise<{ + tokenId: string; + tokenSecret: string; baseUrl: string; }> { const env = await getCurrentEnvironment(); + + const envTokenId = process.env.MUX_TOKEN_ID; + const envTokenSecret = process.env.MUX_TOKEN_SECRET; + if (envTokenId && envTokenSecret) { + return { + tokenId: envTokenId, + tokenSecret: envTokenSecret, + // The base URL follows the credential source: env var credentials + // never inherit a stored environment's host (MUX_BASE_URL or default). + baseUrl: getMuxBaseUrl(null), + }; + } + if (!env) { - throw new Error("Not logged in. Please run 'mux login' to authenticate."); + throw new Error(NOT_LOGGED_IN_MESSAGE); } - const credentials = btoa( - `${env.environment.tokenId}:${env.environment.tokenSecret}`, - ); + return { + tokenId: env.environment.tokenId, + tokenSecret: env.environment.tokenSecret, + baseUrl: getMuxBaseUrl(env), + }; +} + +export interface ActiveEnvironment { + /** Identifier that keys locally stored data (webhook events, signing secrets). */ + environmentId: string; + /** Where the active credentials came from. */ + source: 'env' | 'config'; + /** API host, resolved from the same source as the credentials. */ + baseUrl: string; + /** + * The stored config environment, only when it matches the active + * credentials. Null when credentials come from env vars that point to a + * different environment (or no environment is stored) — persisting API + * results to the stored config would desync it from the environment the + * credentials actually operate on. + */ + stored: { name: string; environment: Environment } | null; +} + +/** + * Resolve the identity of the environment the active credentials operate on. + * When credentials come from env vars, the environment id is confirmed via + * /whoami; the stored config environment is returned only when it matches. + */ +export async function resolveActiveEnvironment(): Promise { + const stored = await getCurrentEnvironment(); + + const envTokenId = process.env.MUX_TOKEN_ID; + const envTokenSecret = process.env.MUX_TOKEN_SECRET; + if (!(envTokenId && envTokenSecret)) { + if (!stored) { + throw new Error(NOT_LOGGED_IN_MESSAGE); + } + return { + environmentId: stored.environment.environmentId ?? stored.name, + source: 'config', + baseUrl: getMuxBaseUrl(stored), + stored, + }; + } + + // The base URL follows the credential source: env var credentials never + // inherit a stored environment's host (MUX_BASE_URL or default). + const baseUrl = getMuxBaseUrl(null); + const response = await fetch(`${baseUrl}/system/v1/whoami`, { + headers: { + Authorization: `Basic ${btoa(`${envTokenId}:${envTokenSecret}`)}`, + 'User-Agent': getUserAgent(), + }, + }); + + if (!response.ok) { + throw new Error( + `Could not verify MUX_TOKEN_ID/MUX_TOKEN_SECRET credentials: ${response.status} ${response.statusText}. Check the values or run 'mux login'.`, + ); + } + + const body = (await response.json()) as { + data: { environment_id?: string }; + }; + const environmentId = body.data.environment_id; + if (!environmentId) { + throw new Error( + 'Could not determine the environment for the MUX_TOKEN_ID/MUX_TOKEN_SECRET credentials.', + ); + } + + return { + environmentId, + source: 'env', + baseUrl, + stored: + stored && stored.environment.environmentId === environmentId + ? stored + : null, + }; +} + +/** + * Get auth headers and base URL in a single config read. + */ +export async function getAuthContext(): Promise<{ + headers: Record; + baseUrl: string; +}> { + const { tokenId, tokenSecret, baseUrl } = await resolveCredentials(); + + const credentials = btoa(`${tokenId}:${tokenSecret}`); return { headers: { Authorization: `Basic ${credentials}`, 'User-Agent': getUserAgent(), }, - baseUrl: getMuxBaseUrl(env), + baseUrl, }; } @@ -55,21 +165,16 @@ export async function getAuthHeaders(): Promise> { } /** - * Create an authenticated Mux client using stored credentials - * Throws an error if not logged in + * Create an authenticated Mux client from env vars or stored credentials. + * Throws an error when no credentials are available. */ export async function createAuthenticatedMuxClient(): Promise { - const env = await getCurrentEnvironment(); - if (!env) { - throw new Error("Not logged in. Please run 'mux login' to authenticate."); - } - - const baseURL = getMuxBaseUrl(env); + const { tokenId, tokenSecret, baseUrl } = await resolveCredentials(); return new Mux({ - tokenId: env.environment.tokenId, - tokenSecret: env.environment.tokenSecret, - ...(baseURL !== DEFAULT_BASE_URL && { baseURL }), + tokenId, + tokenSecret, + ...(baseUrl !== DEFAULT_BASE_URL && { baseURL: baseUrl }), defaultHeaders: { 'User-Agent': getUserAgent() }, }); } diff --git a/src/lib/webhook-signing.ts b/src/lib/webhook-signing.ts index 900d072..7ad758f 100644 --- a/src/lib/webhook-signing.ts +++ b/src/lib/webhook-signing.ts @@ -2,7 +2,6 @@ import { createHmac, randomBytes } from 'node:crypto'; import { existsSync } from 'node:fs'; import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; -import { getCurrentEnvironment } from './config.ts'; import { getDataDir } from './xdg.ts'; function getSigningSecretPath(envName: string): string { @@ -29,18 +28,6 @@ export async function getSigningSecret(envName: string): Promise { return secret; } -/** - * Get the signing secret for the default (logged-in) environment. - * Throws if not logged in. - */ -export async function getSigningSecretForCurrentEnv(): Promise { - const env = await getCurrentEnvironment(); - if (!env) { - throw new Error("Not logged in. Please run 'mux login' to authenticate."); - } - return getSigningSecret(env.name); -} - /** * Sign a webhook payload using the same scheme as Mux webhooks. * Produces a `mux-signature` header value: `t=,v1=`