@gjermundgaraba/effect-actions 0.17.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/dist/ActionCli.d.mts +29 -40
- package/dist/ActionCli.mjs +4 -15
- package/dist/ActionHttp.d.mts +9 -6
- package/dist/cli/commands.d.mts +1 -1
- package/dist/cli/commands.mjs +4 -4
- package/dist/http/client.d.mts +16 -7
- package/dist/http/client.mjs +6 -1
- package/dist/mcp/client.d.mts +12 -5
- package/dist/mcp/client.mjs +3 -3
- package/docs/ActionCli-failures.md +10 -9
- package/docs/ActionCli.md +21 -20
- package/docs/ActionHttp-failures.md +6 -0
- package/docs/ActionHttp.md +9 -4
- package/docs/ActionMcp.md +1 -1
- package/examples/kitchen-sink/src/cli.ts +19 -21
- package/examples/notes-service/README.md +2 -2
- package/examples/notes-service/src/cli.ts +3 -9
- package/examples/notes-service/src/commands.ts +40 -36
- package/examples/notes-service/test/commands.test.ts +13 -19
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -72,7 +72,9 @@ implementation is also:
|
|
|
72
72
|
```ts
|
|
73
73
|
const { toolkit, layer } = ActionToolkit.make(greeting); // native Effect AI Toolkit and its handler layer
|
|
74
74
|
const cli = ActionCli.make(greeting, { name: "greetings" }); // greetings greet --name Ada
|
|
75
|
-
const remote = ActionCli.remoteCommand(Http, Greet, {
|
|
75
|
+
const remote = ActionCli.remoteCommand(Http, Greet, {
|
|
76
|
+
client: ActionHttp.client(Http, { baseUrl }),
|
|
77
|
+
}); // greet --name Ada, over HTTP
|
|
76
78
|
const local = Action.client(greeting); // the HTTP client's methods, in process
|
|
77
79
|
const rpc = ActionRpc.layer(ActionRpc.make([Greet]), greeting); // Effect RPC, on the protocol the host provides
|
|
78
80
|
```
|
package/dist/ActionCli.d.mts
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
import { BuiltIn } from "./contract/errors.mjs";
|
|
2
2
|
import { ActionOf, BuildError, BuildServices, Holding, Known, Member, RequestOf, Selected, SelectedOf, Selection, Served } from "./contract/implementation.mjs";
|
|
3
3
|
import { Any } from "./Action.mjs";
|
|
4
|
-
import { VerifierError } from "./authentication/provider.mjs";
|
|
5
4
|
import { Declaring } from "./contract/declared.mjs";
|
|
6
5
|
import { Options, UserError } from "./cli/commands.mjs";
|
|
7
|
-
import { AnyHttp,
|
|
6
|
+
import { AnyHttp, Client } from "./http/client.mjs";
|
|
8
7
|
import "./ActionHttp.mjs";
|
|
9
|
-
import { Effect
|
|
10
|
-
import { HttpClient
|
|
11
|
-
import { Command } from "effect/cli";
|
|
8
|
+
import { Effect } from "effect";
|
|
9
|
+
import { HttpClient } from "effect/http";
|
|
10
|
+
import { CliError, Command } from "effect/cli";
|
|
12
11
|
//#region src/cli/ActionCli.d.ts
|
|
13
12
|
interface AnyBinding extends Declaring {
|
|
14
13
|
readonly actions: ReadonlyArray<Any>;
|
|
@@ -24,7 +23,7 @@ interface AnyBinding extends Declaring {
|
|
|
24
23
|
* `Effect.tapError(() => Console.error(…))`: a `UserError` wrapped around the result would
|
|
25
24
|
* replace its JSON.
|
|
26
25
|
*/
|
|
27
|
-
export declare const toUserError: (calls: AnyBinding | ReadonlyArray<Any>) => <A, E, R>(self: Effect.Effect<A, E, R>) => Effect.Effect<A, UserError<Exclude<E,
|
|
26
|
+
export declare const toUserError: (calls: AnyBinding | ReadonlyArray<Any>) => <A, E, R>(self: Effect.Effect<A, E, R>) => Effect.Effect<A, UserError<Exclude<E, CliError.CliError>> | Exclude<E, Exclude<E, CliError.CliError>>, R>;
|
|
28
27
|
/**
|
|
29
28
|
* An aggregate command of the actions `A`: its name, the actions that are its subcommands,
|
|
30
29
|
* and their options.
|
|
@@ -69,39 +68,28 @@ type Local<App, A extends Any> = Effect.Effect<A["success"]["Type"], A["error"][
|
|
|
69
68
|
* as JSON when it is not a struct. The flag of an array of strings or numbers is repeated for each value, a boolean
|
|
70
69
|
* field is a switch, `--no-<name>` sending `false` as a required one left out does, and a field's `description` is its flag's help. It needs what the authorization, its handler and its builder
|
|
71
70
|
* need; the host provides the identity. It prints the result on stdout; a failure is a
|
|
72
|
-
* `UserError`,
|
|
71
|
+
* `UserError`, or an Effect CLI error as it is, such as a builder's `CliError.UserError`, which
|
|
72
|
+
* `Command.run` prints on stderr. `remoteCommand` calls a server instead.
|
|
73
73
|
*/
|
|
74
|
-
export declare function command<const Apps extends Served, A extends ActionOf<Member<Apps>>, const O = {}>(implementations: Apps, action: A, options?: O & NoInfer<Options<A>> & NoInfer<Known<O, Options<Any>>>): Command.Command<string, never, {},
|
|
74
|
+
export declare function command<const Apps extends Served, A extends ActionOf<Member<Apps>>, const O = {}>(implementations: Apps, action: A, options?: O & NoInfer<Options<A>> & NoInfer<Known<O, Options<Any>>>): Command.Command<string, never, {}, Failing<Effect.Error<Local<Owning<Member<Apps>, A>, A>>>, Effect.Services<Local<Owning<Member<Apps>, A>, A>>>;
|
|
75
75
|
/**
|
|
76
76
|
* Project one action of an HTTP binding into a native Effect CLI command, derived as `command`
|
|
77
|
-
* derives it, that calls the action
|
|
78
|
-
* the
|
|
79
|
-
*
|
|
77
|
+
* derives it, that calls the action through `client`'s method and fails as the method does, or
|
|
78
|
+
* as the Effect building the client does. `client` is an Effect of the binding's client, such as
|
|
79
|
+
* `ActionHttp.client(Http, { baseUrl })`, run each time the command runs; omitted, it is
|
|
80
|
+
* `ActionHttp.client(binding)` on the host's `HttpClient`. It runs nothing locally.
|
|
80
81
|
*/
|
|
81
|
-
export declare function remoteCommand<const H extends AnyHttp, A extends H["actions"][number], const O = {}>(binding: H, action: A, options?: O & NoInfer<
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
*/
|
|
88
|
-
readonly client?: Options$2;
|
|
89
|
-
};
|
|
90
|
-
/** A remote aggregate command's options, its actions `A`. */
|
|
91
|
-
export type RemoteOptions<A extends Any = Any> = Options$1<A> & {
|
|
92
|
-
/**
|
|
93
|
-
* Every subcommand's client options, as `ActionHttp.client` takes them: `baseUrl`, and
|
|
94
|
-
* `transformClient` for credentials. They reach this aggregate's requests alone. A URL or a
|
|
95
|
-
* token read from `Config` goes instead into the `HttpClient` provided on the command, with
|
|
96
|
-
* `Command.provideEffect(HttpClient.HttpClient, …)`: it is read when a command runs, so
|
|
97
|
-
* `--help` needs neither, and a command of your own in `subcommands` calls through it too.
|
|
98
|
-
* A required one that is not set fails that Effect with a `ConfigError`, which is no
|
|
99
|
-
* command's failure and prints as a crash line: map the read,
|
|
100
|
-
* `Effect.mapError((cause) => new CliError.UserError({ cause, userMessage: "TOKEN is not set" }))`.
|
|
101
|
-
*/
|
|
102
|
-
readonly client?: Options$2;
|
|
82
|
+
export declare function remoteCommand<const H extends AnyHttp, A extends H["actions"][number], const O = {}, C extends ClientOf<H> | undefined = undefined>(binding: H, action: A, options?: O & NoInfer<Options<A>> & NoInfer<Known<O, RemoteKeys<Options<Any>>>> & {
|
|
83
|
+
readonly client?: C;
|
|
84
|
+
}): Command.Command<string, never, {}, Failing<CallFailure<H, C, A>>, CallServices<C, A>>;
|
|
85
|
+
type ClientOf<H extends AnyHttp> = Effect.Effect<Client<H, unknown, unknown>, unknown, unknown>;
|
|
86
|
+
type RemoteKeys<Keys> = Keys & {
|
|
87
|
+
readonly client?: unknown;
|
|
103
88
|
};
|
|
104
|
-
type
|
|
89
|
+
type Failing<F> = UserError<Exclude<F, CliError.CliError>> | Extract<F, CliError.CliError>;
|
|
90
|
+
type Called<M, A extends Any> = A extends unknown ? A["name"] extends keyof M ? M[A["name"]] extends ((...input: never) => infer Call) ? Call : never : never : never;
|
|
91
|
+
type CallFailure<H extends AnyHttp, C, A extends Any> = C extends Effect.Effect<infer M, infer CE, unknown> ? CE | Effect.Error<Called<M, A>> : Effect.Error<Called<Client<H>, A>>;
|
|
92
|
+
type CallServices<C, A extends Any> = C extends Effect.Effect<infer M, unknown, infer CR> ? CR | Effect.Services<Called<M, A>> : HttpClient.HttpClient;
|
|
105
93
|
/**
|
|
106
94
|
* Project every implemented action as a subcommand of one aggregate command, each named after
|
|
107
95
|
* its action in kebab case and run in process. `commands` gives a subcommand the options
|
|
@@ -113,16 +101,17 @@ type HttpUserError<H extends AnyHttp, A extends Any> = UserError<A["error"][numb
|
|
|
113
101
|
*/
|
|
114
102
|
export declare function make<const Apps extends Served, const O extends Selection<ActionOf<Member<Apps>>> = {}, const Own extends Subcommands = []>(implementations: Apps, options: O & NoInfer<Options$1<ActionOf<Member<Apps>>>> & NoInfer<KnownOptions<O, Options$1, ActionOf<Member<Apps>>>> & {
|
|
115
103
|
readonly subcommands?: Own | undefined;
|
|
116
|
-
}): Command.Command<string, {}, {},
|
|
104
|
+
}): Command.Command<string, {}, {}, Failing<Effect.Error<Local<Holding<Member<Apps>, SelectedOf<O, Apps>>, SelectedOf<O, Apps>>>> | Command.Error<OwnCommand<Own>>, Effect.Services<Local<Holding<Member<Apps>, SelectedOf<O, Apps>>, SelectedOf<O, Apps>>> | Command.Services<OwnCommand<Own>>>;
|
|
117
105
|
/**
|
|
118
106
|
* Project every action of an HTTP binding as a subcommand of one aggregate command, derived as
|
|
119
|
-
* `make` derives it, each calling its action
|
|
120
|
-
*
|
|
121
|
-
*
|
|
107
|
+
* `make` derives it, each calling its action through `client`'s method, as `remoteCommand`
|
|
108
|
+
* does. `commands` gives one the options `command` takes; `subcommands` adds commands of your
|
|
109
|
+
* own, which call through the same client by yielding the Effect given as `client`.
|
|
122
110
|
*/
|
|
123
|
-
export declare function remote<const H extends AnyHttp, const O extends Selection<H["actions"][number]> = {}, const Own extends Subcommands = []>(binding: H, options: O & NoInfer<
|
|
111
|
+
export declare function remote<const H extends AnyHttp, const O extends Selection<H["actions"][number]> = {}, const Own extends Subcommands = [], C extends ClientOf<H> | undefined = undefined>(binding: H, options: O & NoInfer<Options$1<H["actions"][number]>> & NoInfer<KnownOptions<O, RemoteKeys<Options$1<Any>>, H["actions"][number]>> & {
|
|
112
|
+
readonly client?: C;
|
|
124
113
|
readonly subcommands?: Own | undefined;
|
|
125
|
-
}): Command.Command<string, {}, {},
|
|
114
|
+
}): Command.Command<string, {}, {}, Failing<CallFailure<H, C, Selected<O, H["actions"][number]>>> | Command.Error<OwnCommand<Own>>, CallServices<C, Selected<O, H["actions"][number]>> | Command.Services<OwnCommand<Own>>>;
|
|
126
115
|
/**
|
|
127
116
|
* A program whose stdout carries only its results, such as a CLI feeding scripts or an MCP
|
|
128
117
|
* subprocess, applied last, before `NodeRuntime.runMain`: the default logger of every layer
|
package/dist/ActionCli.mjs
CHANGED
|
@@ -17,7 +17,7 @@ import { Command } from "effect/cli";
|
|
|
17
17
|
* `Effect.tapError(() => Console.error(…))`: a `UserError` wrapped around the result would
|
|
18
18
|
* replace its JSON.
|
|
19
19
|
*/
|
|
20
|
-
const toUserError = (calls) => "actions" in calls ? failingAsDerived(calls.actions
|
|
20
|
+
const toUserError = (calls) => "actions" in calls ? failingAsDerived(calls.actions.flatMap((action) => bindingErrors(action, calls))) : failingAsDerived(calls.flatMap((action) => projectedErrors(action)));
|
|
21
21
|
const runWithBuildOfItsOwn = (app, action, input) => {
|
|
22
22
|
const thisActionOnly = Implementation.share([action], app);
|
|
23
23
|
return Effect.flatMap(acquire([thisActionOnly]), (bound) => {
|
|
@@ -37,7 +37,7 @@ const inProcess = (apps, action, options) => {
|
|
|
37
37
|
};
|
|
38
38
|
const overHttp = (binding, action, options, client$1) => {
|
|
39
39
|
if (!binding.actions.includes(action)) throw new Error(`Action "${action.name}" is not in this HTTP binding`);
|
|
40
|
-
return command$1(action, (input) => Effect.flatMap(client(binding
|
|
40
|
+
return command$1(action, (input) => Effect.flatMap(client$1 ?? client(binding), (methods) => {
|
|
41
41
|
const method = methods[action.name];
|
|
42
42
|
if (method === void 0) throw new Error(`No client method for ${action.name}`);
|
|
43
43
|
return method(input);
|
|
@@ -69,17 +69,12 @@ const checkedCommandsOf = (commands, actions) => {
|
|
|
69
69
|
* as JSON when it is not a struct. The flag of an array of strings or numbers is repeated for each value, a boolean
|
|
70
70
|
* field is a switch, `--no-<name>` sending `false` as a required one left out does, and a field's `description` is its flag's help. It needs what the authorization, its handler and its builder
|
|
71
71
|
* need; the host provides the identity. It prints the result on stdout; a failure is a
|
|
72
|
-
* `UserError`,
|
|
72
|
+
* `UserError`, or an Effect CLI error as it is, such as a builder's `CliError.UserError`, which
|
|
73
|
+
* `Command.run` prints on stderr. `remoteCommand` calls a server instead.
|
|
73
74
|
*/
|
|
74
75
|
function command(implementations, action, options) {
|
|
75
76
|
return inProcess(toList(implementations), action, options);
|
|
76
77
|
}
|
|
77
|
-
/**
|
|
78
|
-
* Project one action of an HTTP binding into a native Effect CLI command, derived as `command`
|
|
79
|
-
* derives it, that calls the action over HTTP through its `ActionHttp.client` method, made with
|
|
80
|
-
* the `client` options on the host's `HttpClient`, and fails as the method does. It runs
|
|
81
|
-
* nothing locally.
|
|
82
|
-
*/
|
|
83
78
|
function remoteCommand(binding, action, options) {
|
|
84
79
|
const { client, ...syntax } = options ?? {};
|
|
85
80
|
return overHttp(binding, action, options === void 0 ? void 0 : syntax, client);
|
|
@@ -99,12 +94,6 @@ function make(implementations, options) {
|
|
|
99
94
|
const served = select(apps, options.actions);
|
|
100
95
|
return aggregateCommand(options.name, served.flatMap((app) => app.actions), commands, options.subcommands ?? [], (action, syntax) => inProcess(served, action, syntax));
|
|
101
96
|
}
|
|
102
|
-
/**
|
|
103
|
-
* Project every action of an HTTP binding as a subcommand of one aggregate command, derived as
|
|
104
|
-
* `make` derives it, each calling its action over HTTP. `client` configures every derived
|
|
105
|
-
* subcommand's client; `commands` gives one the options `command` takes; `subcommands` adds
|
|
106
|
-
* commands of your own.
|
|
107
|
-
*/
|
|
108
97
|
function remote(binding, options) {
|
|
109
98
|
const listed = options.actions;
|
|
110
99
|
if (listed !== void 0) assertHeld("the binding does not hold it", listed, binding.actions);
|
package/dist/ActionHttp.d.mts
CHANGED
|
@@ -6,11 +6,11 @@ import { Any as Any$1, DescriptorOf, Identity, Matching, PublicOnly, RemoteReque
|
|
|
6
6
|
import { Certain, ErrorsOf } from "./contract/declared.mjs";
|
|
7
7
|
import { AnyHttp, Client, MethodError, Options } from "./http/client.mjs";
|
|
8
8
|
import { Context, Effect, FileSystem, Layer, Path, Schema, Types } from "effect";
|
|
9
|
-
import { Etag, HttpClient, HttpPlatform, HttpRouter } from "effect/http";
|
|
9
|
+
import { Etag, HttpClient, HttpClientError, HttpPlatform, HttpRouter } from "effect/http";
|
|
10
10
|
import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware } from "effect/http-api";
|
|
11
11
|
//#region src/http/ActionHttp.d.ts
|
|
12
12
|
/** What `fetchClient` takes: `client`'s options, and the `fetch` its calls send with. */
|
|
13
|
-
export interface FetchClientOptions extends Options {
|
|
13
|
+
export interface FetchClientOptions<E = HttpClientError.HttpClientError, R = never> extends Options<E, R> {
|
|
14
14
|
/**
|
|
15
15
|
* What each call sends with. Omitted: the global `fetch`, looked up on every call, so one
|
|
16
16
|
* installed after the client is built, such as a test's stub, is used.
|
|
@@ -116,18 +116,21 @@ export declare function layer<const H extends AnyHttp, const Apps extends Served
|
|
|
116
116
|
* `InvalidInput`, `Unauthenticated` or `Forbidden`), a native `HttpClientError` when the
|
|
117
117
|
* server could not be reached or answered with a status or body the contract does not
|
|
118
118
|
* declare, or a `SchemaError` when the success does not decode. An input that does not encode
|
|
119
|
-
* fails with `InvalidInput`, and nothing is sent.
|
|
119
|
+
* fails with `InvalidInput`, and nothing is sent. A call also fails with what the client
|
|
120
|
+
* `transformClient` returns fails with, as it is, and requires what that client requires.
|
|
120
121
|
*/
|
|
121
122
|
export declare function client<const H extends AnyHttp>(binding: H, options?: Options): Effect.Effect<Client<H>, never, HttpClient.HttpClient>;
|
|
123
|
+
export declare function client<const H extends AnyHttp, E, R>(binding: H, options: Options<E, R>): Effect.Effect<Client<H, E, R>, never, HttpClient.HttpClient>;
|
|
122
124
|
/**
|
|
123
125
|
* `client` built once over `fetch`, for code that holds a client outside an Effect, such as
|
|
124
|
-
* a browser app: its methods are the same Effects
|
|
125
|
-
* alone, as `Effect.runPromise(api.greet({ name }))`. The
|
|
126
|
-
* `fetch`.
|
|
126
|
+
* a browser app: its methods are the same Effects, which require only what `transformClient`
|
|
127
|
+
* requires, so a call without one runs alone, as `Effect.runPromise(api.greet({ name }))`. The
|
|
128
|
+
* options are `client`'s, and `fetch`.
|
|
127
129
|
*
|
|
128
130
|
* Each call sends with `options.fetch`, or with the global `fetch` as it is when the call
|
|
129
131
|
* runs.
|
|
130
132
|
*/
|
|
131
133
|
export declare function fetchClient<const H extends AnyHttp>(binding: H, options?: FetchClientOptions): Client<H>;
|
|
134
|
+
export declare function fetchClient<const H extends AnyHttp, E, R>(binding: H, options: FetchClientOptions<E, R>): Client<H, E, R>;
|
|
132
135
|
//#endregion
|
|
133
136
|
export { type AnyHttp as Any, type Client, type Options as ClientOptions, type MethodError, Options$1 as Options };
|
package/dist/cli/commands.d.mts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import "../contract/errors.mjs";
|
|
2
2
|
import { Any } from "../Action.mjs";
|
|
3
|
-
import { Effect, Runtime, Types } from "effect";
|
|
3
|
+
import { Effect, Option, Runtime, Types } from "effect";
|
|
4
4
|
import { CliError, Command } from "effect/cli";
|
|
5
5
|
//#region src/cli/commands.d.ts
|
|
6
6
|
type Field<A extends Any> = A["input"]["Encoded"] extends (infer E) ? true extends Types.IsUnion<E> ? never : E extends ReadonlyArray<unknown> ? never : E extends object ? string extends keyof E ? never : Extract<keyof E, string> : never : never;
|
package/dist/cli/commands.mjs
CHANGED
|
@@ -206,8 +206,8 @@ const userErrorOf = (errors) => {
|
|
|
206
206
|
userMessage
|
|
207
207
|
}))));
|
|
208
208
|
};
|
|
209
|
-
const failingAsDerived = (
|
|
210
|
-
const failure = userErrorOf([...new Set(
|
|
209
|
+
const failingAsDerived = (errors) => {
|
|
210
|
+
const failure = userErrorOf([...new Set(errors)]);
|
|
211
211
|
return (self) => Effect.catchIf(self, (error) => !CliError.isCliError(error), failure);
|
|
212
212
|
};
|
|
213
213
|
const unreadableStdinField = (field, message) => new InvalidInput({
|
|
@@ -274,7 +274,7 @@ const command = (action, execute, options, errors) => {
|
|
|
274
274
|
const name = options?.name ?? kebab(action.name);
|
|
275
275
|
const render = options?.render;
|
|
276
276
|
const { flags, positional, decode, stdinHelp } = inputConfig(action, options?.positional ?? [], options?.aliases ?? {}, options?.stdin);
|
|
277
|
-
const
|
|
277
|
+
const failing = failingAsDerived(errors);
|
|
278
278
|
assertDistinct("flag", [
|
|
279
279
|
...Object.keys(flags).map((field) => [`--${kebab(field)}`, `field ${field}`]),
|
|
280
280
|
...render === void 0 ? [] : [["--json", "render's --json"]],
|
|
@@ -285,7 +285,7 @@ const command = (action, execute, options, errors) => {
|
|
|
285
285
|
...input,
|
|
286
286
|
...Object.fromEntries(positional.map(({ field }, index) => [field, values[index] ?? Option.none()]))
|
|
287
287
|
});
|
|
288
|
-
const run = (parsed, rendered) => decode(parsed).pipe(Effect.flatMap(execute),
|
|
288
|
+
const run = (parsed, rendered) => decode(parsed).pipe(Effect.flatMap(execute), failing, Effect.flatMap((value) => printedOutput(action, value, rendered)), withStderrConsole, Effect.flatMap(Option.match({
|
|
289
289
|
onNone: () => Effect.void,
|
|
290
290
|
onSome: Console.log
|
|
291
291
|
})));
|
package/dist/http/client.d.mts
CHANGED
|
@@ -7,11 +7,19 @@ import { HttpClient, HttpClientError } from "effect/http";
|
|
|
7
7
|
import { HttpApi, HttpApiClient } from "effect/http-api";
|
|
8
8
|
//#region src/http/client.d.ts
|
|
9
9
|
/**
|
|
10
|
-
* The native `HttpApiClient.make` options except `transformResponse
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* The native `HttpApiClient.make` options except `transformResponse`, which may change a call's
|
|
11
|
+
* success, failure or services the method types cannot follow: `baseUrl`, and `transformClient`,
|
|
12
|
+
* which may return a client failing with `E` or requiring `R` of its own, as
|
|
13
|
+
* `HttpApiClient.makeWith` takes one.
|
|
13
14
|
*/
|
|
14
|
-
export
|
|
15
|
+
export interface Options<E = HttpClientError.HttpClientError, R = never> extends Pick<NonNullable<Parameters<typeof HttpApiClient.make>[1]>, "baseUrl"> {
|
|
16
|
+
/**
|
|
17
|
+
* Wraps the native `HttpClient` the calls send with: a bearer token is
|
|
18
|
+
* `HttpClient.mapRequest(HttpClientRequest.bearerToken(token))`. A method also fails with what
|
|
19
|
+
* the returned client fails with beyond an `HttpClientError`, and requires what it requires.
|
|
20
|
+
*/
|
|
21
|
+
readonly transformClient?: ((client: HttpClient.HttpClient) => HttpClient.HttpClient.With<E, R>) | undefined;
|
|
22
|
+
}
|
|
15
23
|
type BindingError = Errors[number];
|
|
16
24
|
/**
|
|
17
25
|
* What one call of `A` fails with: a declared error value (the action's own, one `E` of the
|
|
@@ -20,7 +28,7 @@ type BindingError = Errors[number];
|
|
|
20
28
|
* the success does not decode. Input that does not encode is `InvalidInput`, and nothing is sent.
|
|
21
29
|
*/
|
|
22
30
|
export type MethodError<A extends Any, E extends BindingError = never> = A["error"][number]["Type"] | E["Type"] | BuiltIns["Type"] | HttpClientError.HttpClientError | Schema.SchemaError;
|
|
23
|
-
type Method<A extends Any, E extends BindingError> = Call<A, Effect.Effect<A["success"]["Type"], MethodError<A, E
|
|
31
|
+
type Method<A extends Any, E extends BindingError, CE, CR> = Call<A, Effect.Effect<A["success"]["Type"], MethodError<A, E> | CE, CR>>;
|
|
24
32
|
/**
|
|
25
33
|
* Any HTTP binding, with its actions erased: what `layer` and `client` read. The API is a native
|
|
26
34
|
* constraint here because native groups are invariant in their endpoints; `actions` carries the
|
|
@@ -35,8 +43,9 @@ export interface AnyHttp {
|
|
|
35
43
|
}
|
|
36
44
|
/**
|
|
37
45
|
* Every action of the binding `H`, as `client.<action>(input)`: a protected one may also fail
|
|
38
|
-
* with what its descriptor's verifier declares.
|
|
46
|
+
* with what its descriptor's verifier declares. Each call also fails with `E` and requires `R`,
|
|
47
|
+
* what its `transformClient` adds.
|
|
39
48
|
*/
|
|
40
|
-
export type Client<H extends AnyHttp> = { readonly [A in H["actions"][number] as A["name"]]: Method<A, H["error"][number] | VerifierError<A, H["authentication"]
|
|
49
|
+
export type Client<H extends AnyHttp, E = never, R = never> = { readonly [A in H["actions"][number] as A["name"]]: Method<A, H["error"][number] | VerifierError<A, H["authentication"]>, E, R>; };
|
|
41
50
|
//#endregion
|
|
42
51
|
export {};
|
package/dist/http/client.mjs
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import { checked } from "../contract/call.mjs";
|
|
2
2
|
import { Effect } from "effect";
|
|
3
|
+
import { HttpClient } from "effect/http";
|
|
3
4
|
import { HttpApiClient } from "effect/http-api";
|
|
4
5
|
//#region src/http/client.ts
|
|
5
|
-
const
|
|
6
|
+
const methodsWith = (http, httpClient, baseUrl) => Effect.map(HttpApiClient.makeWith(http.api, {
|
|
7
|
+
baseUrl,
|
|
8
|
+
httpClient
|
|
9
|
+
}), (client) => {
|
|
6
10
|
const native = client;
|
|
7
11
|
return Object.fromEntries(http.actions.map((action) => {
|
|
8
12
|
const method = native[action.name];
|
|
@@ -10,5 +14,6 @@ const methods = (http, options = {}) => Effect.map(HttpApiClient.make(http.api,
|
|
|
10
14
|
return [action.name, checked(action, (payload) => method({ payload }))];
|
|
11
15
|
}));
|
|
12
16
|
});
|
|
17
|
+
const methods = (http, { baseUrl, transformClient } = {}) => Effect.flatMap(HttpClient.HttpClient, (native) => transformClient === void 0 ? methodsWith(http, native, baseUrl) : methodsWith(http, transformClient(native), baseUrl));
|
|
13
18
|
//#endregion
|
|
14
19
|
export { methods };
|
package/dist/mcp/client.d.mts
CHANGED
|
@@ -16,8 +16,11 @@ export interface Params {
|
|
|
16
16
|
};
|
|
17
17
|
readonly [key: string]: Schema.Json | undefined;
|
|
18
18
|
}
|
|
19
|
-
/**
|
|
20
|
-
|
|
19
|
+
/**
|
|
20
|
+
* Where `client` sends, through what client, and what the endpoint's authentication declares: the
|
|
21
|
+
* client `transformClient` returns may fail with `E` and require `R`.
|
|
22
|
+
*/
|
|
23
|
+
export interface ClientOptions<D extends Any$1 | undefined = Any$1 | undefined, E = HttpClientError.HttpClientError, R = never> {
|
|
21
24
|
/**
|
|
22
25
|
* The endpoint, resolved by the `HttpClient`: relative under `Testing.layer`. Defaults to `/mcp`,
|
|
23
26
|
* the default `layerHttp` path.
|
|
@@ -27,7 +30,7 @@ export interface ClientOptions<D extends Any$1 | undefined = Any$1 | undefined>
|
|
|
27
30
|
* Wraps the native `HttpClient`, as `ActionHttp.client` takes it: a bearer token is
|
|
28
31
|
* `HttpClient.mapRequest(HttpClientRequest.bearerToken(token))`.
|
|
29
32
|
*/
|
|
30
|
-
readonly transformClient?: (client: HttpClient.HttpClient) => HttpClient.HttpClient
|
|
33
|
+
readonly transformClient?: (client: HttpClient.HttpClient) => HttpClient.HttpClient.With<E, R>;
|
|
31
34
|
/**
|
|
32
35
|
* The endpoint's descriptor, as `layerHttp` takes it: an error its verifier declares, the
|
|
33
36
|
* endpoint's HTTP answer for a protected tool, is then that error's decoded value, as over
|
|
@@ -89,8 +92,11 @@ declare const McpCallError_base: Schema.Class<McpCallError, Schema.TaggedStruct<
|
|
|
89
92
|
*/
|
|
90
93
|
export declare class McpCallError extends McpCallError_base {}
|
|
91
94
|
type CallError<A extends Any, D> = A["error"][number]["Type"] | VerifierError<A, D>["Type"] | BuiltIn | Schema.SchemaError | HttpClientError.HttpClientError | McpCallError;
|
|
92
|
-
/**
|
|
93
|
-
|
|
95
|
+
/**
|
|
96
|
+
* Every action of `Actions` as `client.<action>(input)`, calling its tool. Each call also fails
|
|
97
|
+
* with `E` and requires `R`, what its `transformClient` adds.
|
|
98
|
+
*/
|
|
99
|
+
export type Client<Actions extends ReadonlyArray<Any>, D = undefined, E = never, R = never> = { readonly [A in Actions[number] as A["name"]]: Call<A, Effect.Effect<A["success"]["Type"], CallError<A, D> | E, R>>; };
|
|
94
100
|
/**
|
|
95
101
|
* A client of an MCP endpoint served by `layerHttp`, one method per action calling
|
|
96
102
|
* its tool with one stateless request, as `ActionHttp.client` calls routes: the input is
|
|
@@ -103,6 +109,7 @@ export type Client<Actions extends ReadonlyArray<Any>, D = undefined> = { readon
|
|
|
103
109
|
* native `HttpClient`, such as the one `Testing.layer` provides.
|
|
104
110
|
*/
|
|
105
111
|
export declare function client<const Actions extends ReadonlyArray<Any>, D extends Any$1 | undefined = undefined>(actions: Actions, options?: ClientOptions<D>): Effect.Effect<Client<Actions, D>, never, HttpClient.HttpClient>;
|
|
112
|
+
export declare function client<const Actions extends ReadonlyArray<Any>, E, R, D extends Any$1 | undefined = undefined>(actions: Actions, options: ClientOptions<D, E, R>): Effect.Effect<Client<Actions, D, E, R>, never, HttpClient.HttpClient>;
|
|
106
113
|
/**
|
|
107
114
|
* One stateless MCP request of `method` with `params`, as `layerHttp` serves it,
|
|
108
115
|
* as the native request value: the JSON-RPC envelope, the 2026-07-28 headers (`Accept`,
|
package/dist/mcp/client.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import { assertOnce } from "../contract/names.mjs";
|
|
|
4
4
|
import { projectedErrors } from "../contract/declared.mjs";
|
|
5
5
|
import { checked } from "../contract/call.mjs";
|
|
6
6
|
import { defaultPath, httpProtocol, liftOf, unliftedFrom } from "./protocol.mjs";
|
|
7
|
-
import { Effect, Match, Option, Predicate, Schema
|
|
7
|
+
import { Effect, Match, Option, Predicate, Schema } from "effect";
|
|
8
8
|
import { McpSchema } from "effect/ai";
|
|
9
9
|
import { HttpClient, HttpClientRequest } from "effect/http";
|
|
10
10
|
import { Sse } from "effect/encoding";
|
|
@@ -134,10 +134,10 @@ const callTool = (client, url, action, lift, refusedWith, input) => {
|
|
|
134
134
|
return yield* Schema.decodeUnknownEffect(Schema.toCodecJson(action.success))(unliftedFrom(lift, result));
|
|
135
135
|
});
|
|
136
136
|
};
|
|
137
|
-
function client(actions, { url = defaultPath, transformClient
|
|
137
|
+
function client(actions, { url = defaultPath, transformClient, authentication } = {}) {
|
|
138
138
|
assertOnce("action", actions);
|
|
139
139
|
return Effect.map(HttpClient.HttpClient, (native) => {
|
|
140
|
-
const http = transformClient(native);
|
|
140
|
+
const http = transformClient === void 0 ? native : transformClient(native);
|
|
141
141
|
return Object.fromEntries(actions.map((action) => {
|
|
142
142
|
const lift = liftOf(action.success);
|
|
143
143
|
const refusedWith = [...Refusal.members, ...action.caller === Anyone ? [] : authentication?.error ?? []];
|
|
@@ -63,7 +63,10 @@ The tasks are in [ActionCli.md](ActionCli.md).
|
|
|
63
63
|
`client`. To choose by a condition, build a command from each:
|
|
64
64
|
`remotely ? ActionCli.remote(Http, options) : ActionCli.make(users, options)`.
|
|
65
65
|
- `Type 'HttpClient' is not assignable to type 'never'` in the pipe that runs a remote command:
|
|
66
|
-
provide `NodeHttpClient.layerUndici` (or `FetchHttpClient.layer`).
|
|
66
|
+
provide `NodeHttpClient.layerUndici` (or `FetchHttpClient.layer`). Another service named there
|
|
67
|
+
is what its `client` requires: provide it too.
|
|
68
|
+
- An argument of type `Binding<…>` not assignable at `remote` or `remoteCommand`, beside a
|
|
69
|
+
`client`: the client is another binding's. Make it with `ActionHttp.client` of this one.
|
|
67
70
|
- A local command requires an identity service no handler yields: its action is protected, and
|
|
68
71
|
requires the identity its contract declares. Provide it on the command
|
|
69
72
|
([Name the caller](ActionCli.md#name-the-caller)); do not make the contract public to satisfy
|
|
@@ -79,20 +82,18 @@ The tasks are in [ActionCli.md](ActionCli.md).
|
|
|
79
82
|
- `--help` or a mistyped flag connects to a database, or fails reading config: services provided
|
|
80
83
|
around `Command.run` are built before parsing. Provide them on the command,
|
|
81
84
|
`Command.provide(layer)`.
|
|
82
|
-
- `
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
there. Where every command needs it, keep it required and map the failure, in the Effect that
|
|
87
|
-
provides the client, to `new CliError.UserError({ cause, userMessage })`:
|
|
85
|
+
- `ConfigError: …` or another error's `Tag: message` on stderr from a remote command, a public
|
|
86
|
+
one too: its `client` failed, as when a required token is not set. Read an optional one with
|
|
87
|
+
`Config.option` and add the header only when it is there. Where every command needs it, map
|
|
88
|
+
the failure to a tagged error whose `message` says what to do:
|
|
88
89
|
[notes-service/src/commands.ts](../examples/notes-service/src/commands.ts).
|
|
89
90
|
- `HttpClientError` whose `reason._tag` is `InvalidUrlError`, from a remote command: its client
|
|
90
91
|
has no `baseUrl` and prepends no URL.
|
|
91
92
|
- 401 `Unauthenticated` from a remote command: a remote command adds no headers of its own. Add
|
|
92
|
-
credentials in
|
|
93
|
+
credentials in its client's `transformClient`.
|
|
93
94
|
- Another command's request goes to the API's URL, or carries its token: an `HttpClient`
|
|
94
95
|
configured around the whole program rewrites every request the program makes. Configure it in
|
|
95
|
-
the `client` option
|
|
96
|
+
the `client` option.
|
|
96
97
|
- A report with a stack on stdout, `ERROR (#1): ...`, instead of JSON on stderr: a defect, or a
|
|
97
98
|
failure of a layer the host provides, which `runMain` reports. Fix the defect; apply
|
|
98
99
|
`ActionCli.logToStderr` to keep such reports off stdout.
|
package/docs/ActionCli.md
CHANGED
|
@@ -36,10 +36,10 @@ Import `@gjermundgaraba/effect-actions/ActionCli`.
|
|
|
36
36
|
| `make`, `remote`: `actions` | The actions that are subcommands; default: every one. |
|
|
37
37
|
| `make`, `remote`: `commands` | Each subcommand's `command` options, by action name: `{ getNote: { render } }`. |
|
|
38
38
|
| `make`, `remote`: `subcommands` | Commands of your own beside the derived ones: `[exportNotes]`. |
|
|
39
|
-
| `remoteCommand`, `remote`: `client` | The `ActionHttp.client
|
|
39
|
+
| `remoteCommand`, `remote`: `client` | The client its commands call: `ActionHttp.client(Http, { baseUrl })`. |
|
|
40
40
|
|
|
41
|
-
Exported types: `Options<A
|
|
42
|
-
and `UserError<E>`, a type only: what a command fails with when its action fails with `E`.
|
|
41
|
+
Exported types: `Options<A>` of `make` and `remote`, `CommandOptions<A>` of `command` and
|
|
42
|
+
`remoteCommand`, and `UserError<E>`, a type only: what a command fails with when its action fails with `E`.
|
|
43
43
|
|
|
44
44
|
## Run actions as local commands
|
|
45
45
|
|
|
@@ -81,21 +81,22 @@ Run it with Effect's own `Command.run`:
|
|
|
81
81
|
Pass the HTTP binding to `remote`. The program imports the binding alone, never a handler; each
|
|
82
82
|
command calls its action through the binding's `ActionHttp.client` method
|
|
83
83
|
([ActionHttp.md](ActionHttp.md#call-the-api-from-effect)) and fails as that method does. The host
|
|
84
|
-
provides `HttpClient` (`NodeHttpClient.layerUndici`)
|
|
85
|
-
[notes-service/src/
|
|
86
|
-
[notes-service/src/
|
|
84
|
+
provides `HttpClient` (`NodeHttpClient.layerUndici`) in
|
|
85
|
+
[notes-service/src/cli.ts](../examples/notes-service/src/cli.ts); the client says where the
|
|
86
|
+
server is in [notes-service/src/commands.ts](../examples/notes-service/src/commands.ts).
|
|
87
87
|
[kitchen-sink/src/cli.ts](../examples/kitchen-sink/src/cli.ts) adds a tenant header and uses
|
|
88
88
|
every command option.
|
|
89
89
|
|
|
90
|
-
- **
|
|
91
|
-
`client: { baseUrl, transformClient
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
90
|
+
- **The connection is the `client` option**, an Effect of the binding's client run each time a
|
|
91
|
+
command runs: `client: ActionHttp.client(Http, { baseUrl, transformClient })`. One reading
|
|
92
|
+
`Config` is an `Effect.gen` returning `yield* ActionHttp.client(…)`, so `--help` reads nothing.
|
|
93
|
+
Its failures and its methods' are the command's, printed as `Tag: message`, a
|
|
94
|
+
`CliError.UserError` as its `userMessage`; what they require, the host provides. Credentials go in `transformClient`
|
|
95
|
+
([ActionHttp.md](ActionHttp.md#call-the-api-from-effect)).
|
|
96
|
+
- **Never transform the `HttpClient` around the program**: it rewrites every request the program
|
|
97
|
+
makes, an absolute URL too, and sends each one the token.
|
|
98
|
+
- **A token every command needs is required, its failure mapped to a tagged error whose `message`
|
|
99
|
+
says what to do**, or a missing one prints as `ConfigError: …`: `api` in
|
|
99
100
|
[notes-service/src/commands.ts](../examples/notes-service/src/commands.ts). Where a public
|
|
100
101
|
command must answer signed out, read it with `Config.option` and add the header when it is
|
|
101
102
|
there: [kitchen-sink/src/cli.ts](../examples/kitchen-sink/src/cli.ts).
|
|
@@ -156,15 +157,15 @@ in `subcommands`: `notes export <file>` takes `Argument.String("file")`
|
|
|
156
157
|
([notes-service/src/commands.ts](../examples/notes-service/src/commands.ts)):
|
|
157
158
|
|
|
158
159
|
```ts
|
|
159
|
-
const notes = ActionCli.remote(Http, { name: "notes", subcommands: [exportNotes] });
|
|
160
|
+
const notes = ActionCli.remote(Http, { name: "notes", client: api, subcommands: [exportNotes] });
|
|
160
161
|
```
|
|
161
162
|
|
|
162
163
|
- **Never pipe `Command.withSubcommands` onto `make` or `remote`.** It replaces the derived
|
|
163
164
|
commands, which then answer with the usage text.
|
|
164
|
-
- **Call the actions through a client**, in the command's own handler: `
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
`authorize` still runs.
|
|
165
|
+
- **Call the actions through a client**, in the command's own handler: `yield* api`, the Effect
|
|
166
|
+
the aggregate's `client` is, which needs no scope, or `Action.client`, acquired inside
|
|
167
|
+
`Effect.scoped` ([Action-in-process.md](Action-in-process.md#call-actions-in-process)).
|
|
168
|
+
`authorize` still runs.
|
|
168
169
|
- **Its output is what it prints**, with `Console.log`: the output rules below are a derived
|
|
169
170
|
command's. **Pipe its whole Effect through `ActionCli.toUserError(Http)`**, the binding its
|
|
170
171
|
client is made from, HTTP or RPC, or `toUserError([GetNote])` over `Action.client`, to fail as a derived command does: a declared
|
|
@@ -53,6 +53,12 @@ The tasks are in [ActionHttp.md](ActionHttp.md).
|
|
|
53
53
|
the binding's `error`, given inline so its type is certain, or move the check to the handlers
|
|
54
54
|
and declare the error on their actions.
|
|
55
55
|
|
|
56
|
+
- `No overload matches this call` at `client`, naming `Options<unknown, unknown>`: options built
|
|
57
|
+
before the call hold the generic `HttpClient.mapRequestEffect(authorize)`, whose failures the
|
|
58
|
+
call cannot infer, or a helper types them as `Parameters<typeof ActionHttp.client>[1]`. Write
|
|
59
|
+
the transform as `(client) => HttpClient.mapRequestEffect(client, authorize)`, and type such
|
|
60
|
+
options as `ActionHttp.ClientOptions<E, R>`.
|
|
61
|
+
|
|
56
62
|
## Requests
|
|
57
63
|
|
|
58
64
|
- A route answers without credentials: its contract is `caller: Action.Anyone`.
|
package/docs/ActionHttp.md
CHANGED
|
@@ -36,7 +36,7 @@ Exported types: `Binding`; `Any`, any binding; `Client<typeof Http>`, a client's
|
|
|
36
36
|
| `layer`: `actions` | The bound actions it serves: `[GetUser]`. Defaults to every one the implementations hold. |
|
|
37
37
|
| `layer`: `middleware` | Native `HttpApiMiddleware` around every route it serves, the first innermost. |
|
|
38
38
|
| `client`: `baseUrl` | What routes resolve against: `https://api.example.com`. Omitted: relative ones, a page's origin. |
|
|
39
|
-
| `client`: `transformClient` | Wraps the native `HttpClient`:
|
|
39
|
+
| `client`: `transformClient` | Wraps the native `HttpClient`: a token, or a sign-in per request, whose failures each call adds. |
|
|
40
40
|
| `fetchClient`: `fetch` | Its `fetch`, beside `client`'s options. Omitted: the global `fetch`, looked up on every call. |
|
|
41
41
|
|
|
42
42
|
Each action is served at `POST <prefix>/<action>`, a `readOnly` one too. One without `success`
|
|
@@ -185,9 +185,12 @@ Files: [quickstart/src/client.ts](../examples/quickstart/src/client.ts),
|
|
|
185
185
|
[kitchen-sink/src/seed.ts](../examples/kitchen-sink/src/seed.ts) (`baseUrl` from `Config`,
|
|
186
186
|
headers, errors by tag).
|
|
187
187
|
|
|
188
|
-
- Outside a browser, set `baseUrl`. A token or any other header goes through `transformClient
|
|
189
|
-
|
|
190
|
-
|
|
188
|
+
- Outside a browser, set `baseUrl`. A token or any other header goes through `transformClient`:
|
|
189
|
+
`HttpClient.mapRequest(HttpClientRequest.bearerToken(token))`. A credential read or refreshed
|
|
190
|
+
as each request is sent, which may fail, is
|
|
191
|
+
`(client) => HttpClient.mapRequestEffect(client, authorize)`: each call then also fails with
|
|
192
|
+
`authorize`'s own errors, as they are, and requires its services. `ActionMcp.client` takes the
|
|
193
|
+
same transform, and `ActionCli.remote` the client itself as its `client`.
|
|
191
194
|
- Leave the argument out when `{}` is a valid input. A given argument is the decoded input, so
|
|
192
195
|
it states a defaulted field.
|
|
193
196
|
- A declared error is a typed failure, as its decoded value: the action's own, the binding's,
|
|
@@ -215,6 +218,8 @@ with `Effect.runPromise`. Files:
|
|
|
215
218
|
- A page on another origin passes `baseUrl`, and the server must allow that origin with
|
|
216
219
|
`HttpRouter.cors`
|
|
217
220
|
([kitchen-sink/src/request-policy.ts](../examples/kitchen-sink/src/request-policy.ts), `cors`).
|
|
221
|
+
- A `transformClient` requiring a service, such as the session's token, makes each call require
|
|
222
|
+
it: provide it on the call before `Effect.runPromise`.
|
|
218
223
|
- An unhandled declared error rejects the promise as its decoded value, so
|
|
219
224
|
`error instanceof Action.Unauthenticated` works in a `catch`. Handle an expected one by its tag
|
|
220
225
|
before `Effect.runPromise`.
|
package/docs/ActionMcp.md
CHANGED
|
@@ -44,7 +44,7 @@ options. Its `protocols` and `allowSessionTermination` are not options.
|
|
|
44
44
|
|
|
45
45
|
`client` takes `url`, default `/mcp`, `transformClient`, and the endpoint's `authentication`, so an
|
|
46
46
|
error its verifier declares decodes; `request` takes `url` and `headers`.
|
|
47
|
-
Types: `Client<Actions, D>`, `ClientOptions`, `RequestOptions`, `Params`.
|
|
47
|
+
Types: `Client<Actions, D, E, R>`, `ClientOptions`, `RequestOptions`, `Params`.
|
|
48
48
|
|
|
49
49
|
## Serve tools over HTTP
|
|
50
50
|
|
|
@@ -3,6 +3,7 @@ import { Config, Effect, flow, Option } from "effect";
|
|
|
3
3
|
import { Command } from "effect/cli";
|
|
4
4
|
import { HttpClient, HttpClientRequest } from "effect/http";
|
|
5
5
|
import * as ActionCli from "@gjermundgaraba/effect-actions/ActionCli";
|
|
6
|
+
import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
|
|
6
7
|
import { Http } from "./Api.js";
|
|
7
8
|
|
|
8
9
|
// Entrypoint: `node --import tsx examples/kitchen-sink/src/cli.ts list-issues`.
|
|
@@ -41,27 +42,24 @@ const tracker = ActionCli.remote(Http, {
|
|
|
41
42
|
`${attachment.name}: ${image.mimeType}, ${image.data.byteLength} bytes`,
|
|
42
43
|
},
|
|
43
44
|
},
|
|
44
|
-
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const url = yield* Config.String("TRACKER_URL").pipe(
|
|
53
|
-
Config.withDefault("http://127.0.0.1:3000"),
|
|
54
|
-
);
|
|
45
|
+
// The connection, read from `Config` when a command runs, so `--help` needs none of it. It is
|
|
46
|
+
// these commands' client, so it reaches their requests and no others. The token and the
|
|
47
|
+
// workspace are optional: `tracker get-status` is public and needs neither, and any other
|
|
48
|
+
// command without them gets the server's 401.
|
|
49
|
+
client: Effect.gen(function* () {
|
|
50
|
+
const baseUrl = yield* Config.String("TRACKER_URL").pipe(
|
|
51
|
+
Config.withDefault("http://127.0.0.1:3000"),
|
|
52
|
+
);
|
|
55
53
|
|
|
56
|
-
|
|
57
|
-
|
|
54
|
+
const token = yield* Config.option(Config.Redacted("TRACKER_TOKEN"));
|
|
55
|
+
const workspace = yield* Config.option(Config.String("TRACKER_WORKSPACE"));
|
|
58
56
|
|
|
59
|
-
|
|
57
|
+
const unchanged = (request: HttpClientRequest.HttpClientRequest) => request;
|
|
60
58
|
|
|
61
|
-
|
|
62
|
-
|
|
59
|
+
return yield* ActionHttp.client(Http, {
|
|
60
|
+
baseUrl,
|
|
61
|
+
transformClient: HttpClient.mapRequest(
|
|
63
62
|
flow(
|
|
64
|
-
HttpClientRequest.prependUrl(url),
|
|
65
63
|
Option.match(token, {
|
|
66
64
|
onNone: () => unchanged,
|
|
67
65
|
onSome: (value) => HttpClientRequest.bearerToken(value),
|
|
@@ -71,10 +69,10 @@ const tracker = ActionCli.remote(Http, {
|
|
|
71
69
|
onSome: (slug) => HttpClientRequest.setHeader("x-workspace", slug),
|
|
72
70
|
}),
|
|
73
71
|
),
|
|
74
|
-
)
|
|
75
|
-
})
|
|
76
|
-
),
|
|
77
|
-
);
|
|
72
|
+
),
|
|
73
|
+
});
|
|
74
|
+
}),
|
|
75
|
+
});
|
|
78
76
|
|
|
79
77
|
// A result is printed on stdout. A failure goes to stderr, under an `ERROR` heading, as the JSON
|
|
80
78
|
// HTTP sends, such as `{"_tag":"IssueNotFound","id":"ACME-9"}`, and the command exits 1.
|
|
@@ -104,7 +104,7 @@ src/
|
|
|
104
104
|
├── worker.ts entrypoint: a fetch handler for a serverless host
|
|
105
105
|
├── stdio.ts entrypoint: MCP for a local agent
|
|
106
106
|
├── commands.ts the commands calling the server, one written by hand, and their connection
|
|
107
|
-
├── cli.ts entrypoint: the commands
|
|
107
|
+
├── cli.ts entrypoint: the commands
|
|
108
108
|
└── browser.ts client module: promises for a web page
|
|
109
109
|
test/
|
|
110
110
|
├── notes/ in process, with Action.client
|
|
@@ -144,7 +144,7 @@ test/
|
|
|
144
144
|
| A CLI derived from the binding: an argument, a field on stdin | `src/commands.ts` |
|
|
145
145
|
| A command written by hand beside the derived ones | `src/commands.ts` |
|
|
146
146
|
| A bulk import as one action, its command reading a file | `src/notes/`, `src/commands.ts` |
|
|
147
|
-
| The CLI's connection, read from config when a command runs | `src/commands.ts
|
|
147
|
+
| The CLI's connection, read from config when a command runs | `src/commands.ts` |
|
|
148
148
|
| A missing token as one message, not a `ConfigError` report | `src/commands.ts` |
|
|
149
149
|
| A promise client handling each declared error by its tag | `src/browser.ts` |
|
|
150
150
|
| Testing handlers and authorization with no transport | `test/notes/notes.test.ts` |
|
|
@@ -1,21 +1,15 @@
|
|
|
1
1
|
import { NodeHttpClient, NodeRuntime, NodeServices } from "@effect/platform-node";
|
|
2
2
|
import { Effect } from "effect";
|
|
3
3
|
import { Command } from "effect/cli";
|
|
4
|
-
import { HttpClient } from "effect/http";
|
|
5
4
|
import * as ActionCli from "@gjermundgaraba/effect-actions/ActionCli";
|
|
6
|
-
import { commands
|
|
5
|
+
import { commands } from "./commands.js";
|
|
7
6
|
|
|
8
7
|
// Entrypoint: `node --import tsx examples/notes-service/src/cli.ts list-notes`.
|
|
9
|
-
// The commands of commands.ts,
|
|
10
|
-
const notes = commands.pipe(
|
|
11
|
-
// Provided on the command, never around `Command.run`: the URL and the token are then read
|
|
12
|
-
// when a command runs, so `--help` and a mistyped flag need neither.
|
|
13
|
-
Command.provideEffect(HttpClient.HttpClient, connection),
|
|
14
|
-
);
|
|
8
|
+
// The commands of commands.ts, which read where the server is and the token when one runs.
|
|
15
9
|
|
|
16
10
|
// A result is printed on stdout. A failure goes to stderr, under an `ERROR` heading, as the JSON
|
|
17
11
|
// HTTP sends, such as `{"_tag":"NoteNotFound","id":"9"}`, and the command exits 1.
|
|
18
|
-
Command.run(
|
|
12
|
+
Command.run(commands, { version: "1.0.0" }).pipe(
|
|
19
13
|
Effect.provide([NodeHttpClient.layerUndici, NodeServices.layer]),
|
|
20
14
|
ActionCli.logToStderr,
|
|
21
15
|
NodeRuntime.runMain,
|
|
@@ -1,10 +1,44 @@
|
|
|
1
|
-
import { Config, Console, Effect, FileSystem,
|
|
1
|
+
import { Config, Console, Effect, FileSystem, Schema } from "effect";
|
|
2
2
|
import { Argument, CliError, Command } from "effect/cli";
|
|
3
3
|
import { HttpClient, HttpClientRequest } from "effect/http";
|
|
4
4
|
import * as ActionCli from "@gjermundgaraba/effect-actions/ActionCli";
|
|
5
5
|
import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
|
|
6
6
|
import { Http } from "./Api.js";
|
|
7
7
|
|
|
8
|
+
// What a command fails with when no token is configured: a tagged error of the CLI's own, printed
|
|
9
|
+
// on stderr as `MissingToken: <message>` under an `ERROR` heading, and exit 1.
|
|
10
|
+
class MissingToken extends Schema.TaggedError<MissingToken>()("MissingToken", {
|
|
11
|
+
message: Schema.String,
|
|
12
|
+
}) {}
|
|
13
|
+
|
|
14
|
+
// The binding's typed client, connected to where the server is and as who is calling, both read
|
|
15
|
+
// from config. It is an Effect, not a client: every command here, the derived ones and your own,
|
|
16
|
+
// runs it when it runs, so `--help` and a mistyped flag read nothing. It reaches these commands'
|
|
17
|
+
// requests and no others.
|
|
18
|
+
const api = Effect.gen(function* () {
|
|
19
|
+
const baseUrl = yield* Config.String("NOTES_URL").pipe(
|
|
20
|
+
Config.withDefault("http://127.0.0.1:3000"),
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
// Required, since every command but `get-status` is refused without one. Left as it is, a
|
|
24
|
+
// missing token prints as `ConfigError: …`; mapped, it prints the message, which says what to
|
|
25
|
+
// do. A CLI whose public commands must answer signed out reads the token with `Config.option`
|
|
26
|
+
// instead and adds the header when it is there.
|
|
27
|
+
const token = yield* Config.Redacted("NOTES_TOKEN").pipe(
|
|
28
|
+
Effect.mapError(
|
|
29
|
+
() =>
|
|
30
|
+
new MissingToken({
|
|
31
|
+
message: "NOTES_TOKEN is not set. Set it to your token: export NOTES_TOKEN=<your token>",
|
|
32
|
+
}),
|
|
33
|
+
),
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
return yield* ActionHttp.client(Http, {
|
|
37
|
+
baseUrl,
|
|
38
|
+
transformClient: HttpClient.mapRequest(HttpClientRequest.bearerToken(token)),
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
8
42
|
// The file's format: each note a `# ` title line, a blank line and its body, the notes apart by
|
|
9
43
|
// a blank line. A body line that starts as a title would is written with a backslash before it,
|
|
10
44
|
// as Markdown escapes one, and `import` takes it off, so every note reads back as it was.
|
|
@@ -15,14 +49,14 @@ const unescape = (body: string) => body.replaceAll(/^\\(?=\\*# )/gmu, "");
|
|
|
15
49
|
// A command of your own, for what no single action does: this one calls two and writes a file.
|
|
16
50
|
// It is a native Effect CLI command, so its syntax and its output are what you write: `notes
|
|
17
51
|
// export notes.md` takes its argument from `Argument`, as a flag would come from `Flag`. It
|
|
18
|
-
// calls the server through the
|
|
19
|
-
//
|
|
52
|
+
// calls the server through `api`, the client the derived commands call, and writes with the
|
|
53
|
+
// `FileSystem` every Effect CLI command has.
|
|
20
54
|
const exportNotes = Command.make(
|
|
21
55
|
"export",
|
|
22
56
|
{ file: Argument.String("file").pipe(Argument.withDescription("The Markdown file to write.")) },
|
|
23
57
|
({ file }) =>
|
|
24
58
|
Effect.gen(function* () {
|
|
25
|
-
const client = yield*
|
|
59
|
+
const client = yield* api;
|
|
26
60
|
const files = yield* FileSystem.FileSystem;
|
|
27
61
|
const { notes } = yield* client.listNotes();
|
|
28
62
|
const whole = yield* Effect.forEach(notes, ({ id }) => client.getNote({ id }));
|
|
@@ -63,7 +97,7 @@ const importNotes = Command.make(
|
|
|
63
97
|
},
|
|
64
98
|
({ text }) =>
|
|
65
99
|
Effect.gen(function* () {
|
|
66
|
-
const client = yield*
|
|
100
|
+
const client = yield* api;
|
|
67
101
|
|
|
68
102
|
if (text === "") return yield* Console.log("Created 0 notes");
|
|
69
103
|
|
|
@@ -93,6 +127,7 @@ const importNotes = Command.make(
|
|
|
93
127
|
// it: test/commands.test.ts.
|
|
94
128
|
export const commands = ActionCli.remote(Http, {
|
|
95
129
|
name: "notes",
|
|
130
|
+
client: api,
|
|
96
131
|
commands: {
|
|
97
132
|
// `notes create-note --title Draft < draft.md`: the body is piped in, not a flag.
|
|
98
133
|
createNote: { stdin: "body" },
|
|
@@ -104,34 +139,3 @@ export const commands = ActionCli.remote(Http, {
|
|
|
104
139
|
// go here.
|
|
105
140
|
subcommands: [exportNotes, importNotes],
|
|
106
141
|
});
|
|
107
|
-
|
|
108
|
-
// Where the server is and who is calling, read from config: the `HttpClient` every command
|
|
109
|
-
// above calls with. It is an Effect, not a value, so that cli.ts provides it on the commands
|
|
110
|
-
// and it runs when one of them does.
|
|
111
|
-
export const connection = Effect.gen(function* () {
|
|
112
|
-
const url = yield* Config.String("NOTES_URL").pipe(Config.withDefault("http://127.0.0.1:3000"));
|
|
113
|
-
|
|
114
|
-
// Required, since every command but `get-status` is refused without one. A failure of what a
|
|
115
|
-
// command is provided is no action's error: left as it is, a missing token is reported as a
|
|
116
|
-
// crash, the log line `ERROR (#n): ConfigError: SchemaError(Expected string at
|
|
117
|
-
// ["NOTES_TOKEN"])`. Mapped to a `UserError`, it is printed as any failure of a command is:
|
|
118
|
-
// the message under an `ERROR` heading on stderr, and exit 1. `userMessage` is all that is
|
|
119
|
-
// printed, never `cause`: what the user needs from the cause goes into the message. A CLI
|
|
120
|
-
// whose public commands must answer signed out reads the token with `Config.option` instead
|
|
121
|
-
// and adds the header when it is there.
|
|
122
|
-
const token = yield* Config.Redacted("NOTES_TOKEN").pipe(
|
|
123
|
-
Effect.mapError(
|
|
124
|
-
(cause) =>
|
|
125
|
-
new CliError.UserError({
|
|
126
|
-
cause,
|
|
127
|
-
userMessage:
|
|
128
|
-
"NOTES_TOKEN is not set. Set it to your token: export NOTES_TOKEN=<your token>",
|
|
129
|
-
}),
|
|
130
|
-
),
|
|
131
|
-
);
|
|
132
|
-
|
|
133
|
-
return HttpClient.mapRequest(
|
|
134
|
-
yield* HttpClient.HttpClient,
|
|
135
|
-
flow(HttpClientRequest.prependUrl(url), HttpClientRequest.bearerToken(token)),
|
|
136
|
-
);
|
|
137
|
-
});
|
|
@@ -16,7 +16,7 @@ import { TestConsole } from "effect/testing";
|
|
|
16
16
|
import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
|
|
17
17
|
import * as Testing from "@gjermundgaraba/effect-actions/Testing";
|
|
18
18
|
import { Http } from "../src/Api.js";
|
|
19
|
-
import { commands
|
|
19
|
+
import { commands } from "../src/commands.js";
|
|
20
20
|
import { ImportNotes, Note, NoteNotFound } from "../src/notes/Notes.js";
|
|
21
21
|
import { NoteStore } from "../src/notes/store.js";
|
|
22
22
|
import { routes } from "../src/routes.js";
|
|
@@ -26,17 +26,16 @@ import { routes } from "../src/routes.js";
|
|
|
26
26
|
// service a builder yields is provided here too (routes.test.ts).
|
|
27
27
|
const served = Testing.layer(routes).pipe(Layer.provide(NoteStore.layerMemory));
|
|
28
28
|
|
|
29
|
-
// Effect's own runner, given arguments instead of the process's
|
|
30
|
-
//
|
|
31
|
-
|
|
29
|
+
// Effect's own runner, given arguments instead of the process's, and what the commands read from
|
|
30
|
+
// the environment as a Layer, so the test decides whether a token is configured, whatever the
|
|
31
|
+
// shell holds. The console of `it.effect` keeps every line printed, so a run returns the lines
|
|
32
|
+
// it added.
|
|
33
|
+
const connected = (environment: Record<string, string>, args: ReadonlyArray<string>, stdin = "") =>
|
|
32
34
|
Effect.gen(function* () {
|
|
33
35
|
const before = (yield* TestConsole.logLines).length;
|
|
34
36
|
|
|
35
37
|
yield* Command.runWith(commands, { version: "0" })(args).pipe(
|
|
36
|
-
Effect.
|
|
37
|
-
HttpClient.HttpClient,
|
|
38
|
-
HttpClient.mapRequest(HttpClientRequest.bearerToken(token)),
|
|
39
|
-
),
|
|
38
|
+
Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnvRecord(environment))),
|
|
40
39
|
Effect.provide(Stdio.layerTest({ stdin: Stream.make(new TextEncoder().encode(stdin)) })),
|
|
41
40
|
Effect.provide(NodeServices.layer),
|
|
42
41
|
);
|
|
@@ -44,15 +43,9 @@ const run = (token: string, args: ReadonlyArray<string>, stdin = "") =>
|
|
|
44
43
|
return (yield* TestConsole.logLines).slice(before);
|
|
45
44
|
});
|
|
46
45
|
|
|
47
|
-
//
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
Command.runWith(commands.pipe(Command.provideEffect(HttpClient.HttpClient, connection)), {
|
|
51
|
-
version: "0",
|
|
52
|
-
})(args).pipe(
|
|
53
|
-
Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnvRecord(environment))),
|
|
54
|
-
Effect.provide(NodeServices.layer),
|
|
55
|
-
);
|
|
46
|
+
// A run as the caller `token` names.
|
|
47
|
+
const run = (token: string, args: ReadonlyArray<string>, stdin = "") =>
|
|
48
|
+
connected({ NOTES_TOKEN: token }, args, stdin);
|
|
56
49
|
|
|
57
50
|
const fitsAFile = (text: string) =>
|
|
58
51
|
new TextDecoder().decode(new TextEncoder().encode(text)) === text;
|
|
@@ -270,9 +263,10 @@ describe("the commands", () => {
|
|
|
270
263
|
const refused = yield* Effect.flip(connected({}, ["list-notes"]));
|
|
271
264
|
|
|
272
265
|
expect(refused).toBeInstanceOf(CliError.UserError);
|
|
266
|
+
expect(refused).toHaveProperty("cause._tag", "MissingToken");
|
|
273
267
|
expect(refused).toHaveProperty(
|
|
274
268
|
"userMessage",
|
|
275
|
-
"NOTES_TOKEN is not set. Set it to your token: export NOTES_TOKEN=<your token>",
|
|
269
|
+
"MissingToken: NOTES_TOKEN is not set. Set it to your token: export NOTES_TOKEN=<your token>",
|
|
276
270
|
);
|
|
277
271
|
|
|
278
272
|
// What a person sees: that message, once, on stderr, and no report of a `ConfigError`.
|
|
@@ -280,7 +274,7 @@ describe("the commands", () => {
|
|
|
280
274
|
expect.stringContaining("NOTES_TOKEN is not set."),
|
|
281
275
|
]);
|
|
282
276
|
|
|
283
|
-
// The token is read when a command runs, so
|
|
277
|
+
// The token is read when a command runs, so help needs none.
|
|
284
278
|
yield* connected({}, ["list-notes", "--help"]);
|
|
285
279
|
|
|
286
280
|
expect(yield* TestConsole.logLines).toHaveLength(2);
|
package/package.json
CHANGED