From d798aca940cf124787ce3cefe6f6f3a293b19376 Mon Sep 17 00:00:00 2001 From: saravanan Date: Wed, 15 Apr 2026 10:22:45 +0800 Subject: [PATCH 1/2] Add multi-profile configuration support Introduces named profiles so multiple API keys/orgs can be managed without re-running config set. Profiles are stored under a 'profiles' key in config.json; legacy flat configs are transparently migrated to the 'default' profile on first load. - config.ts: FullConfig/ProfileConfig types, getProfile, setActiveProfile, listProfiles, deleteProfile, live migration of legacy flat format - client.ts: profile-aware getClient(apiKeyOverride, profileName) - base-command.ts: --profile flag (env: BITMOVIN_PROFILE), passes profileName to getClient - config set: --profile flag to target a specific profile - config show: --profile flag, displays active profile name - config profile {use,list,delete} commands Co-Authored-By: Claude Sonnet 4.6 --- package.json | 6 +- src/commands/config/list/organizations.ts | 8 +- src/commands/config/set.ts | 21 ++- src/commands/config/show.ts | 12 +- src/lib/base-command.ts | 14 +- src/lib/client.ts | 10 +- src/lib/config.ts | 93 ++++++++++- test/commands/base-command-errors.test.ts | 9 +- test/commands/config.test.ts | 59 +++++-- test/lib/client.test.ts | 9 +- test/lib/config.test.ts | 194 ++++++++++++++++++++-- 11 files changed, 378 insertions(+), 57 deletions(-) diff --git a/package.json b/package.json index f730b11..19bc3bd 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,8 @@ "prepare": "tsc -b", "build": "tsc -b", "dev": "tsc -b --watch", - "test": "vitest run", + "typecheck": "tsc -b --noEmit", + "test": "npm run typecheck && vitest run", "test:watch": "vitest", "lint": "eslint src", "postpack": "rm -f oclif.manifest.json", @@ -94,6 +95,9 @@ "config:list": { "description": "List configuration resources" }, + "config:profile": { + "description": "Manage configuration profiles" + }, "encoding": { "description": "Encoding operations" }, diff --git a/src/commands/config/list/organizations.ts b/src/commands/config/list/organizations.ts index dc6964a..eb73f4e 100644 --- a/src/commands/config/list/organizations.ts +++ b/src/commands/config/list/organizations.ts @@ -1,6 +1,6 @@ import chalk from 'chalk'; import {BaseCommand} from '../../../lib/base-command.js'; -import {loadConfig} from '../../../lib/config.js'; +import {loadConfig, getProfile} from '../../../lib/config.js'; export default class ConfigListOrganizations extends BaseCommand { static override description = 'List available organizations and optionally select one'; @@ -16,19 +16,19 @@ export default class ConfigListOrganizations extends BaseCommand { ]; async run(): Promise { - const config = loadConfig(); + const profile = getProfile(loadConfig()); const result = await (await this.getApi()).account.organizations.list(); const orgs = result.items ?? []; // Collect all orgs including sub-orgs for structured output const allOrgs: Record[] = []; for (const org of orgs) { - allOrgs.push({id: org.id, name: org.name, active: config.tenantOrgId === org.id, parent: null}); + allOrgs.push({id: org.id, name: org.name, active: profile.tenantOrgId === org.id, parent: null}); if (org.id) { try { const subOrgs = await (await this.getApi()).account.organizations.subOrganizations.list(org.id); for (const sub of (subOrgs.items ?? [])) { - allOrgs.push({id: sub.id, name: sub.name, active: config.tenantOrgId === sub.id, parent: org.id}); + allOrgs.push({id: sub.id, name: sub.name, active: profile.tenantOrgId === sub.id, parent: org.id}); } } catch { // Sub-organizations may not be accessible diff --git a/src/commands/config/set.ts b/src/commands/config/set.ts index 032f567..48174d1 100644 --- a/src/commands/config/set.ts +++ b/src/commands/config/set.ts @@ -1,4 +1,4 @@ -import {Args} from '@oclif/core'; +import {Args, Flags} from '@oclif/core'; import {BaseCommand} from '../../lib/base-command.js'; import {loadConfig, saveConfig} from '../../lib/config.js'; @@ -22,24 +22,33 @@ export default class ConfigSet extends BaseCommand { static override flags = { ...BaseCommand.baseFlags, + profile: Flags.string({description: 'Profile to set the value in (default: active profile)'}), }; static override examples = [ 'bitmovin config set api-key 41514766-1f1a-480b-aafd-9c89a98932e8', 'bitmovin config set organization 5a1b2c3d-...', + 'bitmovin config set api-key my-key --profile production', 'bitmovin config set default-region GOOGLE_EUROPE_WEST_1', ]; async run(): Promise { - const {args} = await this.parse(ConfigSet); + const {args, flags} = await this.parse(ConfigSet); const configKey = VALID_KEYS[args.key]; if (!configKey) { this.error(`Unknown key: ${args.key}. Valid keys: ${Object.keys(VALID_KEYS).join(', ')}`); } - const config = loadConfig(); - (config as Record)[configKey] = args.value; - saveConfig(config); - this.log(`Set ${args.key} = ${args.key === 'api-key' ? args.value.slice(0, 8) + '...' : args.value}`); + const full = loadConfig(); + const profileName = flags.profile ?? full.activeProfile; + if (!full.profiles[profileName]) { + full.profiles[profileName] = {}; + } + + (full.profiles[profileName] as Record)[configKey] = args.value; + saveConfig(full); + + const suffix = profileName !== 'default' ? ` (profile: ${profileName})` : ''; + this.log(`Set ${args.key} = ${args.key === 'api-key' ? args.value.slice(0, 8) + '...' : args.value}${suffix}`); } } diff --git a/src/commands/config/show.ts b/src/commands/config/show.ts index 11f4ac1..5323d87 100644 --- a/src/commands/config/show.ts +++ b/src/commands/config/show.ts @@ -1,15 +1,20 @@ +import {Flags} from '@oclif/core'; import {BaseCommand} from '../../lib/base-command.js'; -import {loadConfig, getConfigPath} from '../../lib/config.js'; +import {loadConfig, getProfile, getConfigPath} from '../../lib/config.js'; export default class ConfigShow extends BaseCommand { static override description = 'Show current configuration'; static override flags = { ...BaseCommand.baseFlags, + profile: Flags.string({description: 'Profile to show (default: active profile)'}), }; async run(): Promise { - const config = loadConfig(); + const {flags} = await this.parse(ConfigShow); + const full = loadConfig(); + const profileName = flags.profile ?? full.activeProfile; + const config = getProfile(full, profileName); if (await this.isJsonMode()) { const masked = config.apiKey @@ -17,6 +22,8 @@ export default class ConfigShow extends BaseCommand { : undefined; await this.outputData({ configFile: getConfigPath(), + activeProfile: full.activeProfile, + profile: profileName, apiKey: masked ?? '(not set)', tenantOrgId: config.tenantOrgId ?? '(not set)', defaultRegion: config.defaultRegion ?? '(not set)', @@ -25,6 +32,7 @@ export default class ConfigShow extends BaseCommand { } this.log(`Config file: ${getConfigPath()}\n`); + this.log(`Profile: ${profileName}${profileName === full.activeProfile ? ' (active)' : ''}`); if (config.apiKey) { const masked = config.apiKey.slice(0, 8) + '...' + config.apiKey.slice(-4); diff --git a/src/lib/base-command.ts b/src/lib/base-command.ts index c538947..bc5ad95 100644 --- a/src/lib/base-command.ts +++ b/src/lib/base-command.ts @@ -3,7 +3,7 @@ import chalk from 'chalk'; import {getClient, type ApiClient} from './client.js'; import {formatJson, formatTable, formatKeyValue, isTTY} from './output.js'; import {applyJq} from './jq.js'; -import {loadConfig} from './config.js'; +import {loadConfig, getProfile} from './config.js'; export abstract class BaseCommand extends Command { static baseFlags = { @@ -24,6 +24,7 @@ export abstract class BaseCommand extends Command { hidden: false, }), 'api-key': Flags.string({description: 'Override API key'}), + profile: Flags.string({description: 'Configuration profile to use', env: 'BITMOVIN_PROFILE'}), quiet: Flags.boolean({char: 'q', description: 'Suppress non-essential output'}), }; @@ -47,7 +48,7 @@ export abstract class BaseCommand extends Command { protected override async catch(err: Error & {httpStatusCode?: number; errorCode?: number; developerMessage?: string; requestId?: string}): Promise { // Handle Bitmovin API errors if (err.httpStatusCode) { - const config = loadConfig(); + const profile = getProfile(loadConfig(), this._parsedFlags?.profile as string | undefined); const lines: string[] = []; switch (err.httpStatusCode) { @@ -61,8 +62,8 @@ export abstract class BaseCommand extends Command { case 403: lines.push(chalk.red('Access denied.')); lines.push(''); - if (config.tenantOrgId) { - lines.push(` Active organization: ${config.tenantOrgId}`); + if (profile.tenantOrgId) { + lines.push(` Active organization: ${profile.tenantOrgId}`); lines.push(' This organization may not have access to this resource.'); lines.push(''); lines.push(' Try switching organizations:'); @@ -131,7 +132,10 @@ export abstract class BaseCommand extends Command { protected async getApi(): Promise { if (!this._api) { const flags = await this.parseFlags(); - this._api = getClient(flags['api-key'] as string | undefined); + this._api = getClient( + flags['api-key'] as string | undefined, + flags.profile as string | undefined, + ); } return this._api; diff --git a/src/lib/client.ts b/src/lib/client.ts index eb456f6..fb00b17 100644 --- a/src/lib/client.ts +++ b/src/lib/client.ts @@ -1,5 +1,5 @@ import BitmovinApiSdk from '@bitmovin/api-sdk'; -import {loadConfig} from './config.js'; +import {loadConfig, getProfile} from './config.js'; // The Bitmovin SDK is CJS with `export default class BitmovinApi`. // Under NodeNext module resolution, TypeScript treats default imports from CJS @@ -30,9 +30,9 @@ export interface ApiClient { 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 config = loadConfig(); - const apiKey = apiKeyOverride ?? process.env.BITMOVIN_API_KEY ?? config.apiKey; +export function getClient(apiKeyOverride?: string, profileName?: string): ApiClient { + const profile = getProfile(loadConfig(), profileName); + const apiKey = apiKeyOverride ?? process.env.BITMOVIN_API_KEY ?? profile.apiKey; if (!apiKey) { throw new Error( @@ -45,6 +45,6 @@ export function getClient(apiKeyOverride?: string): ApiClient { return new BitmovinApi({ apiKey, - ...(config.tenantOrgId && {tenantOrgId: config.tenantOrgId}), + ...(profile.tenantOrgId && {tenantOrgId: profile.tenantOrgId}), }); } diff --git a/src/lib/config.ts b/src/lib/config.ts index 01ca3f5..6ab2ae0 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -2,23 +2,104 @@ import {existsSync, mkdirSync, readFileSync, writeFileSync} from 'node:fs'; import {homedir} from 'node:os'; import {join} from 'node:path'; -export interface CliConfig { +export interface ProfileConfig { apiKey?: string; tenantOrgId?: string; defaultRegion?: string; } +// Backward-compat alias — existing code that imports CliConfig continues to work +export type CliConfig = ProfileConfig; + +export interface FullConfig { + activeProfile: string; + profiles: Record; +} + const CONFIG_DIR = join(homedir(), '.config', 'bitmovin'); const CONFIG_FILE = join(CONFIG_DIR, 'config.json'); -export function loadConfig(): CliConfig { - if (!existsSync(CONFIG_FILE)) return {}; - return JSON.parse(readFileSync(CONFIG_FILE, 'utf-8')) as CliConfig; +/** + * Load the full configuration, migrating legacy flat configs on the fly. + * A legacy config ({"apiKey":"...","tenantOrgId":"..."}) is transparently + * mapped to a single "default" profile so existing setups keep working. + */ +export function loadConfig(): FullConfig { + if (!existsSync(CONFIG_FILE)) { + return {activeProfile: 'default', profiles: {default: {}}}; + } + + const raw = JSON.parse(readFileSync(CONFIG_FILE, 'utf-8')) as Record; + + // New format: has a 'profiles' key + if (raw.profiles && typeof raw.profiles === 'object') { + return { + activeProfile: typeof raw.activeProfile === 'string' ? raw.activeProfile : 'default', + profiles: raw.profiles as Record, + }; + } + + // Legacy flat format — migrate top-level keys into the default profile and rewrite immediately + const profile: ProfileConfig = {}; + if (typeof raw.apiKey === 'string') profile.apiKey = raw.apiKey; + if (typeof raw.tenantOrgId === 'string') profile.tenantOrgId = raw.tenantOrgId; + if (typeof raw.defaultRegion === 'string') profile.defaultRegion = raw.defaultRegion; + const migrated: FullConfig = {activeProfile: 'default', profiles: {default: profile}}; + saveConfig(migrated); + return migrated; +} + +/** + * Get the config for a named profile (defaults to the active profile). + */ +export function getProfile(config: FullConfig, profileName?: string): ProfileConfig { + const name = profileName ?? config.activeProfile; + return config.profiles[name] ?? {}; } -export function saveConfig(config: CliConfig): void { +export function saveConfig(full: FullConfig): void { mkdirSync(CONFIG_DIR, {recursive: true}); - writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2) + '\n'); + writeFileSync(CONFIG_FILE, JSON.stringify(full, null, 2) + '\n'); +} + +export function setActiveProfile(name: string): void { + const full = loadConfig(); + if (!full.profiles[name]) { + throw new Error( + `Profile '${name}' does not exist.\n` + + `Create it first: bitmovin config set api-key --profile ${name}`, + ); + } + + full.activeProfile = name; + saveConfig(full); +} + +export function listProfiles(): Array<{name: string; active: boolean; config: ProfileConfig}> { + const full = loadConfig(); + return Object.entries(full.profiles).map(([name, config]) => ({ + name, + active: name === full.activeProfile, + config, + })); +} + +export function deleteProfile(name: string): void { + if (name === 'default') { + throw new Error("Cannot delete the 'default' profile."); + } + + const full = loadConfig(); + if (!full.profiles[name]) { + throw new Error(`Profile '${name}' does not exist.`); + } + + delete full.profiles[name]; + if (full.activeProfile === name) { + full.activeProfile = 'default'; + } + + saveConfig(full); } export function getConfigPath(): string { diff --git a/test/commands/base-command-errors.test.ts b/test/commands/base-command-errors.test.ts index 64831b0..47f02a7 100644 --- a/test/commands/base-command-errors.test.ts +++ b/test/commands/base-command-errors.test.ts @@ -2,7 +2,14 @@ import {describe, it, expect, vi, afterEach} from 'vitest'; // Mock the config module vi.mock('../../src/lib/config.js', () => ({ - loadConfig: () => ({apiKey: 'test-key'}), + loadConfig: () => ({ + activeProfile: 'default', + profiles: {default: {apiKey: 'test-key'}}, + }), + getProfile: (full: any, profileName?: string) => { + const name = profileName ?? full.activeProfile; + return full.profiles[name] ?? {}; + }, saveConfig: () => {}, getConfigPath: () => '/mock/.config/bitmovin/config.json', })); diff --git a/test/commands/config.test.ts b/test/commands/config.test.ts index 7aaf6bc..07b9552 100644 --- a/test/commands/config.test.ts +++ b/test/commands/config.test.ts @@ -2,15 +2,22 @@ import {describe, it, expect, vi, beforeEach} from 'vitest'; // Mock the config module vi.mock('../../src/lib/config.js', () => { - let store: Record = {}; + let store = { + activeProfile: 'default', + profiles: {default: {}} as Record>, + }; return { - loadConfig: () => ({...store}), - saveConfig: (config: any) => { - store = {...config}; + loadConfig: () => ({...store, profiles: {...store.profiles}}), + saveConfig: (full: typeof store) => { + store = {...full}; + }, + getProfile: (full: typeof store, profileName?: string) => { + const name = profileName ?? full.activeProfile; + return full.profiles[name] ?? {}; }, getConfigPath: () => '/mock/.config/bitmovin/config.json', _reset: () => { - store = {}; + store = {activeProfile: 'default', profiles: {default: {}}}; }, _getStore: () => store, }; @@ -39,30 +46,47 @@ function captureOutput(): {output: () => string; restore: () => void} { describe('config set', () => { beforeEach(() => configMock._reset()); - it('sets api-key', async () => { + it('sets api-key in the default profile', async () => { const cap = captureOutput(); const {default: Cmd} = await import('../../src/commands/config/set.js'); await Cmd.run(['api-key', 'my-test-key']); cap.restore(); expect(cap.output()).toContain('Set api-key'); - expect(configMock._getStore().apiKey).toBe('my-test-key'); + expect(configMock._getStore().profiles.default.apiKey).toBe('my-test-key'); }); - it('sets organization', async () => { + it('sets organization in the default profile', async () => { const cap = captureOutput(); const {default: Cmd} = await import('../../src/commands/config/set.js'); await Cmd.run(['organization', 'org-123']); cap.restore(); expect(cap.output()).toContain('Set organization'); - expect(configMock._getStore().tenantOrgId).toBe('org-123'); + expect(configMock._getStore().profiles.default.tenantOrgId).toBe('org-123'); }); - it('sets default-region', async () => { + it('sets default-region in the default profile', async () => { const cap = captureOutput(); const {default: Cmd} = await import('../../src/commands/config/set.js'); await Cmd.run(['default-region', 'AWS_EU_WEST_1']); cap.restore(); - expect(configMock._getStore().defaultRegion).toBe('AWS_EU_WEST_1'); + expect(configMock._getStore().profiles.default.defaultRegion).toBe('AWS_EU_WEST_1'); + }); + + it('sets api-key in a named profile', async () => { + const cap = captureOutput(); + const {default: Cmd} = await import('../../src/commands/config/set.js'); + await Cmd.run(['api-key', 'prod-key', '--profile', 'production']); + cap.restore(); + expect(cap.output()).toContain('profile: production'); + expect(configMock._getStore().profiles.production.apiKey).toBe('prod-key'); + }); + + it('creates a new profile when it does not exist', async () => { + const cap = captureOutput(); + const {default: Cmd} = await import('../../src/commands/config/set.js'); + await Cmd.run(['api-key', 'staging-key', '--profile', 'staging']); + cap.restore(); + expect(configMock._getStore().profiles.staging.apiKey).toBe('staging-key'); }); }); @@ -78,7 +102,10 @@ describe('config show', () => { }); it('masks api key', async () => { - configMock.saveConfig({apiKey: '12345678-abcd-1234-abcd-123456789abc'}); + configMock.saveConfig({ + activeProfile: 'default', + profiles: {default: {apiKey: '12345678-abcd-1234-abcd-123456789abc'}}, + }); const cap = captureOutput(); const {default: Cmd} = await import('../../src/commands/config/show.js'); await Cmd.run([]); @@ -86,4 +113,12 @@ describe('config show', () => { expect(cap.output()).toContain('12345678...'); expect(cap.output()).not.toContain('12345678-abcd-1234-abcd-123456789abc'); }); + + it('shows the active profile label', async () => { + const cap = captureOutput(); + const {default: Cmd} = await import('../../src/commands/config/show.js'); + await Cmd.run([]); + cap.restore(); + expect(cap.output()).toContain('default (active)'); + }); }); diff --git a/test/lib/client.test.ts b/test/lib/client.test.ts index bf23599..44abc6c 100644 --- a/test/lib/client.test.ts +++ b/test/lib/client.test.ts @@ -2,7 +2,14 @@ import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest'; // Mock the config module vi.mock('../../src/lib/config.js', () => ({ - loadConfig: () => ({apiKey: 'config-file-key'}), + loadConfig: () => ({ + activeProfile: 'default', + profiles: {default: {apiKey: 'config-file-key'}}, + }), + getProfile: (full: any, profileName?: string) => { + const name = profileName ?? full.activeProfile; + return full.profiles[name] ?? {}; + }, saveConfig: () => {}, getConfigPath: () => '/mock/.config/bitmovin/config.json', })); diff --git a/test/lib/config.test.ts b/test/lib/config.test.ts index 39d5736..2e48142 100644 --- a/test/lib/config.test.ts +++ b/test/lib/config.test.ts @@ -1,6 +1,6 @@ -import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest'; +import {describe, it, expect, vi, beforeEach} from 'vitest'; import {existsSync, readFileSync, writeFileSync, mkdirSync} from 'node:fs'; -import {loadConfig, saveConfig} from '../../src/lib/config.js'; +import {loadConfig, saveConfig, getProfile, listProfiles, setActiveProfile, deleteProfile} from '../../src/lib/config.js'; vi.mock('node:fs', () => ({ existsSync: vi.fn(), @@ -14,17 +14,81 @@ describe('loadConfig', () => { vi.clearAllMocks(); }); - it('returns empty config when file does not exist', () => { + it('returns empty default profile when file does not exist', () => { vi.mocked(existsSync).mockReturnValue(false); - expect(loadConfig()).toEqual({}); + const full = loadConfig(); + expect(full.activeProfile).toBe('default'); + expect(full.profiles.default).toEqual({}); }); - it('parses config from file', () => { + it('parses new-format config', () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ + activeProfile: 'default', + profiles: {default: {apiKey: 'abc123', tenantOrgId: 'org1'}}, + })); + const full = loadConfig(); + expect(full.activeProfile).toBe('default'); + expect(full.profiles.default.apiKey).toBe('abc123'); + expect(full.profiles.default.tenantOrgId).toBe('org1'); + }); + + it('migrates legacy flat config into the default profile and rewrites the file', () => { vi.mocked(existsSync).mockReturnValue(true); vi.mocked(readFileSync).mockReturnValue('{"apiKey":"abc123","tenantOrgId":"org1"}'); - const config = loadConfig(); - expect(config.apiKey).toBe('abc123'); - expect(config.tenantOrgId).toBe('org1'); + const full = loadConfig(); + expect(full.activeProfile).toBe('default'); + expect(full.profiles.default.apiKey).toBe('abc123'); + expect(full.profiles.default.tenantOrgId).toBe('org1'); + // File must be rewritten in the new format immediately + expect(writeFileSync).toHaveBeenCalledOnce(); + const written = vi.mocked(writeFileSync).mock.calls[0]![1] as string; + const parsed = JSON.parse(written); + expect(parsed.activeProfile).toBe('default'); + expect(parsed.profiles.default.apiKey).toBe('abc123'); + }); + + it('parses multiple profiles', () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ + activeProfile: 'production', + profiles: { + default: {apiKey: 'key-default'}, + production: {apiKey: 'key-prod', tenantOrgId: 'org-prod'}, + }, + })); + const full = loadConfig(); + expect(full.activeProfile).toBe('production'); + expect(full.profiles.production.apiKey).toBe('key-prod'); + }); +}); + +describe('getProfile', () => { + it('returns the active profile when no name given', () => { + const full = { + activeProfile: 'production', + profiles: { + default: {apiKey: 'key-default'}, + production: {apiKey: 'key-prod'}, + }, + }; + expect(getProfile(full).apiKey).toBe('key-prod'); + }); + + it('returns the named profile', () => { + const full = { + activeProfile: 'default', + profiles: { + default: {apiKey: 'key-default'}, + staging: {apiKey: 'key-staging'}, + }, + }; + expect(getProfile(full, 'staging').apiKey).toBe('key-staging'); + }); + + it('returns empty object for unknown profile', () => { + const full = {activeProfile: 'default', profiles: {default: {apiKey: 'k'}}}; + expect(getProfile(full, 'nonexistent')).toEqual({}); }); }); @@ -34,7 +98,7 @@ describe('saveConfig', () => { }); it('creates config directory and writes file', () => { - saveConfig({apiKey: 'test-key'}); + saveConfig({activeProfile: 'default', profiles: {default: {apiKey: 'test-key'}}}); expect(mkdirSync).toHaveBeenCalledWith(expect.stringContaining('bitmovin'), {recursive: true}); expect(writeFileSync).toHaveBeenCalledWith( expect.stringContaining('config.json'), @@ -42,12 +106,114 @@ describe('saveConfig', () => { ); }); - it('preserves all config fields', () => { - saveConfig({apiKey: 'key', tenantOrgId: 'org', defaultRegion: 'EU'}); + it('preserves all profile fields', () => { + saveConfig({ + activeProfile: 'default', + profiles: {default: {apiKey: 'key', tenantOrgId: 'org', defaultRegion: 'EU'}}, + }); const written = vi.mocked(writeFileSync).mock.calls[0]![1] as string; const parsed = JSON.parse(written); - expect(parsed.apiKey).toBe('key'); - expect(parsed.tenantOrgId).toBe('org'); - expect(parsed.defaultRegion).toBe('EU'); + expect(parsed.profiles.default.apiKey).toBe('key'); + expect(parsed.profiles.default.tenantOrgId).toBe('org'); + expect(parsed.profiles.default.defaultRegion).toBe('EU'); + }); + + it('writes multiple profiles', () => { + saveConfig({ + activeProfile: 'prod', + profiles: { + default: {apiKey: 'key-a'}, + prod: {apiKey: 'key-b', tenantOrgId: 'org-b'}, + }, + }); + const written = vi.mocked(writeFileSync).mock.calls[0]![1] as string; + const parsed = JSON.parse(written); + expect(parsed.activeProfile).toBe('prod'); + expect(parsed.profiles.default.apiKey).toBe('key-a'); + expect(parsed.profiles.prod.apiKey).toBe('key-b'); + }); +}); + +describe('listProfiles', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns all profiles with correct active flag', () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ + activeProfile: 'default', + profiles: { + default: {apiKey: 'k1'}, + prod: {apiKey: 'k2'}, + }, + })); + const profiles = listProfiles(); + expect(profiles).toHaveLength(2); + expect(profiles.find(p => p.name === 'default')?.active).toBe(true); + expect(profiles.find(p => p.name === 'prod')?.active).toBe(false); + }); +}); + +describe('setActiveProfile', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('switches the active profile', () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ + activeProfile: 'default', + profiles: {default: {}, prod: {apiKey: 'k'}}, + })); + setActiveProfile('prod'); + const written = vi.mocked(writeFileSync).mock.calls[0]![1] as string; + expect(JSON.parse(written).activeProfile).toBe('prod'); + }); + + it('throws when profile does not exist', () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ + activeProfile: 'default', + profiles: {default: {}}, + })); + expect(() => setActiveProfile('nonexistent')).toThrow("Profile 'nonexistent' does not exist"); + }); +}); + +describe('deleteProfile', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('throws when deleting the default profile', () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ + activeProfile: 'default', + profiles: {default: {}}, + })); + expect(() => deleteProfile('default')).toThrow("Cannot delete the 'default' profile"); + }); + + it('deletes a non-default profile', () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ + activeProfile: 'default', + profiles: {default: {}, staging: {apiKey: 'k'}}, + })); + deleteProfile('staging'); + const written = vi.mocked(writeFileSync).mock.calls[0]![1] as string; + expect(JSON.parse(written).profiles.staging).toBeUndefined(); + }); + + it('resets activeProfile to default when the active profile is deleted', () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ + activeProfile: 'staging', + profiles: {default: {}, staging: {apiKey: 'k'}}, + })); + deleteProfile('staging'); + const written = vi.mocked(writeFileSync).mock.calls[0]![1] as string; + expect(JSON.parse(written).activeProfile).toBe('default'); }); }); From 55c2631ee22c3010919b68617a592e60a26ace3c Mon Sep 17 00:00:00 2001 From: saravanan Date: Wed, 15 Apr 2026 10:23:00 +0800 Subject: [PATCH 2/2] Warn when BITMOVIN_API_KEY overrides the newly switched profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If BITMOVIN_API_KEY is set in the environment, switching profiles with 'config profile use' would have no effect on the API key actually used — the env var silently wins. Emit a warning immediately after the switch so users know to unset it. Co-Authored-By: Claude Sonnet 4.6 --- src/commands/config/profile/delete.ts | 29 +++++++++++++++ src/commands/config/profile/list.ts | 53 +++++++++++++++++++++++++++ src/commands/config/profile/use.ts | 35 ++++++++++++++++++ 3 files changed, 117 insertions(+) create mode 100644 src/commands/config/profile/delete.ts create mode 100644 src/commands/config/profile/list.ts create mode 100644 src/commands/config/profile/use.ts diff --git a/src/commands/config/profile/delete.ts b/src/commands/config/profile/delete.ts new file mode 100644 index 0000000..ed919da --- /dev/null +++ b/src/commands/config/profile/delete.ts @@ -0,0 +1,29 @@ +import {Args} from '@oclif/core'; +import {BaseCommand} from '../../../lib/base-command.js'; +import {deleteProfile} from '../../../lib/config.js'; + +export default class ConfigProfileDelete extends BaseCommand { + static override description = 'Delete a configuration profile'; + + static override flags = { + ...BaseCommand.baseFlags, + }; + + static override args = { + name: Args.string({description: 'Profile name to delete', required: true}), + }; + + static override examples = [ + 'bitmovin config profile delete staging', + ]; + + async run(): Promise { + const {args} = await this.parse(ConfigProfileDelete); + try { + deleteProfile(args.name); + this.log(`Deleted profile: ${args.name}`); + } catch (err) { + this.error((err as Error).message); + } + } +} diff --git a/src/commands/config/profile/list.ts b/src/commands/config/profile/list.ts new file mode 100644 index 0000000..a1e8880 --- /dev/null +++ b/src/commands/config/profile/list.ts @@ -0,0 +1,53 @@ +import chalk from 'chalk'; +import {BaseCommand} from '../../../lib/base-command.js'; +import {listProfiles} from '../../../lib/config.js'; + +export default class ConfigProfileList extends BaseCommand { + static override description = 'List all configuration profiles'; + + static override flags = { + ...BaseCommand.baseFlags, + }; + + static override examples = [ + 'bitmovin config profile list', + 'bitmovin config profile list --json', + ]; + + async run(): Promise { + const profiles = listProfiles(); + + if (await this.isJsonMode()) { + await this.outputList( + profiles.map(p => ({ + name: p.name, + active: p.active, + apiKey: p.config.apiKey ? p.config.apiKey.slice(0, 8) + '...' : null, + tenantOrgId: p.config.tenantOrgId ?? null, + defaultRegion: p.config.defaultRegion ?? null, + })), + ['name', 'active', 'apiKey', 'tenantOrgId', 'defaultRegion'], + ); + return; + } + + if (profiles.length === 0) { + this.log('No profiles configured.'); + return; + } + + const lines: string[] = ['']; + for (const p of profiles) { + const activeMarker = p.active ? chalk.green(' (active)') : ''; + const keyStr = p.config.apiKey + ? chalk.dim(p.config.apiKey.slice(0, 8) + '...') + : chalk.dim('no api key'); + const orgStr = p.config.tenantOrgId ? chalk.dim(` org: ${p.config.tenantOrgId}`) : ''; + lines.push(` ${chalk.bold(p.name)}${activeMarker} ${keyStr}${orgStr}`); + } + + lines.push(''); + lines.push(chalk.dim('Switch profile: bitmovin config profile use ')); + process.stdout.write(lines.join('\n') + '\n'); + } +} diff --git a/src/commands/config/profile/use.ts b/src/commands/config/profile/use.ts new file mode 100644 index 0000000..f451957 --- /dev/null +++ b/src/commands/config/profile/use.ts @@ -0,0 +1,35 @@ +import {Args} from '@oclif/core'; +import {BaseCommand} from '../../../lib/base-command.js'; +import {setActiveProfile} from '../../../lib/config.js'; + +export default class ConfigProfileUse extends BaseCommand { + static override description = 'Switch the active configuration profile'; + + static override flags = { + ...BaseCommand.baseFlags, + }; + + static override args = { + name: Args.string({description: 'Profile name to activate', required: true}), + }; + + static override examples = [ + 'bitmovin config profile use production', + 'bitmovin config profile use default', + ]; + + async run(): Promise { + const {args} = await this.parse(ConfigProfileUse); + try { + setActiveProfile(args.name); + this.log(`Switched to profile: ${args.name}`); + if (process.env.BITMOVIN_API_KEY) { + this.warn( + `BITMOVIN_API_KEY is set in your environment and will override the api-key from the '${args.name}' profile.`, + ); + } + } catch (err) { + this.error((err as Error).message); + } + } +}