From 3a1e6137a8e2f09f754c20aed41c6444ebc740ce Mon Sep 17 00:00:00 2001 From: NianJiuZst <3235467914@qq.com> Date: Mon, 11 May 2026 11:29:56 +0800 Subject: [PATCH 1/4] fix: register file commands --- src/registry.ts | 7 ++ test/commands/aliases.test.ts | 6 ++ test/commands/file/delete.test.ts | 135 ++++++++++++++++++++++++++++++ test/commands/file/list.test.ts | 109 ++++++++++++++++++++++++ 4 files changed, 257 insertions(+) create mode 100644 test/commands/file/delete.test.ts create mode 100644 test/commands/file/list.test.ts 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/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'); + }); +}); From 790f897528e10af43a671f51eaad0c854ad9852e Mon Sep 17 00:00:00 2001 From: NianJiuZst <3235467914@qq.com> Date: Mon, 11 May 2026 11:30:07 +0800 Subject: [PATCH 2/4] fix: harden SSE stream parsing --- src/client/stream.ts | 79 +++++++++++++++++++++++--------------- test/client/stream.test.ts | 39 +++++++++++++++++++ 2 files changed, 86 insertions(+), 32 deletions(-) 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/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 // ------------------------------------------------------------------------- From e83978bbac1c8861873a6d6ec6cacdd86f24c327 Mon Sep 17 00:00:00 2001 From: NianJiuZst <3235467914@qq.com> Date: Mon, 11 May 2026 11:30:21 +0800 Subject: [PATCH 3/4] fix: validate configured region --- src/config/loader.ts | 9 ++++++ test/config/loader.test.ts | 59 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 test/config/loader.test.ts 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/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'); + }); +}); From 8c602dbfb49532b8f66e3abd8162e9c2afec90dd Mon Sep 17 00:00:00 2001 From: yuanhe Date: Mon, 11 May 2026 12:17:21 +0800 Subject: [PATCH 4/4] fix: align file endpoints with actual API - file list: GET /v1/files/list (not /v1/files) - file delete: POST /v1/files/delete with JSON body (not DELETE /v1/files?file_id=) - file_id is numeric, response has no 'deleted' field - response uses 'files' array, not 'data' --- src/client/endpoints.ts | 6 +++--- src/commands/file/delete.ts | 11 ++++++----- src/commands/file/list.ts | 4 ++-- src/types/api.ts | 6 ++---- test/commands/file/delete.test.ts | 26 +++++++++++--------------- test/commands/file/list.test.ts | 10 +++++----- 6 files changed, 29 insertions(+), 34 deletions(-) diff --git a/src/client/endpoints.ts b/src/client/endpoints.ts index 617c703..b8337a0 100644 --- a/src/client/endpoints.ts +++ b/src/client/endpoints.ts @@ -49,9 +49,9 @@ export function fileUploadEndpoint(baseUrl: string): string { } export function fileListEndpoint(baseUrl: string): string { - return `${baseUrl}/v1/files`; + return `${baseUrl}/v1/files/list`; } -export function fileDeleteEndpoint(baseUrl: string, fileId: string): string { - return `${baseUrl}/v1/files?file_id=${encodeURIComponent(fileId)}`; +export function fileDeleteEndpoint(baseUrl: string): string { + return `${baseUrl}/v1/files/delete`; } diff --git a/src/commands/file/delete.ts b/src/commands/file/delete.ts index f2e47a6..8bb2c16 100644 --- a/src/commands/file/delete.ts +++ b/src/commands/file/delete.ts @@ -40,20 +40,21 @@ export default defineCommand({ return; } - const url = fileDeleteEndpoint(config.baseUrl, fileId); + const url = fileDeleteEndpoint(config.baseUrl); const response = await requestJson(config, { url, - method: 'DELETE', + method: 'POST', + body: { file_id: Number(fileId) }, }); if (config.quiet) { - process.stdout.write(response.deleted ? 'deleted\n' : 'failed\n'); + process.stdout.write('deleted\n'); return; } process.stdout.write(formatOutput({ - id: response.id, - deleted: response.deleted, + file_id: response.file_id, + deleted: true, }, format) + '\n'); }, }); diff --git a/src/commands/file/list.ts b/src/commands/file/list.ts index 4c1e9b0..e158b34 100644 --- a/src/commands/file/list.ts +++ b/src/commands/file/list.ts @@ -30,12 +30,12 @@ export default defineCommand({ return; } - if (!response.data || response.data.length === 0) { + if (!response.files || response.files.length === 0) { process.stdout.write('No files found.\n'); return; } - const tableData = response.data.map((f) => ({ + const tableData = response.files.map((f) => ({ ID: f.file_id, FILENAME: f.filename, PURPOSE: f.purpose, diff --git a/src/types/api.ts b/src/types/api.ts index bbf50de..ac0e998 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -274,7 +274,7 @@ export interface FileUploadResponse { export interface FileListResponse { base_resp: BaseResp; - data: Array<{ + files: Array<{ file_id: string; bytes: number; created_at: number; @@ -285,9 +285,7 @@ export interface FileListResponse { export interface FileDeleteResponse { base_resp: BaseResp; - id: string; - object: string; - deleted: boolean; + file_id: number; } export interface FileRetrieveResponse { diff --git a/test/commands/file/delete.test.ts b/test/commands/file/delete.test.ts index ac1360e..938c3a7 100644 --- a/test/commands/file/delete.test.ts +++ b/test/commands/file/delete.test.ts @@ -79,19 +79,17 @@ describe('file delete command', () => { expect(parsed.request.delete_file).toBe('file-123'); }); - it('sends DELETE request and prints result', async () => { + it('sends POST request to delete endpoint', async () => { let method = ''; - let fileId = ''; + let body: Record = {}; server = createMockServer({ routes: { - '/v1/files': (req) => { + '/v1/files/delete': async (req) => { method = req.method; - fileId = new URL(req.url).searchParams.get('file_id') ?? ''; + body = await req.json() as Record; return jsonResponse({ base_resp: { status_code: 0, status_msg: '' }, - id: fileId, - object: 'file', - deleted: true, + file_id: 123, }); }, }, @@ -100,24 +98,22 @@ describe('file delete command', () => { const output = await captureStdout(async () => { await deleteCommand.execute(makeConfig({ baseUrl: server.url, output: 'json' }), { ...baseFlags, - fileId: 'file-123', + fileId: '123', }); }); const parsed = JSON.parse(output); - expect(method).toBe('DELETE'); - expect(fileId).toBe('file-123'); - expect(parsed).toEqual({ id: 'file-123', deleted: true }); + expect(method).toBe('POST'); + expect(body.file_id).toBe(123); + expect(parsed).toEqual({ file_id: 123, deleted: true }); }); it('prints compact status in quiet mode', async () => { server = createMockServer({ routes: { - '/v1/files': () => jsonResponse({ + '/v1/files/delete': () => jsonResponse({ base_resp: { status_code: 0, status_msg: '' }, - id: 'file-123', - object: 'file', - deleted: true, + file_id: 123, }), }, }); diff --git a/test/commands/file/list.test.ts b/test/commands/file/list.test.ts index c3dc6d3..2ae796b 100644 --- a/test/commands/file/list.test.ts +++ b/test/commands/file/list.test.ts @@ -71,7 +71,7 @@ describe('file list command', () => { it('shows empty state when no files are returned', async () => { server = createMockServer({ routes: { - '/v1/files': () => jsonResponse({ base_resp: { status_code: 0, status_msg: '' }, data: [] }), + '/v1/files/list': () => jsonResponse({ base_resp: { status_code: 0, status_msg: '' }, files: [] }), }, }); @@ -85,9 +85,9 @@ describe('file list command', () => { it('prints JSON output for file list responses', async () => { server = createMockServer({ routes: { - '/v1/files': () => jsonResponse({ + '/v1/files/list': () => jsonResponse({ base_resp: { status_code: 0, status_msg: '' }, - data: [{ + files: [{ file_id: 'file-123', bytes: 2048, created_at: 1700000000, @@ -103,7 +103,7 @@ describe('file list command', () => { }); const parsed = JSON.parse(output); - expect(parsed.data[0].file_id).toBe('file-123'); - expect(parsed.data[0].filename).toBe('doc.pdf'); + expect(parsed.files[0].file_id).toBe('file-123'); + expect(parsed.files[0].filename).toBe('doc.pdf'); }); });