Compare commits
4 Commits
e06dc8ecba
...
bcb31da773
| Author | SHA1 | Date |
|---|---|---|
|
|
bcb31da773 | |
|
|
3bc35df63c | |
|
|
281cbf845d | |
|
|
f1b1741db8 |
|
|
@ -71,7 +71,7 @@ function commandMatchesSchema(command: Command, schema: CommandSchema): boolean
|
|||
return false;
|
||||
}
|
||||
|
||||
const requiredOptions = schema.options.filter(o => o.required);
|
||||
const requiredOptions = Object.values(schema.options).filter(o => o.required);
|
||||
for (const opt of requiredOptions) {
|
||||
const hasOption = opt.name in command.options || (opt.short && opt.short in command.options);
|
||||
if (!hasOption) {
|
||||
|
|
@ -131,7 +131,7 @@ function handleGeneratorResult<T>(
|
|||
ctx.state = 'invoking';
|
||||
return invokeChildRule(host, yielded.rule, yielded.command, ctx as RuleContext<unknown>);
|
||||
} else {
|
||||
ctx.schema = parseYieldedSchema({ name: '', params: [], options: [], flags: [] });
|
||||
ctx.schema = parseYieldedSchema({ name: '', params: [], options: {}, flags: {} });
|
||||
ctx.state = 'yielded';
|
||||
}
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -20,6 +20,9 @@ export { createRule, dispatchCommand } from './core/rule';
|
|||
export type { Command, CommandSchema, CommandParamSchema, CommandOptionSchema, CommandFlagSchema } from './utils/command';
|
||||
export { parseCommand, parseCommandSchema, validateCommand, parseCommandWithSchema, applyCommandSchema } from './utils/command';
|
||||
|
||||
export type { CommandRunner, CommandRunnerHandler, CommandRegistry, CommandRunnerContext } from './utils/command';
|
||||
export { createCommandRegistry, registerCommand, unregisterCommand, hasCommand, getCommand, runCommand, createCommandRunnerContext } from './utils/command';
|
||||
|
||||
export type { Entity, EntityAccessor } from './utils/entity';
|
||||
export { createEntityCollection } from './utils/entity';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +0,0 @@
|
|||
export { parseCommand } from './command/command-parse.js';
|
||||
export { parseCommandSchema } from './command/schema-parse.js';
|
||||
export { validateCommand, parseCommandWithSchema, applyCommandSchema } from './command/command-validate.js';
|
||||
export type {
|
||||
Command,
|
||||
CommandParamSchema,
|
||||
CommandOptionSchema,
|
||||
CommandFlagSchema,
|
||||
CommandSchema,
|
||||
} from './command/types.js';
|
||||
|
|
@ -19,7 +19,7 @@ function validateCommandCore(command: Command, schema: CommandSchema): string[]
|
|||
errors.push(`参数过多:最多 ${schema.params.length} 个参数,实际 ${command.params.length} 个`);
|
||||
}
|
||||
|
||||
const requiredOptions = schema.options.filter(o => o.required);
|
||||
const requiredOptions = Object.values(schema.options).filter(o => o.required);
|
||||
for (const opt of requiredOptions) {
|
||||
const hasOption = opt.name in command.options || (opt.short && opt.short in command.options);
|
||||
if (!hasOption) {
|
||||
|
|
@ -58,7 +58,7 @@ export function applyCommandSchema(
|
|||
|
||||
const parsedOptions: Record<string, unknown> = { ...command.options };
|
||||
for (const [key, value] of Object.entries(command.options)) {
|
||||
const optSchema = schema.options.find(o => o.name === key || o.short === key);
|
||||
const optSchema = schema.options[key] ?? (key.length === 1 ? Object.values(schema.options).find(o => o.short === key) : undefined);
|
||||
if (optSchema?.schema && typeof value === 'string') {
|
||||
try {
|
||||
parsedOptions[key] = optSchema.schema.parse(value);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,141 @@
|
|||
import type { Command } from './types.js';
|
||||
import type { CommandRunner, CommandRunnerContext, PromptEvent } from './command-runner.js';
|
||||
import { parseCommand } from './command-parse.js';
|
||||
import { applyCommandSchema } from './command-apply.js';
|
||||
import { parseCommandSchema } from './schema-parse.js';
|
||||
|
||||
export type CommandRegistry<TContext> = Map<string, CommandRunner<TContext, unknown>>;
|
||||
|
||||
export function createCommandRegistry<TContext>(): CommandRegistry<TContext> {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
export function registerCommand<TContext, TResult>(
|
||||
registry: CommandRegistry<TContext>,
|
||||
runner: CommandRunner<TContext, TResult>
|
||||
): void {
|
||||
registry.set(runner.schema.name, runner as CommandRunner<TContext, unknown>);
|
||||
}
|
||||
|
||||
export function unregisterCommand<TContext>(
|
||||
registry: CommandRegistry<TContext>,
|
||||
name: string
|
||||
): void {
|
||||
registry.delete(name);
|
||||
}
|
||||
|
||||
export function hasCommand<TContext>(
|
||||
registry: CommandRegistry<TContext>,
|
||||
name: string
|
||||
): boolean {
|
||||
return registry.has(name);
|
||||
}
|
||||
|
||||
export function getCommand<TContext>(
|
||||
registry: CommandRegistry<TContext>,
|
||||
name: string
|
||||
): CommandRunner<TContext, unknown> | undefined {
|
||||
return registry.get(name);
|
||||
}
|
||||
|
||||
type Listener = (e: PromptEvent) => void;
|
||||
|
||||
export type CommandRunnerContextExport<TContext> = CommandRunnerContext<TContext> & {
|
||||
registry: CommandRegistry<TContext>;
|
||||
};
|
||||
|
||||
export function createCommandRunnerContext<TContext>(
|
||||
registry: CommandRegistry<TContext>,
|
||||
context: TContext
|
||||
): CommandRunnerContextExport<TContext> {
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
const on = (_event: 'prompt', listener: Listener) => {
|
||||
listeners.add(listener);
|
||||
};
|
||||
|
||||
const off = (_event: 'prompt', listener: Listener) => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
|
||||
const prompt = (schema: Parameters<CommandRunnerContext<TContext>['prompt']>[0]): Promise<Command> => {
|
||||
const resolvedSchema = typeof schema === 'string' ? parseCommandSchema(schema) : schema;
|
||||
return new Promise((resolve, reject) => {
|
||||
const event: PromptEvent = { schema: resolvedSchema, resolve, reject };
|
||||
for (const listener of listeners) {
|
||||
listener(event);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const runnerCtx: CommandRunnerContextExport<TContext> = {
|
||||
registry,
|
||||
context,
|
||||
run: (input: string) => runCommandWithContext(registry, runnerCtx, input),
|
||||
runParsed: (command: Command) => runCommandParsedWithContext(registry, runnerCtx, command),
|
||||
prompt,
|
||||
on,
|
||||
off,
|
||||
};
|
||||
|
||||
return runnerCtx;
|
||||
}
|
||||
|
||||
async function executeWithRunnerContext<TContext>(
|
||||
runnerCtx: CommandRunnerContextExport<TContext>,
|
||||
runner: CommandRunner<TContext, unknown>,
|
||||
command: Command
|
||||
): Promise<{ success: true; result: unknown } | { success: false; error: string }> {
|
||||
try {
|
||||
const result = await runner.run.call(runnerCtx, command);
|
||||
return { success: true, result };
|
||||
} catch (e) {
|
||||
const error = e as Error;
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function runCommand<TContext>(
|
||||
registry: CommandRegistry<TContext>,
|
||||
context: TContext,
|
||||
input: string
|
||||
): Promise<{ success: true; result: unknown } | { success: false; error: string }> {
|
||||
const runnerCtx = createCommandRunnerContext(registry, context);
|
||||
return await runCommandWithContext(registry, runnerCtx, input);
|
||||
}
|
||||
|
||||
async function runCommandWithContext<TContext>(
|
||||
registry: CommandRegistry<TContext>,
|
||||
runnerCtx: CommandRunnerContextExport<TContext>,
|
||||
input: string
|
||||
): Promise<{ success: true; result: unknown } | { success: false; error: string }> {
|
||||
const command = parseCommand(input);
|
||||
return await runCommandParsedWithContext(registry, runnerCtx, command);
|
||||
}
|
||||
|
||||
export async function runCommandParsed<TContext>(
|
||||
registry: CommandRegistry<TContext>,
|
||||
context: TContext,
|
||||
command: Command
|
||||
): Promise<{ success: true; result: unknown } | { success: false; error: string }> {
|
||||
const runnerCtx = createCommandRunnerContext(registry, context);
|
||||
return await runCommandParsedWithContext(registry, runnerCtx, command);
|
||||
}
|
||||
|
||||
async function runCommandParsedWithContext<TContext>(
|
||||
registry: CommandRegistry<TContext>,
|
||||
runnerCtx: CommandRunnerContextExport<TContext>,
|
||||
command: Command
|
||||
): Promise<{ success: true; result: unknown } | { success: false; error: string }> {
|
||||
const runner = registry.get(command.name);
|
||||
if (!runner) {
|
||||
return { success: false, error: `Unknown command: ${command.name}` };
|
||||
}
|
||||
|
||||
const validationResult = applyCommandSchema(command, runner.schema);
|
||||
if (!validationResult.valid) {
|
||||
return { success: false, error: validationResult.errors.join('; ') };
|
||||
}
|
||||
|
||||
return await executeWithRunnerContext(runnerCtx, runner, validationResult.command);
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import type { Command, CommandSchema } from './types.js';
|
||||
|
||||
export type PromptEvent = {
|
||||
schema: CommandSchema;
|
||||
resolve: (command: Command) => void;
|
||||
reject: (error: Error) => void;
|
||||
};
|
||||
|
||||
export type CommandRunnerEvents = {
|
||||
prompt: PromptEvent;
|
||||
};
|
||||
|
||||
export type CommandRunnerContext<TContext> = {
|
||||
context: TContext;
|
||||
run: (input: string) => Promise<{ success: true; result: unknown } | { success: false; error: string }>;
|
||||
runParsed: (command: Command) => Promise<{ success: true; result: unknown } | { success: false; error: string }>;
|
||||
prompt: (schema: CommandSchema | string) => Promise<Command>;
|
||||
on: <T extends keyof CommandRunnerEvents>(event: T, listener: (e: CommandRunnerEvents[T]) => void) => void;
|
||||
off: <T extends keyof CommandRunnerEvents>(event: T, listener: (e: CommandRunnerEvents[T]) => void) => void;
|
||||
};
|
||||
|
||||
export type CommandRunnerHandler<TContext, TResult> = (
|
||||
this: CommandRunnerContext<TContext>,
|
||||
command: Command
|
||||
) => Promise<TResult>;
|
||||
|
||||
export type CommandRunner<TContext, TResult = unknown> = {
|
||||
schema: CommandSchema;
|
||||
run: CommandRunnerHandler<TContext, TResult>;
|
||||
};
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
export { parseCommand } from './command-parse';
|
||||
export { parseCommandSchema } from './schema-parse';
|
||||
export { validateCommand, parseCommandWithSchema, applyCommandSchema } from './command-validate';
|
||||
export {
|
||||
createCommandRegistry,
|
||||
registerCommand,
|
||||
unregisterCommand,
|
||||
hasCommand,
|
||||
getCommand,
|
||||
runCommand,
|
||||
runCommandParsed,
|
||||
createCommandRunnerContext,
|
||||
} from './command-registry';
|
||||
export type {
|
||||
Command,
|
||||
CommandParamSchema,
|
||||
CommandOptionSchema,
|
||||
CommandFlagSchema,
|
||||
CommandSchema,
|
||||
} from './types';
|
||||
export type { CommandRunner, CommandRunnerHandler, CommandRunnerContext, PromptEvent, CommandRunnerEvents } from './command-runner';
|
||||
export type { CommandRegistry, CommandRunnerContextExport } from './command-registry';
|
||||
|
|
@ -5,8 +5,8 @@ export function parseCommandSchema(schemaStr: string, name?: string): CommandSch
|
|||
const schema: CommandSchema = {
|
||||
name: name ?? '',
|
||||
params: [],
|
||||
options: [],
|
||||
flags: [],
|
||||
options: {},
|
||||
flags: {},
|
||||
};
|
||||
|
||||
const tokens = tokenizeSchema(schemaStr);
|
||||
|
|
@ -27,28 +27,28 @@ export function parseCommandSchema(schemaStr: string, name?: string): CommandSch
|
|||
if (inner.startsWith('--')) {
|
||||
const result = parseOptionToken(inner.slice(2), false);
|
||||
if (result.isFlag) {
|
||||
schema.flags.push({ name: result.name, short: result.short });
|
||||
schema.flags[result.name] = { name: result.name, short: result.short };
|
||||
} else {
|
||||
schema.options.push({
|
||||
schema.options[result.name] = {
|
||||
name: result.name,
|
||||
short: result.short,
|
||||
required: false,
|
||||
defaultValue: result.defaultValue,
|
||||
schema: result.schema,
|
||||
});
|
||||
};
|
||||
}
|
||||
} else if (inner.startsWith('-') && inner.length > 1 && !inner.includes('--')) {
|
||||
const result = parseOptionToken(inner.slice(1), false);
|
||||
if (result.isFlag) {
|
||||
schema.flags.push({ name: result.name, short: result.short || result.name });
|
||||
schema.flags[result.name] = { name: result.name, short: result.short || result.name };
|
||||
} else {
|
||||
schema.options.push({
|
||||
schema.options[result.name] = {
|
||||
name: result.name,
|
||||
short: result.short || result.name,
|
||||
required: false,
|
||||
defaultValue: result.defaultValue,
|
||||
schema: result.schema,
|
||||
});
|
||||
};
|
||||
}
|
||||
} else {
|
||||
const isVariadic = inner.endsWith('...');
|
||||
|
|
@ -78,29 +78,29 @@ export function parseCommandSchema(schemaStr: string, name?: string): CommandSch
|
|||
} else if (token.startsWith('--')) {
|
||||
const result = parseOptionToken(token.slice(2), true);
|
||||
if (result.isFlag) {
|
||||
schema.flags.push({ name: result.name, short: result.short });
|
||||
schema.flags[result.name] = { name: result.name, short: result.short };
|
||||
} else {
|
||||
schema.options.push({
|
||||
schema.options[result.name] = {
|
||||
name: result.name,
|
||||
short: result.short,
|
||||
required: true,
|
||||
defaultValue: result.defaultValue,
|
||||
schema: result.schema,
|
||||
});
|
||||
};
|
||||
}
|
||||
i++;
|
||||
} else if (token.startsWith('-') && token.length > 1 && !/^-?\d+$/.test(token)) {
|
||||
const result = parseOptionToken(token.slice(1), true);
|
||||
if (result.isFlag) {
|
||||
schema.flags.push({ name: result.name, short: result.short || result.name });
|
||||
schema.flags[result.name] = { name: result.name, short: result.short || result.name };
|
||||
} else {
|
||||
schema.options.push({
|
||||
schema.options[result.name] = {
|
||||
name: result.name,
|
||||
short: result.short || result.name,
|
||||
required: true,
|
||||
defaultValue: result.defaultValue,
|
||||
schema: result.schema,
|
||||
});
|
||||
};
|
||||
}
|
||||
i++;
|
||||
} else if (token.startsWith('<') && token.endsWith('>')) {
|
||||
|
|
|
|||
|
|
@ -30,8 +30,8 @@ export type CommandFlagSchema = {
|
|||
export type CommandSchema = {
|
||||
name: string;
|
||||
params: CommandParamSchema[];
|
||||
options: CommandOptionSchema[];
|
||||
flags: CommandFlagSchema[];
|
||||
options: Record<string, CommandOptionSchema>;
|
||||
flags: Record<string, CommandFlagSchema>;
|
||||
}
|
||||
|
||||
export interface ParsedOptionResult {
|
||||
|
|
|
|||
|
|
@ -28,8 +28,8 @@ describe('Rule System', () => {
|
|||
expect(rule.schema.params[0].required).toBe(true);
|
||||
expect(rule.schema.params[1].name).toBe('to');
|
||||
expect(rule.schema.params[1].required).toBe(true);
|
||||
expect(rule.schema.flags).toHaveLength(1);
|
||||
expect(rule.schema.flags[0].name).toBe('force');
|
||||
expect(Object.keys(rule.schema.flags)).toHaveLength(1);
|
||||
expect(rule.schema.flags.force.name).toBe('force');
|
||||
});
|
||||
|
||||
it('should create a generator when called', () => {
|
||||
|
|
|
|||
|
|
@ -21,11 +21,11 @@ describe('parseCommandSchema with inline-schema', () => {
|
|||
it('should parse schema with typed options', () => {
|
||||
const schema = parseCommandSchema('move <from> <to> [--all: boolean] [--count: number]');
|
||||
expect(schema.name).toBe('move');
|
||||
expect(schema.flags).toHaveLength(1);
|
||||
expect(schema.options).toHaveLength(1);
|
||||
expect(schema.flags[0].name).toBe('all');
|
||||
expect(schema.options[0].name).toBe('count');
|
||||
expect(schema.options[0].schema).toBeDefined();
|
||||
expect(Object.keys(schema.flags)).toHaveLength(1);
|
||||
expect(Object.keys(schema.options)).toHaveLength(1);
|
||||
expect(schema.flags.all.name).toBe('all');
|
||||
expect(schema.options.count.name).toBe('count');
|
||||
expect(schema.options.count.schema).toBeDefined();
|
||||
});
|
||||
|
||||
it('should parse schema with tuple type', () => {
|
||||
|
|
@ -58,7 +58,7 @@ describe('parseCommandSchema with inline-schema', () => {
|
|||
);
|
||||
expect(schema.name).toBe('move');
|
||||
expect(schema.params).toHaveLength(2);
|
||||
expect(schema.options).toHaveLength(1);
|
||||
expect(Object.keys(schema.options)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should parse schema with optional typed param', () => {
|
||||
|
|
@ -73,9 +73,9 @@ describe('parseCommandSchema with inline-schema', () => {
|
|||
it('should parse schema with optional typed option', () => {
|
||||
const schema = parseCommandSchema('move <from> [--speed: number]');
|
||||
expect(schema.name).toBe('move');
|
||||
expect(schema.options).toHaveLength(1);
|
||||
expect(schema.options[0].required).toBe(false);
|
||||
expect(schema.options[0].schema).toBeDefined();
|
||||
expect(Object.keys(schema.options)).toHaveLength(1);
|
||||
expect(schema.options.speed.required).toBe(false);
|
||||
expect(schema.options.speed.schema).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,427 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { parseCommandSchema } from '../../src/utils/command/schema-parse';
|
||||
import {
|
||||
createCommandRegistry,
|
||||
registerCommand,
|
||||
unregisterCommand,
|
||||
hasCommand,
|
||||
getCommand,
|
||||
runCommand,
|
||||
createCommandRunnerContext,
|
||||
type CommandRegistry,
|
||||
type CommandRunnerContextExport,
|
||||
} from '../../src/utils/command/command-registry';
|
||||
import type { CommandRunner, PromptEvent } from '../../src/utils/command/command-runner';
|
||||
|
||||
type TestContext = {
|
||||
counter: number;
|
||||
log: string[];
|
||||
};
|
||||
|
||||
describe('CommandRegistry', () => {
|
||||
it('should create an empty registry', () => {
|
||||
const registry = createCommandRegistry<TestContext>();
|
||||
expect(registry.size).toBe(0);
|
||||
});
|
||||
|
||||
it('should register a command', () => {
|
||||
const registry = createCommandRegistry<TestContext>();
|
||||
const runner: CommandRunner<TestContext, number> = {
|
||||
schema: parseCommandSchema('add <a> <b>'),
|
||||
run: async function (cmd) {
|
||||
return Number(cmd.params[0]) + Number(cmd.params[1]);
|
||||
},
|
||||
};
|
||||
registerCommand(registry, runner);
|
||||
expect(registry.size).toBe(1);
|
||||
expect(hasCommand(registry, 'add')).toBe(true);
|
||||
});
|
||||
|
||||
it('should unregister a command', () => {
|
||||
const registry = createCommandRegistry<TestContext>();
|
||||
const runner: CommandRunner<TestContext> = {
|
||||
schema: parseCommandSchema('remove'),
|
||||
run: async () => {},
|
||||
};
|
||||
registerCommand(registry, runner);
|
||||
expect(hasCommand(registry, 'remove')).toBe(true);
|
||||
unregisterCommand(registry, 'remove');
|
||||
expect(hasCommand(registry, 'remove')).toBe(false);
|
||||
});
|
||||
|
||||
it('should get a command runner', () => {
|
||||
const registry = createCommandRegistry<TestContext>();
|
||||
const runner: CommandRunner<TestContext> = {
|
||||
schema: parseCommandSchema('get'),
|
||||
run: async () => {},
|
||||
};
|
||||
registerCommand(registry, runner);
|
||||
const retrieved = getCommand(registry, 'get');
|
||||
expect(retrieved).toBe(runner);
|
||||
});
|
||||
|
||||
it('should return undefined for unknown command', () => {
|
||||
const registry = createCommandRegistry<TestContext>();
|
||||
const retrieved = getCommand(registry, 'unknown');
|
||||
expect(retrieved).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('runCommand', () => {
|
||||
it('should run a command successfully', async () => {
|
||||
const registry = createCommandRegistry<TestContext>();
|
||||
const runner: CommandRunner<TestContext, number> = {
|
||||
schema: parseCommandSchema('add <a> <b>'),
|
||||
run: async function (cmd) {
|
||||
return Number(cmd.params[0]) + Number(cmd.params[1]);
|
||||
},
|
||||
};
|
||||
registerCommand(registry, runner);
|
||||
|
||||
const result = await runCommand(registry, { counter: 0, log: [] }, 'add 1 2');
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.result).toBe(3);
|
||||
}
|
||||
});
|
||||
|
||||
it('should fail for unknown command', async () => {
|
||||
const registry = createCommandRegistry<TestContext>();
|
||||
const result = await runCommand(registry, { counter: 0, log: [] }, 'unknown');
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error).toContain('Unknown command');
|
||||
}
|
||||
});
|
||||
|
||||
it('should fail for invalid command params', async () => {
|
||||
const registry = createCommandRegistry<TestContext>();
|
||||
const runner: CommandRunner<TestContext> = {
|
||||
schema: parseCommandSchema('add <a> <b>'),
|
||||
run: async () => {},
|
||||
};
|
||||
registerCommand(registry, runner);
|
||||
|
||||
const result = await runCommand(registry, { counter: 0, log: [] }, 'add 1');
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error).toContain('参数不足');
|
||||
}
|
||||
});
|
||||
|
||||
it('should access context via this.context', async () => {
|
||||
const registry = createCommandRegistry<TestContext>();
|
||||
const runner: CommandRunner<TestContext, number> = {
|
||||
schema: parseCommandSchema('increment'),
|
||||
run: async function () {
|
||||
this.context.counter++;
|
||||
return this.context.counter;
|
||||
},
|
||||
};
|
||||
registerCommand(registry, runner);
|
||||
|
||||
const ctx = { counter: 0, log: [] };
|
||||
await runCommand(registry, ctx, 'increment');
|
||||
expect(ctx.counter).toBe(1);
|
||||
});
|
||||
|
||||
it('should handle async errors', async () => {
|
||||
const registry = createCommandRegistry<TestContext>();
|
||||
const runner: CommandRunner<TestContext> = {
|
||||
schema: parseCommandSchema('fail'),
|
||||
run: async () => {
|
||||
throw new Error('Something went wrong');
|
||||
},
|
||||
};
|
||||
registerCommand(registry, runner);
|
||||
|
||||
const result = await runCommand(registry, { counter: 0, log: [] }, 'fail');
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error).toBe('Something went wrong');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('CommandRunnerContext', () => {
|
||||
it('should create a runner context', () => {
|
||||
const registry = createCommandRegistry<TestContext>();
|
||||
const ctx = { counter: 0, log: [] };
|
||||
const runnerCtx = createCommandRunnerContext(registry, ctx);
|
||||
expect(runnerCtx.registry).toBe(registry);
|
||||
expect(runnerCtx.context).toBe(ctx);
|
||||
});
|
||||
|
||||
it('should run commands via runner context', async () => {
|
||||
const registry = createCommandRegistry<TestContext>();
|
||||
const runner: CommandRunner<TestContext, string> = {
|
||||
schema: parseCommandSchema('greet <name>'),
|
||||
run: async function (cmd) {
|
||||
this.context.log.push(`Hello, ${cmd.params[0]}!`);
|
||||
return `Hello, ${cmd.params[0]}!`;
|
||||
},
|
||||
};
|
||||
registerCommand(registry, runner);
|
||||
|
||||
const ctx = { counter: 0, log: [] };
|
||||
const runnerCtx = createCommandRunnerContext(registry, ctx);
|
||||
const result = await runnerCtx.run('greet World');
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.result).toBe('Hello, World!');
|
||||
}
|
||||
expect(ctx.log).toEqual(['Hello, World!']);
|
||||
});
|
||||
|
||||
it('should allow commands to call other commands via this.run', async () => {
|
||||
const registry = createCommandRegistry<TestContext>();
|
||||
|
||||
const addRunner: CommandRunner<TestContext, number> = {
|
||||
schema: parseCommandSchema('add <a> <b>'),
|
||||
run: async function (cmd) {
|
||||
return Number(cmd.params[0]) + Number(cmd.params[1]);
|
||||
},
|
||||
};
|
||||
|
||||
registerCommand(registry, addRunner);
|
||||
|
||||
const multiplyRunner: CommandRunner<TestContext, number> = {
|
||||
schema: parseCommandSchema('multiply <a> <b>'),
|
||||
run: async function (cmd) {
|
||||
const a = Number(cmd.params[0]);
|
||||
const b = Number(cmd.params[1]);
|
||||
const addResult = await this.run(`add ${a} ${a}`);
|
||||
if (!addResult.success) throw new Error('add failed');
|
||||
return (addResult.result as number) * b;
|
||||
},
|
||||
};
|
||||
|
||||
registerCommand(registry, multiplyRunner);
|
||||
|
||||
const ctx = { counter: 0, log: [] };
|
||||
const result = await runCommand(registry, ctx, 'multiply 3 4');
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.result).toBe(24);
|
||||
}
|
||||
});
|
||||
|
||||
it('should allow commands to call other commands via this.runParsed', async () => {
|
||||
const registry = createCommandRegistry<TestContext>();
|
||||
|
||||
const doubleRunner: CommandRunner<TestContext, number> = {
|
||||
schema: parseCommandSchema('double <n>'),
|
||||
run: async function (cmd) {
|
||||
return Number(cmd.params[0]) * 2;
|
||||
},
|
||||
};
|
||||
|
||||
registerCommand(registry, doubleRunner);
|
||||
|
||||
const quadrupleRunner: CommandRunner<TestContext, number> = {
|
||||
schema: parseCommandSchema('quadruple <n>'),
|
||||
run: async function (cmd) {
|
||||
const n = Number(cmd.params[0]);
|
||||
const doubleResult = await this.runParsed({ name: 'double', params: [String(n)], options: {}, flags: {} });
|
||||
if (!doubleResult.success) throw new Error('double failed');
|
||||
return (doubleResult.result as number) * 2;
|
||||
},
|
||||
};
|
||||
|
||||
registerCommand(registry, quadrupleRunner);
|
||||
|
||||
const ctx = { counter: 0, log: [] };
|
||||
const result = await runCommand(registry, ctx, 'quadruple 5');
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.result).toBe(20);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('prompt', () => {
|
||||
it('should dispatch prompt event with string schema', async () => {
|
||||
const registry = createCommandRegistry<TestContext>();
|
||||
|
||||
const chooseRunner: CommandRunner<TestContext, string> = {
|
||||
schema: parseCommandSchema('choose'),
|
||||
run: async function () {
|
||||
const result = await this.prompt('select <card>');
|
||||
return result.params[0] as string;
|
||||
},
|
||||
};
|
||||
|
||||
registerCommand(registry, chooseRunner);
|
||||
|
||||
const ctx = { counter: 0, log: [] };
|
||||
let promptEvent: PromptEvent | null = null;
|
||||
|
||||
const runnerCtx = createCommandRunnerContext(registry, ctx);
|
||||
runnerCtx.on('prompt', (e) => {
|
||||
promptEvent = e;
|
||||
});
|
||||
|
||||
const runPromise = runnerCtx.run('choose');
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(promptEvent).not.toBeNull();
|
||||
expect(promptEvent!.schema.name).toBe('select');
|
||||
});
|
||||
|
||||
it('should resolve prompt with valid input', async () => {
|
||||
const registry = createCommandRegistry<TestContext>();
|
||||
|
||||
const chooseRunner: CommandRunner<TestContext, string> = {
|
||||
schema: parseCommandSchema('choose'),
|
||||
run: async function () {
|
||||
const result = await this.prompt('select <card>');
|
||||
this.context.log.push(`selected ${result.params[0]}`);
|
||||
return result.params[0] as string;
|
||||
},
|
||||
};
|
||||
|
||||
registerCommand(registry, chooseRunner);
|
||||
|
||||
const ctx = { counter: 0, log: [] };
|
||||
let promptEvent: PromptEvent | null = null;
|
||||
|
||||
const runnerCtx = createCommandRunnerContext(registry, ctx);
|
||||
runnerCtx.on('prompt', (e) => {
|
||||
promptEvent = e;
|
||||
});
|
||||
|
||||
const runPromise = runnerCtx.run('choose');
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(promptEvent).not.toBeNull();
|
||||
|
||||
const parsed = { name: 'select', params: ['Ace'], options: {}, flags: {} };
|
||||
promptEvent!.resolve(parsed);
|
||||
|
||||
const result = await runPromise;
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.result).toBe('Ace');
|
||||
}
|
||||
expect(ctx.log).toEqual(['selected Ace']);
|
||||
});
|
||||
|
||||
it('should reject prompt with invalid input', async () => {
|
||||
const registry = createCommandRegistry<TestContext>();
|
||||
|
||||
const chooseRunner: CommandRunner<TestContext, string> = {
|
||||
schema: parseCommandSchema('choose'),
|
||||
run: async function () {
|
||||
try {
|
||||
await this.prompt('select <card>');
|
||||
return 'unexpected success';
|
||||
} catch (e) {
|
||||
return (e as Error).message;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
registerCommand(registry, chooseRunner);
|
||||
|
||||
const ctx = { counter: 0, log: [] };
|
||||
let promptEvent: PromptEvent | null = null;
|
||||
|
||||
const runnerCtx = createCommandRunnerContext(registry, ctx);
|
||||
runnerCtx.on('prompt', (e) => {
|
||||
promptEvent = e;
|
||||
});
|
||||
|
||||
const runPromise = runnerCtx.run('choose');
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(promptEvent).not.toBeNull();
|
||||
|
||||
promptEvent!.reject(new Error('user cancelled'));
|
||||
|
||||
const result = await runPromise;
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.result).toBe('user cancelled');
|
||||
}
|
||||
});
|
||||
|
||||
it('should accept CommandSchema object in prompt', async () => {
|
||||
const registry = createCommandRegistry<TestContext>();
|
||||
const schema = parseCommandSchema('pick <item>');
|
||||
|
||||
const pickRunner: CommandRunner<TestContext, string> = {
|
||||
schema: parseCommandSchema('pick'),
|
||||
run: async function () {
|
||||
const result = await this.prompt(schema);
|
||||
return result.params[0] as string;
|
||||
},
|
||||
};
|
||||
|
||||
registerCommand(registry, pickRunner);
|
||||
|
||||
const ctx = { counter: 0, log: [] };
|
||||
let promptEvent: PromptEvent | null = null;
|
||||
|
||||
const runnerCtx = createCommandRunnerContext(registry, ctx);
|
||||
runnerCtx.on('prompt', (e) => {
|
||||
promptEvent = e;
|
||||
});
|
||||
|
||||
const runPromise = runnerCtx.run('pick');
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(promptEvent).not.toBeNull();
|
||||
expect(promptEvent!.schema.name).toBe('pick');
|
||||
|
||||
promptEvent!.resolve({ name: 'pick', params: ['sword'], options: {}, flags: {} });
|
||||
|
||||
const result = await runPromise;
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.result).toBe('sword');
|
||||
}
|
||||
});
|
||||
|
||||
it('should allow multiple sequential prompts', async () => {
|
||||
const registry = createCommandRegistry<TestContext>();
|
||||
|
||||
const multiPromptRunner: CommandRunner<TestContext, string[]> = {
|
||||
schema: parseCommandSchema('multi'),
|
||||
run: async function () {
|
||||
const first = await this.prompt('first <a>');
|
||||
const second = await this.prompt('second <b>');
|
||||
return [first.params[0] as string, second.params[0] as string];
|
||||
},
|
||||
};
|
||||
|
||||
registerCommand(registry, multiPromptRunner);
|
||||
|
||||
const ctx = { counter: 0, log: [] };
|
||||
const promptEvents: PromptEvent[] = [];
|
||||
|
||||
const runnerCtx = createCommandRunnerContext(registry, ctx);
|
||||
runnerCtx.on('prompt', (e) => {
|
||||
promptEvents.push(e);
|
||||
});
|
||||
|
||||
const runPromise = runnerCtx.run('multi');
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(promptEvents.length).toBe(1);
|
||||
expect(promptEvents[0].schema.name).toBe('first');
|
||||
|
||||
promptEvents[0].resolve({ name: 'first', params: ['one'], options: {}, flags: {} });
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(promptEvents.length).toBe(2);
|
||||
expect(promptEvents[1].schema.name).toBe('second');
|
||||
|
||||
promptEvents[1].resolve({ name: 'second', params: ['two'], options: {}, flags: {} });
|
||||
|
||||
const result = await runPromise;
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.result).toEqual(['one', 'two']);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -7,18 +7,8 @@ describe('parseCommandSchema', () => {
|
|||
expect(schema).toEqual({
|
||||
name: '',
|
||||
params: [],
|
||||
options: [],
|
||||
flags: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse command name only', () => {
|
||||
const schema = parseCommandSchema('move');
|
||||
expect(schema).toEqual({
|
||||
name: 'move',
|
||||
params: [],
|
||||
options: [],
|
||||
flags: [],
|
||||
options: {},
|
||||
flags: {},
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -55,34 +45,36 @@ describe('parseCommandSchema', () => {
|
|||
|
||||
it('should parse long flags', () => {
|
||||
const schema = parseCommandSchema('move [--force] [--quiet]');
|
||||
expect(schema.flags).toEqual([
|
||||
{ name: 'force' },
|
||||
{ name: 'quiet' },
|
||||
]);
|
||||
expect(schema.flags).toEqual({
|
||||
force: { name: 'force', short: undefined },
|
||||
quiet: { name: 'quiet', short: undefined },
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse short flags', () => {
|
||||
const schema = parseCommandSchema('move [-f] [-q]');
|
||||
expect(schema.flags).toEqual([
|
||||
{ name: 'f', short: 'f' },
|
||||
{ name: 'q', short: 'q' },
|
||||
]);
|
||||
expect(schema.flags).toEqual({
|
||||
f: { name: 'f', short: 'f' },
|
||||
q: { name: 'q', short: 'q' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse long options', () => {
|
||||
const schema = parseCommandSchema('move --x: string [--y: string]');
|
||||
expect(schema.options).toEqual([
|
||||
{ name: 'x', required: true, schema: expect.any(Object) },
|
||||
{ name: 'y', required: false, schema: expect.any(Object) },
|
||||
]);
|
||||
expect(Object.keys(schema.options)).toEqual(['x', 'y']);
|
||||
expect(schema.options.x).toMatchObject({ name: 'x', required: true });
|
||||
expect(schema.options.x.schema).toBeDefined();
|
||||
expect(schema.options.y).toMatchObject({ name: 'y', required: false });
|
||||
expect(schema.options.y.schema).toBeDefined();
|
||||
});
|
||||
|
||||
it('should parse short options', () => {
|
||||
const schema = parseCommandSchema('move -x: string [-y: string]');
|
||||
expect(schema.options).toEqual([
|
||||
{ name: 'x', short: 'x', required: true, schema: expect.any(Object) },
|
||||
{ name: 'y', short: 'y', required: false, schema: expect.any(Object) },
|
||||
]);
|
||||
expect(Object.keys(schema.options)).toEqual(['x', 'y']);
|
||||
expect(schema.options.x).toMatchObject({ name: 'x', short: 'x', required: true });
|
||||
expect(schema.options.x.schema).toBeDefined();
|
||||
expect(schema.options.y).toMatchObject({ name: 'y', short: 'y', required: false });
|
||||
expect(schema.options.y.schema).toBeDefined();
|
||||
});
|
||||
|
||||
it('should parse mixed schema', () => {
|
||||
|
|
@ -93,13 +85,13 @@ describe('parseCommandSchema', () => {
|
|||
{ name: 'from', required: true, variadic: false, schema: undefined },
|
||||
{ name: 'to', required: true, variadic: false, schema: undefined },
|
||||
],
|
||||
flags: [
|
||||
{ name: 'force' },
|
||||
{ name: 'f', short: 'f' },
|
||||
],
|
||||
options: [
|
||||
{ name: 'speed', short: 's', required: false, schema: expect.any(Object), defaultValue: undefined },
|
||||
],
|
||||
flags: {
|
||||
force: { name: 'force', short: undefined },
|
||||
f: { name: 'f', short: 'f' },
|
||||
},
|
||||
options: {
|
||||
speed: { name: 'speed', short: 's', required: false, schema: expect.any(Object), defaultValue: undefined },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -107,8 +99,8 @@ describe('parseCommandSchema', () => {
|
|||
const schema = parseCommandSchema('place <piece> <region> [x...] [--rotate: number] [--force] [-f]');
|
||||
expect(schema.name).toBe('place');
|
||||
expect(schema.params).toHaveLength(3);
|
||||
expect(schema.flags).toHaveLength(2);
|
||||
expect(schema.options).toHaveLength(1);
|
||||
expect(Object.keys(schema.flags)).toHaveLength(2);
|
||||
expect(Object.keys(schema.options)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -237,39 +229,8 @@ describe('integration', () => {
|
|||
|
||||
it('should parse short alias syntax', () => {
|
||||
const schema = parseCommandSchema('move <from> [--verbose: boolean -v]');
|
||||
expect(schema.flags).toHaveLength(1);
|
||||
expect(schema.flags[0]).toEqual({ name: 'verbose', short: 'v' });
|
||||
});
|
||||
|
||||
it('should parse short alias for options', () => {
|
||||
const schema = parseCommandSchema('move <from> [--speed: number -s]');
|
||||
expect(schema.options).toHaveLength(1);
|
||||
expect(schema.options[0]).toEqual({
|
||||
name: 'speed',
|
||||
short: 's',
|
||||
required: false,
|
||||
schema: expect.any(Object),
|
||||
defaultValue: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse default value syntax', () => {
|
||||
const schema = parseCommandSchema('move <from> [--speed: number = 10]');
|
||||
expect(schema.options).toHaveLength(1);
|
||||
expect(schema.options[0].defaultValue).toBe(10);
|
||||
});
|
||||
|
||||
it('should parse default string value', () => {
|
||||
const schema = parseCommandSchema('move <from> [--name: string = "default"]');
|
||||
expect(schema.options).toHaveLength(1);
|
||||
expect(schema.options[0].defaultValue).toBe('default');
|
||||
});
|
||||
|
||||
it('should parse short alias with default value', () => {
|
||||
const schema = parseCommandSchema('move <from> [--speed: number -s = 5]');
|
||||
expect(schema.options).toHaveLength(1);
|
||||
expect(schema.options[0].short).toBe('s');
|
||||
expect(schema.options[0].defaultValue).toBe(5);
|
||||
expect(Object.keys(schema.flags)).toHaveLength(1);
|
||||
expect(schema.flags.verbose).toEqual({ name: 'verbose', short: 'v' });
|
||||
});
|
||||
|
||||
it('should parse command with short alias', () => {
|
||||
|
|
@ -288,3 +249,5 @@ describe('integration', () => {
|
|||
expect(command.options.s).toBe('100');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue