From f83b56acd14d56dcb38831cda8d12a949d446e60 Mon Sep 17 00:00:00 2001 From: Daniel Weinberger Date: Wed, 29 Apr 2026 11:18:01 +0200 Subject: [PATCH 1/4] Fix encoding templates create to send raw YAML body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /encoding/templates endpoint expects a raw YAML body with Content-Type: application/yaml. The SDK only sends Content-Type: application/json with a JSON-stringified body, so the API rejected every well-formed template with "Could not parse encoding template". Bypass the SDK and POST raw YAML directly. Also drop the --name flag — the API takes the template name from `metadata.name` in the YAML and ignores any `?name=` query parameter, so the flag was misleading. Add a resolveAuth() helper alongside getClient() so commands that need raw HTTP calls can resolve the API key the same way (CLI override → env var → config file). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/commands/encoding/templates/create.ts | 53 +++++++++++++++--- src/lib/client.ts | 20 +++++-- test/commands/encoding-templates.test.ts | 65 +++++++++++++++++++++++ 3 files changed, 128 insertions(+), 10 deletions(-) diff --git a/src/commands/encoding/templates/create.ts b/src/commands/encoding/templates/create.ts index e9d9707..faa5f54 100644 --- a/src/commands/encoding/templates/create.ts +++ b/src/commands/encoding/templates/create.ts @@ -1,9 +1,20 @@ -import {Args, Flags} from '@oclif/core'; +import {Args} from '@oclif/core'; import {readFileSync} from 'node:fs'; import {BaseCommand} from '../../../lib/base-command.js'; +import {API_BASE_URL, resolveAuth} from '../../../lib/client.js'; + +interface CreateResponse { + data?: { + result?: { + id?: string; + [key: string]: unknown; + }; + }; +} export default class EncodingTemplateCreate extends BaseCommand { - static override description = 'Store an encoding template for reuse'; + static override description = + 'Store an encoding template for reuse. The template name is taken from `metadata.name` in the YAML.'; static override args = { file: Args.string({description: 'Path to YAML template file', required: true}), @@ -11,15 +22,45 @@ export default class EncodingTemplateCreate extends BaseCommand { static override flags = { ...BaseCommand.baseFlags, - name: Flags.string({description: 'Template name', required: true}), }; async run(): Promise { const {args, flags} = await this.parse(EncodingTemplateCreate); const content = readFileSync(args.file, 'utf-8'); - const templatePayload: Record = {name: flags.name, template: content}; - const result = await (await this.getApi()).encoding.templates.create(templatePayload as never); - this.log(`Template created: ${(result as {id?: string}).id}`); + + const {apiKey, tenantOrgId} = resolveAuth(flags['api-key']); + + // The /encoding/templates endpoint expects a raw YAML body with + // Content-Type: application/yaml. The SDK only sends application/json, + // so we issue this request directly. + const headers: Record = { + 'X-Api-Key': apiKey, + 'X-Api-Client': '@bitmovin/cli', + 'Content-Type': 'application/yaml', + }; + if (tenantOrgId) headers['X-Tenant-Org-Id'] = tenantOrgId; + + const response = await fetch(`${API_BASE_URL}/encoding/templates`, { + method: 'POST', + headers, + body: content, + }); + + if (!response.ok) { + let bodyText = await response.text(); + try { + const parsed = JSON.parse(bodyText) as {data?: {message?: string; developerMessage?: string}}; + bodyText = parsed.data?.developerMessage ?? parsed.data?.message ?? bodyText; + } catch { + // leave bodyText as-is + } + + this.error(`Failed to create template (${response.status}): ${bodyText}`); + } + + const json = (await response.json()) as CreateResponse; + const result = json.data?.result ?? {}; + this.log(`Template created: ${result.id ?? ''}`); await this.outputData(result); } } diff --git a/src/lib/client.ts b/src/lib/client.ts index eb456f6..b6d0915 100644 --- a/src/lib/client.ts +++ b/src/lib/client.ts @@ -31,6 +31,19 @@ const SdkModule = BitmovinApiSdk as unknown as {default?: BitmovinApiConstructor const BitmovinApi: BitmovinApiConstructor = SdkModule.default ?? (BitmovinApiSdk as unknown as BitmovinApiConstructor); export function getClient(apiKeyOverride?: string): ApiClient { + const {apiKey, tenantOrgId} = resolveAuth(apiKeyOverride); + return new BitmovinApi({ + apiKey, + ...(tenantOrgId && {tenantOrgId}), + }); +} + +/** + * Resolve API auth from CLI override, env var, or config file. Used both by + * `getClient()` and by commands that need to make raw HTTP requests outside + * the SDK (e.g. when an endpoint expects a non-JSON body). + */ +export function resolveAuth(apiKeyOverride?: string): {apiKey: string; tenantOrgId?: string} { const config = loadConfig(); const apiKey = apiKeyOverride ?? process.env.BITMOVIN_API_KEY ?? config.apiKey; @@ -43,8 +56,7 @@ export function getClient(apiKeyOverride?: string): ApiClient { ); } - return new BitmovinApi({ - apiKey, - ...(config.tenantOrgId && {tenantOrgId: config.tenantOrgId}), - }); + return {apiKey, tenantOrgId: config.tenantOrgId}; } + +export const API_BASE_URL = 'https://api.bitmovin.com/v1'; diff --git a/test/commands/encoding-templates.test.ts b/test/commands/encoding-templates.test.ts index 15ec469..b9ba493 100644 --- a/test/commands/encoding-templates.test.ts +++ b/test/commands/encoding-templates.test.ts @@ -1,4 +1,7 @@ import {describe, it, expect, vi} from 'vitest'; +import {writeFileSync, mkdtempSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; const mockTemplates = [ {id: 'tmpl-1', name: 'Standard VOD', type: 'VOD', createdAt: '2026-01-01T00:00:00.000Z'}, @@ -21,6 +24,11 @@ vi.mock('../../src/lib/client.js', () => ({ }, }, }), + resolveAuth: (override?: string) => ({ + apiKey: override ?? process.env.BITMOVIN_API_KEY ?? 'mock-api-key', + tenantOrgId: undefined, + }), + API_BASE_URL: 'https://api.bitmovin.com/v1', })); function captureStdout(): {output: () => string; restore: () => void} { @@ -82,6 +90,63 @@ describe('encoding templates delete', () => { }); }); +describe('encoding templates create', () => { + function setupCreate(): {dir: string; fetchMock: ReturnType} { + const dir = mkdtempSync(join(tmpdir(), 'bm-cli-create-')); + process.env.BITMOVIN_API_KEY = 'test-key-create'; + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 201, + json: async () => ({data: {result: {id: 'tmpl-new', name: 'Test'}}}), + }); + vi.stubGlobal('fetch', fetchMock); + return {dir, fetchMock}; + } + + it('posts the YAML body verbatim with Content-Type: application/yaml', async () => { + const {dir, fetchMock} = setupCreate(); + const file = join(dir, 't.yaml'); + const yamlBody = "metadata:\n name: Test\n type: LIVE\nencodings: {}\n"; + writeFileSync(file, yamlBody); + const cap = vi.spyOn(console, 'log').mockImplementation(() => {}); + const {default: Cmd} = await import('../../src/commands/encoding/templates/create.js'); + await Cmd.run([file]); + cap.mockRestore(); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('https://api.bitmovin.com/v1/encoding/templates'); + expect((init as RequestInit).method).toBe('POST'); + expect((init as RequestInit).body).toBe(yamlBody); + const headers = (init as RequestInit).headers as Record; + expect(headers['Content-Type']).toBe('application/yaml'); + expect(headers['X-Api-Key']).toBe('test-key-create'); + vi.unstubAllGlobals(); + delete process.env.BITMOVIN_API_KEY; + }); + + it('surfaces API error message on failure', async () => { + const dir = mkdtempSync(join(tmpdir(), 'bm-cli-create-')); + process.env.BITMOVIN_API_KEY = 'test-key-create'; + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: false, + status: 400, + text: async () => + JSON.stringify({data: {developerMessage: 'Could not parse encoding template'}}), + }), + ); + const file = join(dir, 'bad.yaml'); + writeFileSync(file, 'metadata:\n type: LIVE\n'); + const cap = vi.spyOn(console, 'error').mockImplementation(() => {}); + const {default: Cmd} = await import('../../src/commands/encoding/templates/create.js'); + await expect(Cmd.run([file])).rejects.toThrow(/EEXIT: 1|Could not parse encoding template/); + cap.mockRestore(); + vi.unstubAllGlobals(); + delete process.env.BITMOVIN_API_KEY; + }); +}); + describe('encoding templates start', () => { it('starts from a stored template ID', async () => { const capErr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); From 226cdae7c06a3b47bab0d5bc0c5aef3a6087a3bd Mon Sep 17 00:00:00 2001 From: Daniel Weinberger Date: Wed, 29 Apr 2026 13:35:10 +0200 Subject: [PATCH 2/4] fix templates create json output and docs --- README.md | 2 +- src/commands/encoding/templates/create.ts | 1 + src/commands/skill.ts | 2 +- test/commands/encoding-templates.test.ts | 16 ++++++++++++++++ 4 files changed, 19 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 91f8354..ce75427 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ The recommended way to encode. Define your entire workflow in a single [YAML tem ```bash bitmovin encoding templates start ./template.yaml --watch # Start from file bitmovin encoding templates start --id --watch # Start stored template -bitmovin encoding templates create ./template.yaml --name "Standard VOD" +bitmovin encoding templates create ./template.yaml # Name comes from metadata.name bitmovin encoding templates list bitmovin encoding templates get bitmovin encoding templates delete diff --git a/src/commands/encoding/templates/create.ts b/src/commands/encoding/templates/create.ts index faa5f54..47f9ad4 100644 --- a/src/commands/encoding/templates/create.ts +++ b/src/commands/encoding/templates/create.ts @@ -27,6 +27,7 @@ export default class EncodingTemplateCreate extends BaseCommand { async run(): Promise { const {args, flags} = await this.parse(EncodingTemplateCreate); const content = readFileSync(args.file, 'utf-8'); + await this.isJsonMode(); const {apiKey, tenantOrgId} = resolveAuth(flags['api-key']); diff --git a/src/commands/skill.ts b/src/commands/skill.ts index 2af1488..d71c161 100644 --- a/src/commands/skill.ts +++ b/src/commands/skill.ts @@ -23,7 +23,7 @@ bitmovin config set organization bitmovin encoding templates start ./template.yaml --watch # Start encoding from YAML template bitmovin encoding templates list # List stored templates bitmovin encoding templates get # Get template details -bitmovin encoding templates create ./file.yaml --name "X" # Store template +bitmovin encoding templates create ./file.yaml # Store template (name from metadata.name) bitmovin encoding templates delete bitmovin encoding templates validate ./template.yaml # Validate YAML against schema \`\`\` diff --git a/test/commands/encoding-templates.test.ts b/test/commands/encoding-templates.test.ts index b9ba493..b6e4063 100644 --- a/test/commands/encoding-templates.test.ts +++ b/test/commands/encoding-templates.test.ts @@ -124,6 +124,22 @@ describe('encoding templates create', () => { delete process.env.BITMOVIN_API_KEY; }); + it('outputs clean JSON with --json', async () => { + const {dir} = setupCreate(); + const file = join(dir, 't.yaml'); + writeFileSync(file, 'metadata:\n name: Test\n'); + const cap = captureStdout(); + const capErr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + const {default: Cmd} = await import('../../src/commands/encoding/templates/create.js'); + await Cmd.run([file, '--json']); + cap.restore(); + capErr.mockRestore(); + const data = JSON.parse(cap.output()); + expect(data).toEqual({id: 'tmpl-new', name: 'Test'}); + vi.unstubAllGlobals(); + delete process.env.BITMOVIN_API_KEY; + }); + it('surfaces API error message on failure', async () => { const dir = mkdtempSync(join(tmpdir(), 'bm-cli-create-')); process.env.BITMOVIN_API_KEY = 'test-key-create'; From c563e653c5457874d8e8fb8ebd6cf44e66dc870f Mon Sep 17 00:00:00 2001 From: Daniel Weinberger Date: Thu, 30 Apr 2026 08:45:11 +0200 Subject: [PATCH 3/4] Address PR review comments - Parse top-level API errors - Capture create stdout in tests - Assert surfaced API failure details --- src/commands/encoding/templates/create.ts | 18 ++++++++++++++++-- test/commands/encoding-templates.test.ts | 15 ++++++++------- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/src/commands/encoding/templates/create.ts b/src/commands/encoding/templates/create.ts index 47f9ad4..78d3745 100644 --- a/src/commands/encoding/templates/create.ts +++ b/src/commands/encoding/templates/create.ts @@ -12,6 +12,15 @@ interface CreateResponse { }; } +interface ErrorResponse { + developerMessage?: string; + message?: string; + data?: { + developerMessage?: string; + message?: string; + }; +} + export default class EncodingTemplateCreate extends BaseCommand { static override description = 'Store an encoding template for reuse. The template name is taken from `metadata.name` in the YAML.'; @@ -50,8 +59,13 @@ export default class EncodingTemplateCreate extends BaseCommand { if (!response.ok) { let bodyText = await response.text(); try { - const parsed = JSON.parse(bodyText) as {data?: {message?: string; developerMessage?: string}}; - bodyText = parsed.data?.developerMessage ?? parsed.data?.message ?? bodyText; + const parsed = JSON.parse(bodyText) as ErrorResponse; + bodyText = + parsed.developerMessage ?? + parsed.message ?? + parsed.data?.developerMessage ?? + parsed.data?.message ?? + bodyText; } catch { // leave bodyText as-is } diff --git a/test/commands/encoding-templates.test.ts b/test/commands/encoding-templates.test.ts index 8053ada..4dd4d0a 100644 --- a/test/commands/encoding-templates.test.ts +++ b/test/commands/encoding-templates.test.ts @@ -121,6 +121,7 @@ describe('encoding templates delete', () => { describe('encoding templates create', () => { function setupCreate(): {dir: string; fetchMock: ReturnType} { const dir = mkdtempSync(join(tmpdir(), 'bm-cli-create-')); + cleanupCallbacks.push(() => rmSync(dir, {recursive: true, force: true})); process.env.BITMOVIN_API_KEY = 'test-key-create'; const fetchMock = vi.fn().mockResolvedValue({ ok: true, @@ -136,10 +137,10 @@ describe('encoding templates create', () => { const file = join(dir, 't.yaml'); const yamlBody = "metadata:\n name: Test\n type: LIVE\nencodings: {}\n"; writeFileSync(file, yamlBody); - const cap = vi.spyOn(console, 'log').mockImplementation(() => {}); + const cap = captureStdout(); const {default: Cmd} = await import('../../src/commands/encoding/templates/create.js'); await Cmd.run([file]); - cap.mockRestore(); + cap.restore(); expect(fetchMock).toHaveBeenCalledTimes(1); const [url, init] = fetchMock.mock.calls[0]; expect(url).toBe('https://api.bitmovin.com/v1/encoding/templates'); @@ -170,22 +171,22 @@ describe('encoding templates create', () => { it('surfaces API error message on failure', async () => { const dir = mkdtempSync(join(tmpdir(), 'bm-cli-create-')); + cleanupCallbacks.push(() => rmSync(dir, {recursive: true, force: true})); process.env.BITMOVIN_API_KEY = 'test-key-create'; vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue({ ok: false, status: 400, - text: async () => - JSON.stringify({data: {developerMessage: 'Could not parse encoding template'}}), + text: async () => JSON.stringify({developerMessage: 'Could not parse encoding template'}), }), ); const file = join(dir, 'bad.yaml'); writeFileSync(file, 'metadata:\n type: LIVE\n'); - const cap = vi.spyOn(console, 'error').mockImplementation(() => {}); const {default: Cmd} = await import('../../src/commands/encoding/templates/create.js'); - await expect(Cmd.run([file])).rejects.toThrow(/EEXIT: 1|Could not parse encoding template/); - cap.mockRestore(); + await expect(Cmd.run([file])).rejects.toThrow( + 'Failed to create template (400): Could not parse encoding template', + ); vi.unstubAllGlobals(); delete process.env.BITMOVIN_API_KEY; }); From 89ae17a1be4a25a63118cd1552a916c6e94a0542 Mon Sep 17 00:00:00 2001 From: Daniel Weinberger Date: Thu, 30 Apr 2026 16:44:47 +0200 Subject: [PATCH 4/4] Address template create review comments - Preserve API error metadata for JSON output - Clean up test API key environment state --- src/commands/encoding/templates/create.ts | 28 ++++++++--- test/commands/encoding-templates.test.ts | 61 ++++++++++++++++++++--- 2 files changed, 75 insertions(+), 14 deletions(-) diff --git a/src/commands/encoding/templates/create.ts b/src/commands/encoding/templates/create.ts index 78d3745..194d90e 100644 --- a/src/commands/encoding/templates/create.ts +++ b/src/commands/encoding/templates/create.ts @@ -15,12 +15,20 @@ interface CreateResponse { interface ErrorResponse { developerMessage?: string; message?: string; + requestId?: string; data?: { developerMessage?: string; message?: string; + requestId?: string; }; } +interface ApiError extends Error { + httpStatusCode: number; + developerMessage: string; + requestId?: string; +} + export default class EncodingTemplateCreate extends BaseCommand { static override description = 'Store an encoding template for reuse. The template name is taken from `metadata.name` in the YAML.'; @@ -57,20 +65,28 @@ export default class EncodingTemplateCreate extends BaseCommand { }); if (!response.ok) { - let bodyText = await response.text(); + const rawBodyText = await response.text(); + let developerMessage = rawBodyText; + let requestId = response.headers?.get('X-Request-Id') ?? undefined; + try { - const parsed = JSON.parse(bodyText) as ErrorResponse; - bodyText = + const parsed = JSON.parse(rawBodyText) as ErrorResponse; + developerMessage = parsed.developerMessage ?? parsed.message ?? parsed.data?.developerMessage ?? parsed.data?.message ?? - bodyText; + rawBodyText; + requestId = requestId ?? parsed.requestId ?? parsed.data?.requestId; } catch { - // leave bodyText as-is + // leave developerMessage as raw response body } - this.error(`Failed to create template (${response.status}): ${bodyText}`); + const error = new Error(`Failed to create template (${response.status}): ${developerMessage}`) as ApiError; + error.httpStatusCode = response.status; + error.developerMessage = developerMessage; + if (requestId) error.requestId = requestId; + throw error; } const json = (await response.json()) as CreateResponse; diff --git a/test/commands/encoding-templates.test.ts b/test/commands/encoding-templates.test.ts index 4dd4d0a..d993c87 100644 --- a/test/commands/encoding-templates.test.ts +++ b/test/commands/encoding-templates.test.ts @@ -22,6 +22,7 @@ afterEach(() => { vi.unstubAllGlobals(); delete process.env.BM_CLI_TEST_HOME; + delete process.env.BITMOVIN_API_KEY; }); const mockTemplates = [ @@ -71,6 +72,25 @@ function captureStdout(): {output: () => string; restore: () => void} { }; } +function captureStderr(): {output: () => string; restore: () => void} { + let captured = ''; + const mock = vi.spyOn(process.stderr, 'write').mockImplementation((chunk: string | Uint8Array) => { + captured += typeof chunk === 'string' ? chunk : chunk.toString(); + return true; + }); + let restored = false; + const restore = () => { + if (restored) return; + restored = true; + mock.mockRestore(); + }; + cleanupCallbacks.push(restore); + return { + output: () => captured, + restore, + }; +} + describe('encoding templates list', () => { it('outputs JSON', async () => { const cap = captureStdout(); @@ -149,8 +169,6 @@ describe('encoding templates create', () => { const headers = (init as RequestInit).headers as Record; expect(headers['Content-Type']).toBe('application/yaml'); expect(headers['X-Api-Key']).toBe('test-key-create'); - vi.unstubAllGlobals(); - delete process.env.BITMOVIN_API_KEY; }); it('outputs clean JSON with --json', async () => { @@ -165,8 +183,6 @@ describe('encoding templates create', () => { capErr.mockRestore(); const data = JSON.parse(cap.output()); expect(data).toEqual({id: 'tmpl-new', name: 'Test'}); - vi.unstubAllGlobals(); - delete process.env.BITMOVIN_API_KEY; }); it('surfaces API error message on failure', async () => { @@ -183,12 +199,41 @@ describe('encoding templates create', () => { ); const file = join(dir, 'bad.yaml'); writeFileSync(file, 'metadata:\n type: LIVE\n'); + const capErr = captureStderr(); const {default: Cmd} = await import('../../src/commands/encoding/templates/create.js'); - await expect(Cmd.run([file])).rejects.toThrow( - 'Failed to create template (400): Could not parse encoding template', + await expect(Cmd.run([file])).rejects.toThrow(/EEXIT: 1/); + capErr.restore(); + expect(capErr.output()).toContain('API error: 400'); + expect(capErr.output()).toContain('Could not parse encoding template'); + }); + + it('outputs structured JSON API error with --json', async () => { + const dir = mkdtempSync(join(tmpdir(), 'bm-cli-create-')); + cleanupCallbacks.push(() => rmSync(dir, {recursive: true, force: true})); + process.env.BITMOVIN_API_KEY = 'test-key-create'; + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: false, + status: 400, + headers: {get: (name: string) => (name.toLowerCase() === 'x-request-id' ? 'req-create-1' : null)}, + text: async () => JSON.stringify({developerMessage: 'Could not parse encoding template'}), + }), ); - vi.unstubAllGlobals(); - delete process.env.BITMOVIN_API_KEY; + const file = join(dir, 'bad.yaml'); + writeFileSync(file, 'metadata:\n type: LIVE\n'); + const capOut = captureStdout(); + const capErr = captureStderr(); + const {default: Cmd} = await import('../../src/commands/encoding/templates/create.js'); + await expect(Cmd.run([file, '--json'])).rejects.toThrow(/EEXIT: 1/); + capOut.restore(); + capErr.restore(); + expect(JSON.parse(capOut.output())).toEqual({ + error: true, + httpStatusCode: 400, + message: 'Could not parse encoding template', + requestId: 'req-create-1', + }); }); });