diff --git a/src/client/stream.ts b/src/client/stream.ts index a2da167..7502750 100644 --- a/src/client/stream.ts +++ b/src/client/stream.ts @@ -10,6 +10,41 @@ export async function* parseSSE(response: Response): AsyncGenerator = {}; + + const processLine = (rawLine: string): ServerSentEvent | undefined => { + const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine; + + if (line === '') { + const completed = event.data !== undefined + ? { data: event.data, event: event.event, id: event.id } + : undefined; + event = {}; + return completed; + } + + if (line.startsWith(':')) return undefined; + + const colonIndex = line.indexOf(':'); + if (colonIndex === -1) return undefined; + + const field = line.slice(0, colonIndex); + const value = line.slice(colonIndex + 1).trimStart(); + + switch (field) { + case 'data': + event.data = event.data !== undefined ? `${event.data}\n${value}` : value; + break; + case 'event': + event.event = value; + break; + case 'id': + event.id = value; + break; + } + + return undefined; + }; try { while (true) { @@ -21,45 +56,25 @@ export async function* parseSSE(response: Response): AsyncGenerator = {}; - for (const line of lines) { - if (line === '') { - if (event.data !== undefined) { - yield { data: event.data, event: event.event, id: event.id }; - } - event = {}; - continue; + const completed = processLine(line); + if (completed) { + yield completed; } + } + } - if (line.startsWith(':')) continue; // comment - - const colonIndex = line.indexOf(':'); - if (colonIndex === -1) continue; - - const field = line.slice(0, colonIndex); - const value = line.slice(colonIndex + 1).trimStart(); + buffer += decoder.decode(); - switch (field) { - case 'data': - event.data = event.data !== undefined ? `${event.data}\n${value}` : value; - break; - case 'event': - event.event = value; - break; - case 'id': - event.id = value; - break; - } + if (buffer.length > 0) { + const completed = processLine(buffer); + if (completed) { + yield completed; } } - // Flush remaining - if (buffer.trim() && buffer.includes('data:')) { - const colonIndex = buffer.indexOf(':'); - if (colonIndex !== -1) { - yield { data: buffer.slice(colonIndex + 1).trimStart() }; - } + if (event.data !== undefined) { + yield { data: event.data, event: event.event, id: event.id }; } } finally { reader.releaseLock(); diff --git a/src/commands/music/generate.ts b/src/commands/music/generate.ts index 3a4bfe3..1f22922 100644 --- a/src/commands/music/generate.ts +++ b/src/commands/music/generate.ts @@ -3,7 +3,7 @@ import { CLIError } from '../../errors/base'; import { ExitCode } from '../../errors/codes'; import { request, requestJson } from '../../client/http'; import { musicEndpoint } from '../../client/endpoints'; -import { formatOutput, detectOutputFormat, dryRun } from '../../output/formatter'; +import { detectOutputFormat, dryRun } from '../../output/formatter'; import { saveAudioOutput } from '../../output/audio'; import { readTextFromPathOrStdin } from '../../utils/fs'; import { MUSIC_FORMATS, formatList, validateAudioFormat } from '../../utils/audio-formats'; diff --git a/src/config/loader.ts b/src/config/loader.ts index a29790d..5d40ec9 100644 --- a/src/config/loader.ts +++ b/src/config/loader.ts @@ -2,6 +2,8 @@ import { readFileSync, writeFileSync, renameSync, existsSync } from 'fs'; import { parseConfigFile, REGIONS, type Config, type ConfigFile, type Region } from './schema'; import { ensureConfigDir, getConfigPath } from './paths'; import { detectOutputFormat, type OutputFormat } from '../output/formatter'; +import { CLIError } from '../errors/base'; +import { ExitCode } from '../errors/codes'; import type { GlobalFlags } from '../types/flags'; export function readConfigFile(): ConfigFile { @@ -33,6 +35,13 @@ export function loadConfig(flags: GlobalFlags): Config { const fileApiKey = file.api_key; const explicitRegion = (flags.region as string) || process.env.MINIMAX_REGION || undefined; + if (explicitRegion && !(explicitRegion in REGIONS)) { + throw new CLIError( + `Invalid region "${explicitRegion}". Valid values: ${Object.keys(REGIONS).join(', ')}`, + ExitCode.USAGE, + ); + } + const cachedRegion = file.region; const region = (explicitRegion || cachedRegion || 'global') as Region; diff --git a/src/registry.ts b/src/registry.ts index 654f263..cf6e67d 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -22,6 +22,9 @@ import quotaShow from './commands/quota/show'; import configShow from './commands/config/show'; import configSet from './commands/config/set'; import configExportSchema from './commands/config/export-schema'; +import fileUpload from './commands/file/upload'; +import fileList from './commands/file/list'; +import fileDelete from './commands/file/delete'; import update from './commands/update'; import help from './commands/help'; @@ -197,6 +200,7 @@ ${b('Resources:')} ${a('vision')} ${d('Image understanding (describe)')} ${a('quota')} ${d('Usage quotas (show)')} ${a('config')} ${d('CLI configuration (show, set, export-schema)')} + ${a('file')} ${d('File storage (upload, list, delete)')} ${a('update')} ${d('Update mmx to a newer version')} ${b('Global Flags:')} @@ -284,6 +288,9 @@ export const registry = new CommandRegistry({ 'config show': configShow, 'config set': configSet, 'config export-schema': configExportSchema, + 'file upload': fileUpload, + 'file list': fileList, + 'file delete': fileDelete, 'update': update, 'help': help, }); diff --git a/test/client/stream.test.ts b/test/client/stream.test.ts index ba94e9d..58b491b 100644 --- a/test/client/stream.test.ts +++ b/test/client/stream.test.ts @@ -241,6 +241,45 @@ describe('parseSSE', () => { expect(events2[1].data).toBe('[DONE]'); }); + it('preserves event fields split across network chunks', async () => { + const body = new ReadableStream({ + start(controller) { + const encoder = new TextEncoder(); + controller.enqueue(encoder.encode('id: 7\nevent: mes')); + controller.enqueue(encoder.encode('sage\ndata: hel')); + controller.enqueue(encoder.encode('lo\n\n')); + controller.close(); + }, + }); + + const events = await collectEvents(new Response(body)); + + expect(events).toEqual([{ id: '7', event: 'message', data: 'hello' }]); + }); + + it('preserves multi-line data split across network chunks', async () => { + const body = new ReadableStream({ + start(controller) { + const encoder = new TextEncoder(); + controller.enqueue(encoder.encode('data: first\n')); + controller.enqueue(encoder.encode('data: sec')); + controller.enqueue(encoder.encode('ond\n\n')); + controller.close(); + }, + }); + + const events = await collectEvents(new Response(body)); + + expect(events).toEqual([{ data: 'first\nsecond' }]); + }); + + it('handles CRLF line endings', async () => { + const body = 'event: message\r\ndata: hello\r\n\r\n'; + const events = await collectEvents(new Response(body)); + + expect(events).toEqual([{ event: 'message', data: 'hello' }]); + }); + // ------------------------------------------------------------------------- // Helpers // ------------------------------------------------------------------------- diff --git a/test/commands/aliases.test.ts b/test/commands/aliases.test.ts index 12d349e..c0fdf9e 100644 --- a/test/commands/aliases.test.ts +++ b/test/commands/aliases.test.ts @@ -13,6 +13,12 @@ describe('command aliases', () => { const synthesize = registry.resolve(['speech', 'synthesize']); expect(generate.command).toBe(synthesize.command); }); + + it('resolves file storage commands', () => { + expect(registry.resolve(['file', 'upload']).command.name).toBe('file upload'); + expect(registry.resolve(['file', 'list']).command.name).toBe('file list'); + expect(registry.resolve(['file', 'delete']).command.name).toBe('file delete'); + }); }); describe('text chat --prompt alias', () => { diff --git a/test/commands/file/delete.test.ts b/test/commands/file/delete.test.ts new file mode 100644 index 0000000..ac1360e --- /dev/null +++ b/test/commands/file/delete.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect, afterEach } from 'bun:test'; +import { default as deleteCommand } from '../../../src/commands/file/delete'; +import { createMockServer, jsonResponse, type MockServer } from '../../helpers/mock-server'; +import type { Config } from '../../../src/config/schema'; +import type { GlobalFlags } from '../../../src/types/flags'; + +function makeConfig(overrides: Partial = {}): Config { + return { + apiKey: 'test-key', + region: 'global', + baseUrl: 'https://api.mmx.io', + output: 'text', + timeout: 10, + verbose: false, + quiet: false, + noColor: true, + yes: false, + dryRun: false, + nonInteractive: true, + async: false, + ...overrides, + }; +} + +const baseFlags: GlobalFlags = { + quiet: false, + verbose: false, + noColor: true, + yes: false, + dryRun: false, + help: false, + nonInteractive: true, + async: false, +}; + +async function captureStdout(fn: () => Promise): Promise { + const originalWrite = process.stdout.write; + let output = ''; + process.stdout.write = ((chunk: string | Uint8Array) => { + output += typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf-8'); + return true; + }) as typeof process.stdout.write; + + try { + await fn(); + return output; + } finally { + process.stdout.write = originalWrite; + } +} + +describe('file delete command', () => { + let server: MockServer; + + afterEach(() => { + server?.close(); + }); + + it('has correct name', () => { + expect(deleteCommand.name).toBe('file delete'); + }); + + it('requires file-id argument', async () => { + await expect( + deleteCommand.execute(makeConfig({ dryRun: true }), baseFlags), + ).rejects.toThrow('Missing required argument: --file-id'); + }); + + it('handles dry run', async () => { + const output = await captureStdout(async () => { + await deleteCommand.execute(makeConfig({ dryRun: true, output: 'json' }), { + ...baseFlags, + dryRun: true, + fileId: 'file-123', + }); + }); + + const parsed = JSON.parse(output); + expect(parsed.request.delete_file).toBe('file-123'); + }); + + it('sends DELETE request and prints result', async () => { + let method = ''; + let fileId = ''; + server = createMockServer({ + routes: { + '/v1/files': (req) => { + method = req.method; + fileId = new URL(req.url).searchParams.get('file_id') ?? ''; + return jsonResponse({ + base_resp: { status_code: 0, status_msg: '' }, + id: fileId, + object: 'file', + deleted: true, + }); + }, + }, + }); + + const output = await captureStdout(async () => { + await deleteCommand.execute(makeConfig({ baseUrl: server.url, output: 'json' }), { + ...baseFlags, + fileId: 'file-123', + }); + }); + + const parsed = JSON.parse(output); + expect(method).toBe('DELETE'); + expect(fileId).toBe('file-123'); + expect(parsed).toEqual({ id: 'file-123', deleted: true }); + }); + + it('prints compact status in quiet mode', async () => { + server = createMockServer({ + routes: { + '/v1/files': () => jsonResponse({ + base_resp: { status_code: 0, status_msg: '' }, + id: 'file-123', + object: 'file', + deleted: true, + }), + }, + }); + + const output = await captureStdout(async () => { + await deleteCommand.execute(makeConfig({ baseUrl: server.url, quiet: true }), { + ...baseFlags, + quiet: true, + fileId: 'file-123', + }); + }); + + expect(output).toBe('deleted\n'); + }); +}); diff --git a/test/commands/file/list.test.ts b/test/commands/file/list.test.ts new file mode 100644 index 0000000..c3dc6d3 --- /dev/null +++ b/test/commands/file/list.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect, afterEach } from 'bun:test'; +import { default as listCommand } from '../../../src/commands/file/list'; +import { createMockServer, jsonResponse, type MockServer } from '../../helpers/mock-server'; +import type { Config } from '../../../src/config/schema'; +import type { GlobalFlags } from '../../../src/types/flags'; + +function makeConfig(overrides: Partial = {}): Config { + return { + apiKey: 'test-key', + region: 'global', + baseUrl: 'https://api.mmx.io', + output: 'text', + timeout: 10, + verbose: false, + quiet: false, + noColor: true, + yes: false, + dryRun: false, + nonInteractive: true, + async: false, + ...overrides, + }; +} + +const baseFlags: GlobalFlags = { + quiet: false, + verbose: false, + noColor: true, + yes: false, + dryRun: false, + help: false, + nonInteractive: true, + async: false, +}; + +async function captureStdout(fn: () => Promise): Promise { + const originalWrite = process.stdout.write; + let output = ''; + process.stdout.write = ((chunk: string | Uint8Array) => { + output += typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf-8'); + return true; + }) as typeof process.stdout.write; + + try { + await fn(); + return output; + } finally { + process.stdout.write = originalWrite; + } +} + +describe('file list command', () => { + let server: MockServer; + + afterEach(() => { + server?.close(); + }); + + it('has correct name', () => { + expect(listCommand.name).toBe('file list'); + }); + + it('handles dry run', async () => { + const output = await captureStdout(async () => { + await listCommand.execute(makeConfig({ dryRun: true }), baseFlags); + }); + + expect(output).toBe('Would list uploaded files.\n'); + }); + + it('shows empty state when no files are returned', async () => { + server = createMockServer({ + routes: { + '/v1/files': () => jsonResponse({ base_resp: { status_code: 0, status_msg: '' }, data: [] }), + }, + }); + + const output = await captureStdout(async () => { + await listCommand.execute(makeConfig({ baseUrl: server.url }), baseFlags); + }); + + expect(output).toBe('No files found.\n'); + }); + + it('prints JSON output for file list responses', async () => { + server = createMockServer({ + routes: { + '/v1/files': () => jsonResponse({ + base_resp: { status_code: 0, status_msg: '' }, + data: [{ + file_id: 'file-123', + bytes: 2048, + created_at: 1700000000, + filename: 'doc.pdf', + purpose: 'retrieval', + }], + }), + }, + }); + + const output = await captureStdout(async () => { + await listCommand.execute(makeConfig({ baseUrl: server.url, output: 'json' }), baseFlags); + }); + + const parsed = JSON.parse(output); + expect(parsed.data[0].file_id).toBe('file-123'); + expect(parsed.data[0].filename).toBe('doc.pdf'); + }); +}); diff --git a/test/config/loader.test.ts b/test/config/loader.test.ts new file mode 100644 index 0000000..38a160e --- /dev/null +++ b/test/config/loader.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { mkdirSync, rmSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { loadConfig } from '../../src/config/loader'; +import { CLIError } from '../../src/errors/base'; +import type { GlobalFlags } from '../../src/types/flags'; + +const baseFlags: GlobalFlags = { + quiet: false, + verbose: false, + noColor: true, + yes: false, + dryRun: false, + help: false, + nonInteractive: true, + async: false, +}; + +describe('loadConfig', () => { + const testDir = join(tmpdir(), `mmx-config-test-${Date.now()}`); + const originalHome = process.env.HOME; + const originalRegion = process.env.MINIMAX_REGION; + + beforeEach(() => { + mkdirSync(join(testDir, '.mmx'), { recursive: true }); + process.env.HOME = testDir; + delete process.env.MINIMAX_REGION; + }); + + afterEach(() => { + process.env.HOME = originalHome; + if (originalRegion === undefined) delete process.env.MINIMAX_REGION; + else process.env.MINIMAX_REGION = originalRegion; + rmSync(testDir, { recursive: true, force: true }); + }); + + it('rejects invalid --region values', () => { + expect(() => loadConfig({ ...baseFlags, region: 'mars' })).toThrow(CLIError); + expect(() => loadConfig({ ...baseFlags, region: 'mars' })).toThrow( + 'Invalid region "mars". Valid values: global, cn', + ); + }); + + it('rejects invalid MINIMAX_REGION values', () => { + process.env.MINIMAX_REGION = 'moon'; + + expect(() => loadConfig(baseFlags)).toThrow( + 'Invalid region "moon". Valid values: global, cn', + ); + }); + + it('accepts valid explicit region values', () => { + const config = loadConfig({ ...baseFlags, region: 'cn' }); + + expect(config.region).toBe('cn'); + expect(config.baseUrl).toBe('https://api.minimaxi.com'); + }); +});