refactor: move runner context to handler's this
This commit is contained in:
parent
3bc35df63c
commit
bcb31da773
|
|
@ -1,7 +1,8 @@
|
||||||
import type { Command } from './types.js';
|
import type { Command } from './types.js';
|
||||||
import type { CommandRunner, CommandRunnerContext as RunnerContext } from './command-runner.js';
|
import type { CommandRunner, CommandRunnerContext, PromptEvent } from './command-runner.js';
|
||||||
import { parseCommand } from './command-parse.js';
|
import { parseCommand } from './command-parse.js';
|
||||||
import { applyCommandSchema } from './command-apply.js';
|
import { applyCommandSchema } from './command-apply.js';
|
||||||
|
import { parseCommandSchema } from './schema-parse.js';
|
||||||
|
|
||||||
export type CommandRegistry<TContext> = Map<string, CommandRunner<TContext, unknown>>;
|
export type CommandRegistry<TContext> = Map<string, CommandRunner<TContext, unknown>>;
|
||||||
|
|
||||||
|
|
@ -37,17 +38,54 @@ export function getCommand<TContext>(
|
||||||
return registry.get(name);
|
return registry.get(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function executeWithRunnerContext<TContext>(
|
type Listener = (e: PromptEvent) => void;
|
||||||
|
|
||||||
|
export type CommandRunnerContextExport<TContext> = CommandRunnerContext<TContext> & {
|
||||||
|
registry: CommandRegistry<TContext>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createCommandRunnerContext<TContext>(
|
||||||
registry: CommandRegistry<TContext>,
|
registry: CommandRegistry<TContext>,
|
||||||
context: 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>,
|
runner: CommandRunner<TContext, unknown>,
|
||||||
command: Command
|
command: Command
|
||||||
): Promise<{ success: true; result: unknown } | { success: false; error: string }> {
|
): Promise<{ success: true; result: unknown } | { success: false; error: string }> {
|
||||||
const runnerCtx: RunnerContext<TContext> = {
|
|
||||||
context,
|
|
||||||
run: (input: string) => runCommand(registry, context, input),
|
|
||||||
runParsed: (cmd: Command) => runCommandParsed(registry, context, cmd),
|
|
||||||
};
|
|
||||||
try {
|
try {
|
||||||
const result = await runner.run.call(runnerCtx, command);
|
const result = await runner.run.call(runnerCtx, command);
|
||||||
return { success: true, result };
|
return { success: true, result };
|
||||||
|
|
@ -61,15 +99,33 @@ export async function runCommand<TContext>(
|
||||||
registry: CommandRegistry<TContext>,
|
registry: CommandRegistry<TContext>,
|
||||||
context: TContext,
|
context: TContext,
|
||||||
input: string
|
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 }> {
|
): Promise<{ success: true; result: unknown } | { success: false; error: string }> {
|
||||||
const command = parseCommand(input);
|
const command = parseCommand(input);
|
||||||
return await runCommandParsed(registry, context, command);
|
return await runCommandParsedWithContext(registry, runnerCtx, command);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runCommandParsed<TContext>(
|
export async function runCommandParsed<TContext>(
|
||||||
registry: CommandRegistry<TContext>,
|
registry: CommandRegistry<TContext>,
|
||||||
context: TContext,
|
context: TContext,
|
||||||
command: Command
|
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 }> {
|
): Promise<{ success: true; result: unknown } | { success: false; error: string }> {
|
||||||
const runner = registry.get(command.name);
|
const runner = registry.get(command.name);
|
||||||
if (!runner) {
|
if (!runner) {
|
||||||
|
|
@ -81,21 +137,5 @@ export async function runCommandParsed<TContext>(
|
||||||
return { success: false, error: validationResult.errors.join('; ') };
|
return { success: false, error: validationResult.errors.join('; ') };
|
||||||
}
|
}
|
||||||
|
|
||||||
return await executeWithRunnerContext(registry, context, runner, validationResult.command);
|
return await executeWithRunnerContext(runnerCtx, runner, validationResult.command);
|
||||||
}
|
|
||||||
|
|
||||||
export type CommandRunnerContext<TContext> = RunnerContext<TContext> & {
|
|
||||||
registry: CommandRegistry<TContext>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function createCommandRunnerContext<TContext>(
|
|
||||||
registry: CommandRegistry<TContext>,
|
|
||||||
context: TContext
|
|
||||||
): CommandRunnerContext<TContext> {
|
|
||||||
return {
|
|
||||||
registry,
|
|
||||||
context,
|
|
||||||
run: (input: string) => runCommand(registry, context, input),
|
|
||||||
runParsed: (command: Command) => runCommandParsed(registry, context, command),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,22 @@
|
||||||
import type { Command, CommandSchema } from './types.js';
|
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> = {
|
export type CommandRunnerContext<TContext> = {
|
||||||
context: TContext;
|
context: TContext;
|
||||||
run: (input: string) => Promise<{ success: true; result: unknown } | { success: false; error: string }>;
|
run: (input: string) => Promise<{ success: true; result: unknown } | { success: false; error: string }>;
|
||||||
runParsed: (command: Command) => 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> = (
|
export type CommandRunnerHandler<TContext, TResult> = (
|
||||||
|
|
|
||||||
|
|
@ -18,5 +18,5 @@ export type {
|
||||||
CommandFlagSchema,
|
CommandFlagSchema,
|
||||||
CommandSchema,
|
CommandSchema,
|
||||||
} from './types';
|
} from './types';
|
||||||
export type { CommandRunner, CommandRunnerHandler } from './command-runner';
|
export type { CommandRunner, CommandRunnerHandler, CommandRunnerContext, PromptEvent, CommandRunnerEvents } from './command-runner';
|
||||||
export type { CommandRegistry, CommandRunnerContext } from './command-registry';
|
export type { CommandRegistry, CommandRunnerContextExport } from './command-registry';
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,9 @@ import {
|
||||||
runCommand,
|
runCommand,
|
||||||
createCommandRunnerContext,
|
createCommandRunnerContext,
|
||||||
type CommandRegistry,
|
type CommandRegistry,
|
||||||
type CommandRunnerContext,
|
type CommandRunnerContextExport,
|
||||||
} from '../../src/utils/command/command-registry';
|
} from '../../src/utils/command/command-registry';
|
||||||
import type { CommandRunner } from '../../src/utils/command/command-runner';
|
import type { CommandRunner, PromptEvent } from '../../src/utils/command/command-runner';
|
||||||
|
|
||||||
type TestContext = {
|
type TestContext = {
|
||||||
counter: number;
|
counter: number;
|
||||||
|
|
@ -238,3 +238,190 @@ describe('CommandRunnerContext', () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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']);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue