diff --git a/examples/naval-fate-2.ts b/examples/naval-fate-2.ts new file mode 100644 index 0000000..6924d79 --- /dev/null +++ b/examples/naval-fate-2.ts @@ -0,0 +1,135 @@ +import { Args, CliApp, Command, Options } from "@effect/cli" +import * as Handled from "@effect/cli/HandledCommand" +import * as KeyValueStore from "@effect/platform-node/KeyValueStore" +import * as NodeContext from "@effect/platform-node/NodeContext" +import * as Runtime from "@effect/platform-node/Runtime" +import * as Console from "effect/Console" +import * as Effect from "effect/Effect" +import * as Layer from "effect/Layer" +import * as NavalFateStore from "./naval-fate/store.js" + +const { createShip, moveShip, removeMine, setMine, shoot } = Effect.serviceFunctions( + NavalFateStore.NavalFateStore +) + +// naval_fate [-h | --help] [--version] +// naval_fate ship new ... +// naval_fate ship move [--speed=] +// naval_fate ship shoot +// naval_fate mine set [--moored] +// naval_fate mine remove [--moored] + +const nameArg = Args.text({ name: "name" }) +const xArg = Args.integer({ name: "x" }) +const yArg = Args.integer({ name: "y" }) +const nameAndCoordinatesArg = Args.all({ name: nameArg, x: xArg, y: yArg }) +const coordinatesArg = Args.all({ x: xArg, y: yArg }) + +const mooredOption = Options.boolean("moored").pipe( + Options.withDescription("Whether the mine is moored (anchored) or drifting") +) +const speedOption = Options.integer("speed").pipe( + Options.withDescription("Speed in knots"), + Options.withDefault(10) +) + +const newShipCommand = Command.make("new", { + args: nameArg +}).pipe( + Handled.make("new", ({ args: name }) => + createShip(name).pipe( + Effect.zipRight(Console.log(`Created ship: '${name}'`)) + )) +) + +const moveShipCommand = Command.make("move", { + args: nameAndCoordinatesArg, + options: speedOption +}).pipe( + Handled.make("move", ({ args: { name, x, y }, options: speed }) => + moveShip(name, x, y).pipe( + Effect.zipRight( + Console.log(`Moving ship '${name}' to coordinates (${x}, ${y}) at ${speed} knots`) + ) + )) +) + +const shootShipCommand = Command.make("shoot", { + args: coordinatesArg +}).pipe( + Handled.make("shoot", ({ args: { x, y } }) => + shoot(x, y).pipe( + Effect.zipRight(Console.log(`Shot cannons at coordinates (${x}, ${y})`)) + )) +) + +const shipCommand = Command.make("ship").pipe( + Handled.makeUnit("ship"), + Handled.withSubcommands([ + newShipCommand, + moveShipCommand, + shootShipCommand + ]) +) + +const setMineCommand = Command.make("set", { + args: coordinatesArg, + options: mooredOption +}).pipe( + Handled.make("set", ({ args: { x, y }, options: moored }) => + setMine(x, y).pipe( + Effect.zipRight( + Console.log(`Set ${moored ? "moored" : "drifting"} mine at coordinates (${x}, ${y})`) + ) + )) +) + +const removeMineCommand = Command.make("remove", { + args: coordinatesArg +}).pipe( + Handled.make("remove", ({ args: { x, y } }) => + removeMine(x, y).pipe( + Effect.zipRight(Console.log(`Removing mine at coordinates (${x}, ${y}), if present`)) + )) +) + +const mineCommand = Command.make("mine").pipe( + Handled.makeUnit("mine"), + Handled.withSubcommands([ + setMineCommand, + removeMineCommand + ]) +) + +const navalFate = Command.make("naval_fate").pipe( + Command.withDescription("An implementation of the Naval Fate CLI application."), + Handled.makeUnit("naval_fate"), + Handled.withSubcommands([shipCommand, mineCommand]) +) + +const navalFateApp = CliApp.make({ + name: "Naval Fate", + version: "1.0.0", + command: navalFate.command +}) + +const main = Effect.sync(() => globalThis.process.argv.slice(2)).pipe( + Effect.flatMap((argv) => + CliApp.run( + navalFateApp, + argv, + navalFate.handler + ) + ) +) + +const MainLayer = NavalFateStore.layer.pipe( + Layer.use(KeyValueStore.layerFileSystem("naval-fate-store")), + Layer.merge(NodeContext.layer) +) + +main.pipe( + Effect.provide(MainLayer), + Effect.tapErrorCause(Effect.logError), + Runtime.runMain +) diff --git a/src/HandledCommand.ts b/src/HandledCommand.ts new file mode 100644 index 0000000..f43bc3f --- /dev/null +++ b/src/HandledCommand.ts @@ -0,0 +1,140 @@ +/** + * @since 1.0.0 + */ +import * as Effect from "effect/Effect" +import { dual } from "effect/Function" +import type * as Option from "effect/Option" +import type { Pipeable } from "effect/Pipeable" +import ReadonlyArray from "effect/ReadonlyArray" +import * as Command from "./Command.js" + +/** + * @since 1.0.0 + * @category type ids + */ +export const TypeId = Symbol.for("@effect/cli/HandledCommand") + +/** + * @since 1.0.0 + * @category type ids + */ +export type TypeId = typeof TypeId + +/** + * @since 1.0.0 + * @category models + */ +export interface HandledCommand extends Pipeable { + readonly [TypeId]: TypeId + readonly name: Name + readonly command: Command.Command + readonly handler: (_: A) => Effect.Effect +} + +const Prototype = { + [TypeId]: TypeId +} + +/** + * @since 1.0.0 + * @category constructors + */ +export const make = dual< + ( + name: Name, + handler: (_: A) => Effect.Effect + ) => ( + command: Command.Command<{ readonly name: Name } & A> + ) => HandledCommand, + ( + command: Command.Command<{ readonly name: Name } & A>, + name: Name, + handler: (_: A) => Effect.Effect + ) => HandledCommand +>(3, (command, name, handler) => { + const self = Object.create(Prototype) + self.name = name + self.command = command + self.handler = handler + return self +}) + +/** + * @since 1.0.0 + * @category constructors + */ +export const makeUnit = dual< + ( + name: Name + ) => ( + command: Command.Command<{ readonly name: Name } & A> + ) => HandledCommand, + ( + command: Command.Command<{ readonly name: Name } & A>, + name: Name + ) => HandledCommand +>(2, (command, name) => make(command, name, (_) => Effect.unit) as any) + +/** + * @since 1.0.0 + * @category combinators + */ +export const withSubcommands = dual< + < + Subcommand extends ReadonlyArray.NonEmptyReadonlyArray> + >( + subcommands: Subcommand + ) => (self: HandledCommand) => HandledCommand< + Name, + Command.Command.ComputeParsedType< + & A + & Readonly< + { subcommand: Option.Option> } + > + >, + R | Effect.Effect.Context>, + E | Effect.Effect.Error> + >, + < + Name extends string, + A, + R, + E, + Subcommand extends ReadonlyArray.NonEmptyReadonlyArray> + >( + self: HandledCommand, + subcommands: Subcommand + ) => HandledCommand< + Name, + Command.Command.ComputeParsedType< + & A + & Readonly< + { subcommand: Option.Option> } + > + >, + R | Effect.Effect.Context>, + E | Effect.Effect.Error> + > +>(2, (self, subcommands) => { + const command = Command.withSubcommands( + self.command, + ReadonlyArray.map(subcommands, (_) => _.command) + ) + const handlers = ReadonlyArray.reduce( + subcommands, + {} as Record Effect.Effect>, + (handlers, subcommand) => { + handlers[subcommand.name] = subcommand.handler + return handlers + } + ) + const handler = ( + args: { readonly subcommand: Option.Option<{ readonly name: string }> } + ) => { + if (args.subcommand._tag === "Some") { + return handlers[args.subcommand.value.name](args.subcommand.value) + } + return self.handler(args as any) + } + return make(command as any, self.name, handler) as any +})