From e86c3a32f3f12cdd37d1241163b525447a8547d4 Mon Sep 17 00:00:00 2001 From: Michael Roudnitski Date: Fri, 31 Oct 2025 18:07:00 -0400 Subject: [PATCH 1/6] Base profiles --- src/core/providers/openai.ts | 18 +++ src/core/providers/watsonx.ts | 41 +---- src/lib/profile/factory.ts | 18 +++ src/lib/profile/storage.ts | 64 ++++++++ src/lib/profile/types.ts | 25 +++ test/commands/profile/storage.test.ts | 225 ++++++++++++++++++++++++++ 6 files changed, 358 insertions(+), 33 deletions(-) create mode 100644 src/core/providers/openai.ts create mode 100644 src/lib/profile/factory.ts create mode 100644 src/lib/profile/storage.ts create mode 100644 src/lib/profile/types.ts create mode 100644 test/commands/profile/storage.test.ts diff --git a/src/core/providers/openai.ts b/src/core/providers/openai.ts new file mode 100644 index 0000000..721910b --- /dev/null +++ b/src/core/providers/openai.ts @@ -0,0 +1,18 @@ +import {ChatOpenAI} from '@langchain/openai' + +export interface OpenAIConfig { + apiKey: string + maxTokens?: number + model: string + temperature?: number +} + +export function createClient(config: OpenAIConfig): ChatOpenAI { + return new ChatOpenAI({ + apiKey: config.apiKey, + maxRetries: 3, + maxTokens: config.maxTokens || 2000, + model: config.model, + temperature: config.temperature || 0.3, + }) +} diff --git a/src/core/providers/watsonx.ts b/src/core/providers/watsonx.ts index 9505f9e..3fade8e 100644 --- a/src/core/providers/watsonx.ts +++ b/src/core/providers/watsonx.ts @@ -1,49 +1,24 @@ import {ChatWatsonx} from '@langchain/community/chat_models/ibm' -/** - * Configuration for watsonx.ai client - */ export interface WatsonxConfig { apiKey: string maxNewTokens?: number - model?: string + model: string projectId: string serviceUrl: string temperature?: number } -/** - * Creates and returns a configured watsonx.ai chat model with IAM authentication - */ -export function createClient(config?: Partial): ChatWatsonx { - // Get configuration from environment variables or provided config - const serviceUrl = config?.serviceUrl || process.env.WATSONX_AI_SERVICE_URL - const projectId = config?.projectId || process.env.WATSONX_AI_PROJECT_ID - const apiKey = config?.apiKey || process.env.WATSONX_AI_APIKEY - - // Validate required configuration - if (!serviceUrl) { - throw new Error('WATSONX_AI_SERVICE_URL is required') - } - - if (!projectId) { - throw new Error('WATSONX_AI_PROJECT_ID is required') - } - - if (!apiKey) { - throw new Error('WATSONX_AI_APIKEY is required') - } - - // Create and return the chat model with IAM authentication +export function createClient(config: WatsonxConfig): ChatWatsonx { return new ChatWatsonx({ maxRetries: 3, - maxTokens: config?.maxNewTokens || 2000, - model: config?.model || 'ibm/granite-4-h-small', - projectId, - serviceUrl, - temperature: config?.temperature || 0.3, + maxTokens: config.maxNewTokens || 2000, + model: config.model, + projectId: config.projectId, + serviceUrl: config.serviceUrl, + temperature: config.temperature || 0.3, version: '2024-05-31', - watsonxAIApikey: apiKey, + watsonxAIApikey: config.apiKey, watsonxAIAuthType: 'iam', }) } diff --git a/src/lib/profile/factory.ts b/src/lib/profile/factory.ts new file mode 100644 index 0000000..a4f47a4 --- /dev/null +++ b/src/lib/profile/factory.ts @@ -0,0 +1,18 @@ +import type {BaseChatModel} from '@langchain/core/language_models/chat_models' + +import type {Profile} from './types.js' + +import {createClient as createOpenAIClient} from '../../core/providers/openai.js' +import {createClient as createWatsonxClient} from '../../core/providers/watsonx.js' + +export function createProviderFromProfile(profile: Profile): BaseChatModel { + switch (profile.provider) { + case 'openai': { + return createOpenAIClient(profile.config) + } + + case 'watsonx': { + return createWatsonxClient(profile.config) + } + } +} diff --git a/src/lib/profile/storage.ts b/src/lib/profile/storage.ts new file mode 100644 index 0000000..e946b92 --- /dev/null +++ b/src/lib/profile/storage.ts @@ -0,0 +1,64 @@ +import {existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync} from 'node:fs' +import {homedir} from 'node:os' +import {join} from 'node:path' + +import type {Profile} from './types.js' + +export function getProfilesDir(): string { + const home = process.env.HOME || homedir() + return join(home, '.config', 'translation-ai-cli', 'profiles') +} + +export function ensureProfilesDir(): void { + const dir = getProfilesDir() + if (!existsSync(dir)) { + mkdirSync(dir, {recursive: true}) + } +} + +export function getProfilePath(name: string): string { + return join(getProfilesDir(), `${name}.json`) +} + +export function saveProfile(profile: Profile): void { + ensureProfilesDir() + const path = getProfilePath(profile.name) + writeFileSync(path, JSON.stringify(profile, null, 2), { + encoding: 'utf8', + mode: 0o600, // only owner can read/write + }) +} + +export function loadProfile(name: string): Profile { + const path = getProfilePath(name) + if (!existsSync(path)) { + throw new Error(`Profile "${name}" does not exist`) + } + + const content = readFileSync(path, 'utf8') + return JSON.parse(content) as Profile +} + +export function listProfiles(): string[] { + const dir = getProfilesDir() + if (!existsSync(dir)) { + return [] + } + + return readdirSync(dir) + .filter((file) => file.endsWith('.json')) + .map((file) => file.replace(/\.json$/, '')) +} + +export function deleteProfile(name: string): void { + const path = getProfilePath(name) + if (!existsSync(path)) { + throw new Error(`Profile "${name}" does not exist`) + } + + rmSync(path) +} + +export function profileExists(name: string): boolean { + return existsSync(getProfilePath(name)) +} diff --git a/src/lib/profile/types.ts b/src/lib/profile/types.ts new file mode 100644 index 0000000..e8bab33 --- /dev/null +++ b/src/lib/profile/types.ts @@ -0,0 +1,25 @@ +export interface OpenAIProfileConfig { + apiKey: string + model: string +} + +export interface WatsonxProfileConfig { + apiKey: string + model: string + projectId: string + serviceUrl: string +} + +export interface OpenAIProfile { + config: OpenAIProfileConfig + name: string + provider: 'openai' +} + +export interface WatsonxProfile { + config: WatsonxProfileConfig + name: string + provider: 'watsonx' +} + +export type Profile = OpenAIProfile | WatsonxProfile diff --git a/test/commands/profile/storage.test.ts b/test/commands/profile/storage.test.ts new file mode 100644 index 0000000..e0f38fa --- /dev/null +++ b/test/commands/profile/storage.test.ts @@ -0,0 +1,225 @@ +import {afterEach, beforeEach, describe, expect, it} from '@jest/globals' +import {existsSync, mkdirSync, rmSync} from 'node:fs' +import {tmpdir} from 'node:os' +import {join} from 'node:path' + +import type {Profile} from '../../../src/lib/profile/types.js' + +import { + deleteProfile, + getProfilePath, + listProfiles, + loadProfile, + profileExists, + saveProfile, +} from '../../../src/lib/profile/storage.js' + +describe('profile storage', () => { + let testDir: string + const originalHomeDir = process.env.HOME + + beforeEach(() => { + testDir = join(tmpdir(), `test-profiles-${Date.now()}`) + mkdirSync(testDir, {recursive: true}) + process.env.HOME = testDir + }) + + afterEach(() => { + if (existsSync(testDir)) { + rmSync(testDir, {force: true, recursive: true}) + } + + process.env.HOME = originalHomeDir + }) + + describe('saveProfile', () => { + it('creates profiles directory if it does not exist', () => { + const profilesDir = join(testDir, '.config', 'translation-ai-cli', 'profiles') + if (existsSync(profilesDir)) { + rmSync(profilesDir, {force: true, recursive: true}) + } + + const profile: Profile = { + config: { + apiKey: 'test-key', + model: 'gpt-4o', + }, + name: 'test-profile', + provider: 'openai', + } + + saveProfile(profile) + + expect(existsSync(profilesDir)).toBe(true) + expect(existsSync(getProfilePath('test-profile'))).toBe(true) + }) + + it('saves an OpenAI profile', () => { + const profile: Profile = { + config: { + apiKey: 'test-key', + model: 'gpt-4o', + }, + name: 'test-openai', + provider: 'openai', + } + + saveProfile(profile) + + const saved = loadProfile('test-openai') + expect(saved).toEqual(profile) + }) + + it('saves a Watsonx profile', () => { + const profile: Profile = { + config: { + apiKey: 'test-key', + model: 'ibm/granite-3-8b-instruct', + projectId: 'test-project', + serviceUrl: 'https://test.example.com', + }, + name: 'test-watsonx', + provider: 'watsonx', + } + + saveProfile(profile) + + const saved = loadProfile('test-watsonx') + expect(saved).toEqual(profile) + }) + + it('overwrites existing profile (idempotent)', () => { + const profile1: Profile = { + config: { + apiKey: 'key-1', + model: 'gpt-4o', + }, + name: 'test-profile', + provider: 'openai', + } + + const profile2: Profile = { + config: { + apiKey: 'key-2', + model: 'gpt-4o-mini', + }, + name: 'test-profile', + provider: 'openai', + } + + saveProfile(profile1) + saveProfile(profile2) + + const saved = loadProfile('test-profile') + expect(saved).toEqual(profile2) + }) + }) + + describe('loadProfile', () => { + it('loads an existing profile', () => { + const profile: Profile = { + config: { + apiKey: 'test-key', + model: 'gpt-4o', + }, + name: 'test-profile', + provider: 'openai', + } + + saveProfile(profile) + + const loaded = loadProfile('test-profile') + expect(loaded).toEqual(profile) + }) + + it('throws error when profile does not exist', () => { + expect(() => loadProfile('nonexistent')).toThrow('Profile "nonexistent" does not exist') + }) + }) + + describe('listProfiles', () => { + it('returns empty array when no profiles exist', () => { + const profiles = listProfiles() + expect(profiles).toEqual([]) + }) + + it('returns empty array when directory does not exist', () => { + rmSync(testDir, {force: true, recursive: true}) + const profiles = listProfiles() + expect(profiles).toEqual([]) + }) + + it('lists all saved profiles', () => { + const profile1: Profile = { + config: { + apiKey: 'key-1', + model: 'gpt-4o', + }, + name: 'profile-1', + provider: 'openai', + } + + const profile2: Profile = { + config: { + apiKey: 'key-2', + model: 'ibm/granite-3-8b-instruct', + projectId: 'test-project', + serviceUrl: 'https://test.example.com', + }, + name: 'profile-2', + provider: 'watsonx', + } + + saveProfile(profile1) + saveProfile(profile2) + + const profiles = listProfiles() + expect(profiles).toContain('profile-1') + expect(profiles).toContain('profile-2') + expect(profiles).toHaveLength(2) + }) + }) + + describe('deleteProfile', () => { + it('deletes an existing profile', () => { + const profile: Profile = { + config: { + apiKey: 'test-key', + model: 'gpt-4o', + }, + name: 'test-profile', + provider: 'openai', + } + + saveProfile(profile) + expect(profileExists('test-profile')).toBe(true) + + deleteProfile('test-profile') + expect(profileExists('test-profile')).toBe(false) + }) + + it('throws error when profile does not exist', () => { + expect(() => deleteProfile('nonexistent')).toThrow('Profile "nonexistent" does not exist') + }) + }) + + describe('profileExists', () => { + it('returns true when profile exists', () => { + const profile: Profile = { + config: { + apiKey: 'test-key', + model: 'gpt-4o', + }, + name: 'test-profile', + provider: 'openai', + } + + saveProfile(profile) + expect(profileExists('test-profile')).toBe(true) + }) + + it('returns false when profile does not exist', () => { + expect(profileExists('nonexistent')).toBe(false) + }) + }) +}) From 02ec84d763fa21740b0134dbb8383f1b513def0f Mon Sep 17 00:00:00 2001 From: Michael Roudnitski Date: Fri, 31 Oct 2025 18:18:25 -0400 Subject: [PATCH 2/6] Base profile commands --- src/commands/profiles/delete.ts | 29 +++++++ src/commands/profiles/list.ts | 21 +++++ src/commands/profiles/set.ts | 84 +++++++++++++++++++ .../{profile => profiles}/storage.test.ts | 0 4 files changed, 134 insertions(+) create mode 100644 src/commands/profiles/delete.ts create mode 100644 src/commands/profiles/list.ts create mode 100644 src/commands/profiles/set.ts rename test/commands/{profile => profiles}/storage.test.ts (100%) diff --git a/src/commands/profiles/delete.ts b/src/commands/profiles/delete.ts new file mode 100644 index 0000000..b54952c --- /dev/null +++ b/src/commands/profiles/delete.ts @@ -0,0 +1,29 @@ +import {Args, Command} from '@oclif/core' + +import {deleteProfile} from '../../lib/profile/storage.js' + +export default class ProfilesDelete extends Command { + static args = { + name: Args.string({ + description: 'Profile name to delete', + required: true, + }), + } + static description = 'Delete a profile' + static examples = ['<%= config.bin %> <%= command.id %> my-profile'] + + async run(): Promise { + const {args} = await this.parse(ProfilesDelete) + + try { + deleteProfile(args.name) + this.log(`Profile "${args.name}" deleted successfully`) + } catch (error) { + if (error instanceof Error) { + this.error(error.message) + } + + throw error + } + } +} diff --git a/src/commands/profiles/list.ts b/src/commands/profiles/list.ts new file mode 100644 index 0000000..92f0240 --- /dev/null +++ b/src/commands/profiles/list.ts @@ -0,0 +1,21 @@ +import {Command} from '@oclif/core' + +import {listProfiles} from '../../lib/profile/storage.js' + +export default class ProfilesList extends Command { + static description = 'List all profiles' + static examples = ['<%= config.bin %> <%= command.id %>'] + + async run(): Promise { + const profiles = listProfiles() + + if (profiles.length === 0) { + this.log('No profiles found') + return + } + + for (const profile of profiles) { + this.log(profile) + } + } +} diff --git a/src/commands/profiles/set.ts b/src/commands/profiles/set.ts new file mode 100644 index 0000000..2b1b340 --- /dev/null +++ b/src/commands/profiles/set.ts @@ -0,0 +1,84 @@ +import {Args, Command, Flags} from '@oclif/core' + +import type {OpenAIProfileConfig, WatsonxProfileConfig} from '../../lib/profile/types.js' + +import {saveProfile} from '../../lib/profile/storage.js' + +export default class ProfilesSet extends Command { + static args = { + name: Args.string({ + description: 'Profile name', + required: true, + }), + } + static description = 'Create or update a profile' + static examples = [ + '<%= config.bin %> <%= command.id %> my-openai-profile --provider openai --api-key sk-... --model gpt-4o', + '<%= config.bin %> <%= command.id %> my-watsonx-profile --provider watsonx --api-key ... --project-id ... --service-url https://... --model ibm/granite-3-8b-instruct', + ] + static flags = { + 'api-key': Flags.string({ + description: 'API key for the provider', + required: true, + }), + model: Flags.string({ + description: 'Model to use', + required: true, + }), + 'project-id': Flags.string({ + description: 'Watsonx project ID (required for watsonx)', + required: false, + }), + provider: Flags.string({ + description: 'LLM provider', + options: ['openai', 'watsonx'], + required: true, + }), + 'service-url': Flags.string({ + description: 'Watsonx service URL (required for watsonx)', + required: false, + }), + } + + async run(): Promise { + const {args, flags} = await this.parse(ProfilesSet) + + if (flags.provider === 'openai') { + const config: OpenAIProfileConfig = { + apiKey: flags['api-key'], + model: flags.model, + } + + saveProfile({ + config, + name: args.name, + provider: 'openai', + }) + + this.log(`Profile "${args.name}" saved successfully`) + } else if (flags.provider === 'watsonx') { + if (!flags['project-id']) { + this.error('--project-id is required for watsonx provider') + } + + if (!flags['service-url']) { + this.error('--service-url is required for watsonx provider') + } + + const config: WatsonxProfileConfig = { + apiKey: flags['api-key'], + model: flags.model, + projectId: flags['project-id'], + serviceUrl: flags['service-url'], + } + + saveProfile({ + config, + name: args.name, + provider: 'watsonx', + }) + + this.log(`Profile "${args.name}" saved successfully`) + } + } +} diff --git a/test/commands/profile/storage.test.ts b/test/commands/profiles/storage.test.ts similarity index 100% rename from test/commands/profile/storage.test.ts rename to test/commands/profiles/storage.test.ts From df6bacc297318ff3fd016d4656259546df60d934 Mon Sep 17 00:00:00 2001 From: Michael Roudnitski Date: Fri, 31 Oct 2025 18:21:33 -0400 Subject: [PATCH 3/6] Install openai langchain --- package.json | 1 + pnpm-lock.yaml | 3 +++ 2 files changed, 4 insertions(+) diff --git a/package.json b/package.json index bdac4ed..0b36ffb 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "@ibm-cloud/watsonx-ai": "^1.7.0", "@langchain/community": "^1.0.0", "@langchain/core": "^1.0.2", + "@langchain/openai": "^1.0.0", "@langchain/textsplitters": "^1.0.0", "@oclif/core": "^4", "@oclif/plugin-help": "^6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2b706ca..ac28cce 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@langchain/core': specifier: ^1.0.2 version: 1.0.2(openai@6.7.0(ws@8.18.3)(zod@4.1.12)) + '@langchain/openai': + specifier: ^1.0.0 + version: 1.0.0(@langchain/core@1.0.2(openai@6.7.0(ws@8.18.3)(zod@4.1.12)))(ws@8.18.3) '@langchain/textsplitters': specifier: ^1.0.0 version: 1.0.0(@langchain/core@1.0.2(openai@6.7.0(ws@8.18.3)(zod@4.1.12))) From 242b1260cc2732311d7d54cff31fb9c0d4c1eedf Mon Sep 17 00:00:00 2001 From: Michael Roudnitski Date: Fri, 31 Oct 2025 19:16:00 -0400 Subject: [PATCH 4/6] Update markdown command to require profiles, better mocking --- src/commands/markdown.ts | 18 ++++-- src/core/providers/fake.ts | 11 ++++ src/lib/profile/factory.ts | 5 ++ src/lib/profile/types.ts | 12 +++- test/commands/markdown.test.ts | 103 ++++++++++++++++++++++----------- test/helpers/profile-setup.ts | 33 +++++++++++ 6 files changed, 141 insertions(+), 41 deletions(-) create mode 100644 src/core/providers/fake.ts create mode 100644 test/helpers/profile-setup.ts diff --git a/src/commands/markdown.ts b/src/commands/markdown.ts index b4037ff..7fafba2 100644 --- a/src/commands/markdown.ts +++ b/src/commands/markdown.ts @@ -1,7 +1,8 @@ import {Args, Command, Flags} from '@oclif/core' -import {createClient} from '../core/providers/watsonx.js' import {MarkdownTranslator} from '../core/translators/markdown.js' +import {createProviderFromProfile} from '../lib/profile/factory.js' +import {loadProfile} from '../lib/profile/storage.js' export default class Markdown extends Command { static args = { @@ -12,16 +13,20 @@ export default class Markdown extends Command { } static description = 'Translate markdown' static examples = [ - '<%= config.bin %> <%= command.id %> --from EN --to ES "Hello"', - '<%= config.bin %> <%= command.id %> --from EN --to ES --stream "Hello"', - 'cat doc.md | <%= config.bin %> <%= command.id %> --from EN --to ES', - 'echo "# Hello" | <%= config.bin %> <%= command.id %> --from EN --to ES', + '<%= config.bin %> <%= command.id %> --profile default-openai --from EN --to ES "Hello"', + '<%= config.bin %> <%= command.id %> --profile default-openai --from EN --to ES --stream "Hello"', + 'cat doc.md | <%= config.bin %> <%= command.id %> --profile default-openai --from EN --to ES', + 'echo "# Hello" | <%= config.bin %> <%= command.id %> --profile default-openai --from EN --to ES', ] static flags = { from: Flags.string({ description: 'Source language', required: true, }), + profile: Flags.string({ + description: 'Profile to use for translation', + required: true, + }), stream: Flags.boolean({ default: false, description: 'Stream the translation output', @@ -48,7 +53,8 @@ export default class Markdown extends Command { input = Buffer.concat(chunks).toString('utf8') } - const llm = createClient() + const profile = loadProfile(flags.profile) + const llm = createProviderFromProfile(profile) const translator = new MarkdownTranslator(llm) if (flags.stream) { diff --git a/src/core/providers/fake.ts b/src/core/providers/fake.ts new file mode 100644 index 0000000..71b4c7a --- /dev/null +++ b/src/core/providers/fake.ts @@ -0,0 +1,11 @@ +import {FakeListChatModel} from '@langchain/core/utils/testing' + +export interface FakeConfig { + responses: string[] +} + +export function createClient(config: FakeConfig): FakeListChatModel { + return new FakeListChatModel({ + responses: config.responses, + }) +} diff --git a/src/lib/profile/factory.ts b/src/lib/profile/factory.ts index a4f47a4..7949888 100644 --- a/src/lib/profile/factory.ts +++ b/src/lib/profile/factory.ts @@ -2,11 +2,16 @@ import type {BaseChatModel} from '@langchain/core/language_models/chat_models' import type {Profile} from './types.js' +import {createClient as createFakeClient} from '../../core/providers/fake.js' import {createClient as createOpenAIClient} from '../../core/providers/openai.js' import {createClient as createWatsonxClient} from '../../core/providers/watsonx.js' export function createProviderFromProfile(profile: Profile): BaseChatModel { switch (profile.provider) { + case 'fake': { + return createFakeClient(profile.config) + } + case 'openai': { return createOpenAIClient(profile.config) } diff --git a/src/lib/profile/types.ts b/src/lib/profile/types.ts index e8bab33..6d69b53 100644 --- a/src/lib/profile/types.ts +++ b/src/lib/profile/types.ts @@ -10,6 +10,10 @@ export interface WatsonxProfileConfig { serviceUrl: string } +export interface FakeProfileConfig { + responses: string[] +} + export interface OpenAIProfile { config: OpenAIProfileConfig name: string @@ -22,4 +26,10 @@ export interface WatsonxProfile { provider: 'watsonx' } -export type Profile = OpenAIProfile | WatsonxProfile +export interface FakeProfile { + config: FakeProfileConfig + name: string + provider: 'fake' +} + +export type Profile = FakeProfile | OpenAIProfile | WatsonxProfile diff --git a/test/commands/markdown.test.ts b/test/commands/markdown.test.ts index aadabbc..295c048 100644 --- a/test/commands/markdown.test.ts +++ b/test/commands/markdown.test.ts @@ -1,44 +1,79 @@ -import {describe, expect, it, jest} from '@jest/globals' +import {afterAll, beforeAll, describe, expect, it} from '@jest/globals' import {runCommand} from '@oclif/test' -jest.unstable_mockModule('../../src/core/providers/watsonx.js', () => ({ - createClient: jest.fn(() => ({ - invoke: jest.fn(async () => ({ - content: 'Hóla', - })), - stream: jest.fn(async function* () { - yield {content: 'Hól'} - yield {content: 'a'} - }), - })), -})) - -describe('command structure', () => { - it('accepts --from and --to flags with input', async () => { - try { - await runCommand(['markdown', '--from', 'EN', '--to', 'ES', 'Hello']) - } catch (error: unknown) { - expect((error as Error).message).not.toMatch(/Unknown flag|Unexpected argument/i) - } +import {setupTestProfile, teardownTestProfile} from '../helpers/profile-setup.js' + +describe('markdown command', () => { + beforeAll(() => { + setupTestProfile() }) - it('accepts --stream flag', async () => { - try { - await runCommand(['markdown', '--from', 'EN', '--to', 'ES', '--stream', 'Hello']) - } catch (error: unknown) { - expect((error as Error).message).not.toMatch(/Unknown flag|Unexpected argument/i) - } + afterAll(() => { + teardownTestProfile() }) -}) -describe('run', () => { - it('writes the translated text to stdout', async () => { - const {stdout} = await runCommand(['markdown', '--from', 'EN', '--to', 'ES', 'Hello']) - expect(stdout).toEqual('Hóla') + describe('basic usage', () => { + it('translates markdown', async () => { + await expect( + runCommand(['markdown', '--profile', 'test-profile', '--from', 'EN', '--to', 'ES', 'Hello']), + ).resolves.not.toThrow() + }) + + it('streams translated markdown', async () => { + await expect( + runCommand(['markdown', '--profile', 'test-profile', '--from', 'EN', '--to', 'ES', '--stream', 'Hello']), + ).resolves.not.toThrow() + }) }) - it('streams the translated text to stdout', async () => { - const {stdout} = await runCommand(['markdown', '--from', 'EN', '--to', 'ES', '--stream', 'Hello']) - expect(stdout).toEqual('Hóla') + describe('output verification', () => { + it('outputs the correct translation', async () => { + setupTestProfile('test-profile', ['Hóla']) + + const {stdout} = await runCommand([ + 'markdown', + '--profile', + 'test-profile', + '--from', + 'EN', + '--to', + 'ES', + 'Hello', + ]) + expect(stdout).toBe('Hóla') + }) + + it('streams the correct translation', async () => { + setupTestProfile('test-profile', ['Hóla']) + + const {stdout} = await runCommand([ + 'markdown', + '--profile', + 'test-profile', + '--from', + 'EN', + '--to', + 'ES', + '--stream', + 'Hello', + ]) + expect(stdout).toBe('Hóla') + }) + + it('respects splitting rules', async () => { + setupTestProfile('test-profile', ['# Page 1\nbonjour', '# Page 2\nmonde']) + + const {stdout} = await runCommand([ + 'markdown', + '--profile', + 'test-profile', + '--from', + 'EN', + '--to', + 'FR', + '"# Page 1\nhello\n# Page 2\nworld"', + ]) + expect(stdout).toBe('# Page 1\nbonjour\n# Page 2\nmonde') + }) }) }) diff --git a/test/helpers/profile-setup.ts b/test/helpers/profile-setup.ts new file mode 100644 index 0000000..b665064 --- /dev/null +++ b/test/helpers/profile-setup.ts @@ -0,0 +1,33 @@ +import {existsSync, mkdirSync, rmSync} from 'node:fs' +import {tmpdir} from 'node:os' +import {join} from 'node:path' + +import {saveProfile} from '../../src/lib/profile/storage.js' + +let originalHomeDir: string | undefined +let testDir: string | undefined + +export function setupTestProfile(profileName = 'test-profile', responses: string[] = ['Hóla']) { + originalHomeDir = process.env.HOME + testDir = join(tmpdir(), `test-markdown-${Date.now()}`) + mkdirSync(testDir, {recursive: true}) + process.env.HOME = testDir + + saveProfile({ + config: { + responses, + }, + name: profileName, + provider: 'fake', + }) +} + +export function teardownTestProfile() { + if (originalHomeDir !== undefined) { + process.env.HOME = originalHomeDir + } + + if (testDir && existsSync(testDir)) { + rmSync(testDir, {force: true, recursive: true}) + } +} From 0dbe6092c7676b4f3d2fdb490a41e60be48b2fba Mon Sep 17 00:00:00 2001 From: Michael Roudnitski Date: Fri, 31 Oct 2025 19:18:19 -0400 Subject: [PATCH 5/6] Use newest granite models in examples --- src/commands/profiles/set.ts | 2 +- test/commands/profiles/storage.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/commands/profiles/set.ts b/src/commands/profiles/set.ts index 2b1b340..57f8d65 100644 --- a/src/commands/profiles/set.ts +++ b/src/commands/profiles/set.ts @@ -14,7 +14,7 @@ export default class ProfilesSet extends Command { static description = 'Create or update a profile' static examples = [ '<%= config.bin %> <%= command.id %> my-openai-profile --provider openai --api-key sk-... --model gpt-4o', - '<%= config.bin %> <%= command.id %> my-watsonx-profile --provider watsonx --api-key ... --project-id ... --service-url https://... --model ibm/granite-3-8b-instruct', + '<%= config.bin %> <%= command.id %> my-watsonx-profile --provider watsonx --api-key ... --project-id ... --service-url https://... --model ibm/granite-4-h-small', ] static flags = { 'api-key': Flags.string({ diff --git a/test/commands/profiles/storage.test.ts b/test/commands/profiles/storage.test.ts index e0f38fa..d2bff9c 100644 --- a/test/commands/profiles/storage.test.ts +++ b/test/commands/profiles/storage.test.ts @@ -74,7 +74,7 @@ describe('profile storage', () => { const profile: Profile = { config: { apiKey: 'test-key', - model: 'ibm/granite-3-8b-instruct', + model: 'ibm/granite-4-h-small', projectId: 'test-project', serviceUrl: 'https://test.example.com', }, @@ -162,7 +162,7 @@ describe('profile storage', () => { const profile2: Profile = { config: { apiKey: 'key-2', - model: 'ibm/granite-3-8b-instruct', + model: 'ibm/granite-4-h-small', projectId: 'test-project', serviceUrl: 'https://test.example.com', }, From 8d23cbe5e78d64d7cbc71811dd328e944283f867 Mon Sep 17 00:00:00 2001 From: Michael Roudnitski Date: Mon, 3 Nov 2025 14:16:57 -0500 Subject: [PATCH 6/6] Reject path traversal --- src/lib/profile/storage.ts | 5 ++++ test/commands/profiles/storage.test.ts | 34 ++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/src/lib/profile/storage.ts b/src/lib/profile/storage.ts index e946b92..e653b69 100644 --- a/src/lib/profile/storage.ts +++ b/src/lib/profile/storage.ts @@ -17,6 +17,11 @@ export function ensureProfilesDir(): void { } export function getProfilePath(name: string): string { + // Reject path traversal attempts, empty names, or names with path separators + if (!name || name.includes('/') || name.includes('\\') || name.includes('..')) { + throw new Error(`Invalid profile name: "${name}". Profile names cannot contain path separators or parent directory references.`) + } + return join(getProfilesDir(), `${name}.json`) } diff --git a/test/commands/profiles/storage.test.ts b/test/commands/profiles/storage.test.ts index d2bff9c..fb9c621 100644 --- a/test/commands/profiles/storage.test.ts +++ b/test/commands/profiles/storage.test.ts @@ -222,4 +222,38 @@ describe('profile storage', () => { expect(profileExists('nonexistent')).toBe(false) }) }) + + describe('getProfilePath', () => { + it('accepts valid profile names', () => { + expect(() => getProfilePath('valid-profile')).not.toThrow() + expect(() => getProfilePath('profile_123')).not.toThrow() + expect(() => getProfilePath('MyProfile')).not.toThrow() + }) + + it('rejects empty profile names', () => { + expect(() => getProfilePath('')).toThrow('Invalid profile name') + }) + + it('rejects profile names with forward slashes', () => { + expect(() => getProfilePath('foo/bar')).toThrow('Invalid profile name') + expect(() => getProfilePath('/etc/passwd')).toThrow('Invalid profile name') + }) + + it('rejects profile names with backslashes', () => { + expect(() => getProfilePath(String.raw`foo\bar`)).toThrow('Invalid profile name') + expect(() => getProfilePath(String.raw`C:\Windows\System32`)).toThrow('Invalid profile name') + }) + + it('rejects profile names with parent directory references', () => { + expect(() => getProfilePath('..')).toThrow('Invalid profile name') + expect(() => getProfilePath('../../etc/passwd')).toThrow('Invalid profile name') + expect(() => getProfilePath('foo..bar')).toThrow('Invalid profile name') + }) + + it('rejects path traversal attempts', () => { + expect(() => getProfilePath('../../../../some-place-you-should-not-have-access-to')).toThrow( + 'Invalid profile name', + ) + }) + }) })