@gjermundgaraba/effect-actions 0.18.2 → 0.19.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 +20 -47
- package/dist/Action.d.mts +96 -47
- package/dist/Action.mjs +11 -14
- package/dist/ActionCli.d.mts +81 -42
- package/dist/ActionCli.mjs +52 -33
- package/dist/ActionHttp.d.mts +84 -43
- package/dist/ActionHttp.mjs +2 -2
- package/dist/ActionMcp.d.mts +54 -27
- package/dist/ActionMcp.mjs +5 -5
- package/dist/ActionRpc.d.mts +48 -35
- package/dist/ActionRpc.mjs +1 -1
- package/dist/ActionToolkit.d.mts +32 -13
- package/dist/ActionToolkit.mjs +10 -7
- package/dist/Authentication.d.mts +92 -40
- package/dist/Authentication.mjs +37 -35
- package/dist/Testing.d.mts +22 -24
- package/dist/authentication/provider.d.mts +1 -4
- package/dist/authentication/provider.mjs +1 -1
- package/dist/authentication/refusal.mjs +2 -1
- package/dist/cli/commands.d.mts +34 -11
- package/dist/cli/commands.mjs +47 -22
- package/dist/contract/caller.mjs +1 -1
- package/dist/contract/declared.mjs +27 -14
- package/dist/contract/errors.d.mts +42 -17
- package/dist/contract/errors.mjs +42 -17
- package/dist/contract/hints.mjs +3 -2
- package/dist/contract/implementation.d.mts +5 -8
- package/dist/contract/implementation.mjs +29 -17
- package/dist/contract/media.d.mts +6 -2
- package/dist/contract/media.mjs +6 -2
- package/dist/contract/names.mjs +14 -12
- package/dist/contract/status.mjs +3 -10
- package/dist/http/client.d.mts +15 -4
- package/dist/http/client.mjs +1 -1
- package/dist/mcp/client.d.mts +21 -8
- package/dist/mcp/client.mjs +6 -8
- package/docs/Action-failures.md +6 -15
- package/docs/Action.md +159 -191
- package/docs/ActionCli-failures.md +12 -92
- package/docs/ActionCli.md +102 -156
- package/docs/ActionHttp-failures.md +14 -76
- package/docs/ActionHttp.md +150 -176
- package/docs/ActionMcp-failures.md +2 -22
- package/docs/ActionMcp.md +79 -170
- package/docs/ActionRpc-failures.md +3 -12
- package/docs/ActionRpc.md +183 -120
- package/docs/ActionToolkit-failures.md +3 -10
- package/docs/ActionToolkit.md +112 -109
- package/docs/Authentication-advanced.md +167 -108
- package/docs/Authentication-failures.md +11 -91
- package/docs/Authentication.md +114 -135
- package/docs/README.md +45 -30
- package/docs/Testing-failures.md +2 -6
- package/docs/Testing.md +126 -159
- package/docs/setup-failures.md +8 -32
- package/docs/setup.md +42 -37
- package/examples/README.md +6 -10
- package/examples/assistant/README.md +20 -38
- package/examples/assistant/src/agent.ts +2 -3
- package/examples/assistant/src/cli.ts +4 -9
- package/examples/assistant/src/commands.ts +6 -4
- package/examples/assistant/src/identity/authorize.ts +3 -3
- package/examples/assistant/src/tasks/Tasks.ts +1 -2
- package/examples/assistant/src/tasks/handlers.ts +3 -3
- package/examples/assistant/src/toolkit.ts +6 -8
- package/examples/assistant/test/commands.test.ts +16 -11
- package/examples/assistant/test/tasks/tasks.test.ts +0 -2
- package/examples/assistant/test/toolkit.test.ts +0 -1
- package/examples/kitchen-sink/README.md +30 -52
- package/examples/kitchen-sink/src/Actions.ts +2 -3
- package/examples/kitchen-sink/src/Api.ts +3 -4
- package/examples/kitchen-sink/src/admin/Admin.ts +1 -1
- package/examples/kitchen-sink/src/admin/authenticate.ts +3 -3
- package/examples/kitchen-sink/src/admin/authorize.ts +1 -2
- package/examples/kitchen-sink/src/agent.ts +1 -1
- package/examples/kitchen-sink/src/assistant/handlers.ts +1 -6
- package/examples/kitchen-sink/src/attachments/Attachments.ts +4 -8
- package/examples/kitchen-sink/src/attachments/download.ts +5 -8
- package/examples/kitchen-sink/src/attachments/handlers.ts +2 -3
- package/examples/kitchen-sink/src/browser.ts +3 -4
- package/examples/kitchen-sink/src/cli.ts +6 -7
- package/examples/kitchen-sink/src/config.ts +2 -0
- package/examples/kitchen-sink/src/identity/Identity.ts +7 -7
- package/examples/kitchen-sink/src/identity/authenticate.ts +3 -5
- package/examples/kitchen-sink/src/identity/authorize.ts +4 -3
- package/examples/kitchen-sink/src/identity/quota.ts +1 -1
- package/examples/kitchen-sink/src/issues/Issues.ts +2 -2
- package/examples/kitchen-sink/src/issues/assistant.ts +3 -3
- package/examples/kitchen-sink/src/issues/guide.ts +2 -3
- package/examples/kitchen-sink/src/ops.ts +3 -6
- package/examples/kitchen-sink/src/request-policy.ts +3 -3
- package/examples/kitchen-sink/src/routes.ts +4 -6
- package/examples/kitchen-sink/src/seed.ts +0 -1
- package/examples/kitchen-sink/src/server.ts +11 -12
- package/examples/kitchen-sink/src/stdio.ts +3 -2
- package/examples/kitchen-sink/src/workspaces/tenant.ts +3 -1
- package/examples/kitchen-sink/test/admin/admin.test.ts +0 -1
- package/examples/kitchen-sink/test/assistant/assistant.test.ts +1 -1
- package/examples/kitchen-sink/test/attachments/attachments.test.ts +1 -2
- package/examples/kitchen-sink/test/identity/identity.test.ts +0 -1
- package/examples/kitchen-sink/test/issues/issues.test.ts +0 -2
- package/examples/kitchen-sink/test/request-policy.test.ts +1 -1
- package/examples/kitchen-sink/test/routes.test.ts +3 -3
- package/examples/live-rpc/README.md +8 -49
- package/examples/live-rpc/src/browser.ts +8 -41
- package/examples/live-rpc/src/chat/Chat.ts +9 -3
- package/examples/live-rpc/src/chat/room.ts +1 -1
- package/examples/live-rpc/src/client.ts +2 -3
- package/examples/live-rpc/src/page.ts +34 -0
- package/examples/live-rpc/src/routes.ts +6 -7
- package/examples/live-rpc/src/server.ts +6 -4
- package/examples/live-rpc/test/browser.test.ts +6 -5
- package/examples/live-rpc/test/routes.test.ts +16 -15
- package/examples/live-rpc/test/server.test.ts +1 -1
- package/examples/mcp-tools/README.md +22 -45
- package/examples/mcp-tools/src/printers/Printers.ts +3 -7
- package/examples/mcp-tools/src/routes.ts +0 -7
- package/examples/mcp-tools/src/server.ts +1 -1
- package/examples/mcp-tools/test/printers/printers.test.ts +2 -3
- package/examples/mcp-tools/test/routes.test.ts +2 -3
- package/examples/notes-service/README.md +9 -43
- package/examples/notes-service/src/cli.ts +0 -3
- package/examples/notes-service/src/commands.ts +7 -17
- package/examples/notes-service/src/identity/authorize.ts +2 -5
- package/examples/notes-service/src/identity/verifier.ts +3 -4
- package/examples/notes-service/src/notes/Notes.ts +2 -6
- package/examples/notes-service/src/notes/store.ts +5 -1
- package/examples/notes-service/src/routes.ts +6 -8
- package/examples/notes-service/src/server.ts +4 -3
- package/examples/notes-service/src/stdio.ts +4 -3
- package/examples/notes-service/test/commands.test.ts +1 -1
- package/examples/notes-service/test/notes/as.ts +6 -0
- package/examples/notes-service/test/notes/idempotency.test.ts +1 -4
- package/examples/notes-service/test/notes/in-process.test.ts +50 -0
- package/examples/notes-service/test/notes/notes.test.ts +3 -42
- package/examples/notes-service/test/routes.test.ts +2 -2
- package/examples/quickstart/README.md +1 -1
- package/examples/quickstart/src/client.ts +1 -0
- package/examples/quickstart/src/server.ts +2 -4
- package/examples/quickstart/test/{greeting/greeting.test.ts → routes.test.ts} +3 -3
- package/package.json +1 -1
- package/docs/Action-in-process.md +0 -93
- package/docs/CONTEXT.md +0 -46
- package/docs/guarantees.md +0 -44
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Effect, Layer } from "effect";
|
|
2
2
|
import { RpcClient, RpcSerialization } from "effect/rpc";
|
|
3
3
|
import { Socket } from "effect/socket";
|
|
4
4
|
import * as ActionRpc from "@gjermundgaraba/effect-actions/ActionRpc";
|
|
@@ -9,18 +9,16 @@ import { Rpc } from "./Rpc.js";
|
|
|
9
9
|
// bundler gets the contracts and Effect's RPC client, no handler and nothing of Node.
|
|
10
10
|
|
|
11
11
|
// Effect's own client protocol: one WebSocket, speaking the server's JSON. This layer owns
|
|
12
|
-
// the socket
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
// own, and client.ts provides Node's.
|
|
12
|
+
// the socket, so a page provides it once, around its whole program, and every call shares
|
|
13
|
+
// that one socket. Which WebSocket is left to the host: a page provides
|
|
14
|
+
// `Socket.layerWebSocketConstructorGlobal`, the browser's own, and client.ts provides Node's.
|
|
16
15
|
export const connection = (url: string) =>
|
|
17
16
|
RpcClient.layerProtocolSocket().pipe(
|
|
18
17
|
Layer.provide(Socket.layerWebSocket(url)),
|
|
19
18
|
Layer.provide(RpcSerialization.layerJson),
|
|
20
19
|
);
|
|
21
20
|
|
|
22
|
-
// The chat as a page uses it, on the connection provided around it.
|
|
23
|
-
// only the typed client built on that connection, not the socket.
|
|
21
|
+
// The chat as a page uses it, on the connection provided around it.
|
|
24
22
|
export const openChat = Effect.gen(function* () {
|
|
25
23
|
// One typed method per action of the binding, all on the one connection.
|
|
26
24
|
const client = yield* ActionRpc.client(Rpc);
|
|
@@ -30,14 +28,13 @@ export const openChat = Effect.gen(function* () {
|
|
|
30
28
|
rooms: Effect.map(client.listRooms(), ({ rooms }) => rooms),
|
|
31
29
|
|
|
32
30
|
room: (name: string, token: string) => {
|
|
33
|
-
// A browser WebSocket
|
|
34
|
-
// message, and the room beside it. A refreshed token needs no new socket.
|
|
31
|
+
// A browser WebSocket cannot set an `Authorization` header on its upgrade, so the token
|
|
32
|
+
// travels on each message, and the room beside it. A refreshed token needs no new socket.
|
|
35
33
|
const here = RpcClient.withHeaders({ authorization: `Bearer ${token}`, [roomHeader]: name });
|
|
36
34
|
|
|
37
35
|
return {
|
|
38
36
|
// Called again and again while the page is open, with the last id it holds. A token
|
|
39
|
-
// may expire
|
|
40
|
-
// a typed failure matched by its tag, and only that call fails: the socket stays open.
|
|
37
|
+
// may expire while the page stays open, so each refusal becomes what the page shows.
|
|
41
38
|
read: (after: number) =>
|
|
42
39
|
client.readMessages({ after }).pipe(
|
|
43
40
|
here,
|
|
@@ -66,33 +63,3 @@ export const openChat = Effect.gen(function* () {
|
|
|
66
63
|
},
|
|
67
64
|
};
|
|
68
65
|
});
|
|
69
|
-
|
|
70
|
-
// The chat for a page that is not one Effect program: the same client, each call a promise.
|
|
71
|
-
// A `ManagedRuntime` keeps the protocol's layer, and so the socket, between calls. It builds
|
|
72
|
-
// the layer on the first call, which is when the socket opens, and `close` releases it. A
|
|
73
|
-
// failure `openChat` does not handle, such as `InvalidInput`, rejects the promise.
|
|
74
|
-
class Chat extends Context.Service<Chat, Effect.Success<typeof openChat>>()("live-rpc/Chat") {}
|
|
75
|
-
|
|
76
|
-
export const chatOver = <E>(protocol: Layer.Layer<RpcClient.Protocol, E>) => {
|
|
77
|
-
const runtime = ManagedRuntime.make(Layer.effect(Chat, openChat).pipe(Layer.provide(protocol)));
|
|
78
|
-
|
|
79
|
-
const call = <A, E2>(use: (chat: Chat["Service"]) => Effect.Effect<A, E2>) =>
|
|
80
|
-
runtime.runPromise(Effect.flatMap(Chat, use));
|
|
81
|
-
|
|
82
|
-
return {
|
|
83
|
-
rooms: () => call((chat) => chat.rooms),
|
|
84
|
-
|
|
85
|
-
room: (name: string, token: string) => ({
|
|
86
|
-
read: (after: number) => call((chat) => chat.room(name, token).read(after)),
|
|
87
|
-
post: (text: string) => call((chat) => chat.room(name, token).post(text)),
|
|
88
|
-
remove: (id: number) => call((chat) => chat.room(name, token).remove(id)),
|
|
89
|
-
}),
|
|
90
|
-
|
|
91
|
-
close: () => runtime.dispose(),
|
|
92
|
-
};
|
|
93
|
-
};
|
|
94
|
-
|
|
95
|
-
// What a page calls: `const chat = connect("wss://chat.example/rpc")`, on the browser's own
|
|
96
|
-
// WebSocket, then `await chat.room("lobby", token).post("Hello")`.
|
|
97
|
-
export const connect = (url: string) =>
|
|
98
|
-
chatOver(connection(url).pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)));
|
|
@@ -30,13 +30,19 @@ export class NotAuthor extends Schema.TaggedError<NotAuthor>()(
|
|
|
30
30
|
) {}
|
|
31
31
|
|
|
32
32
|
// Which room a call is about is not an input of any action: it travels beside the call, in
|
|
33
|
-
// this header, as the token does
|
|
34
|
-
//
|
|
33
|
+
// this header, as the token does, because `authorize` sees the action and not its input, and
|
|
34
|
+
// the membership rule needs the room. With the room in the input, that check belongs in the
|
|
35
|
+
// store, as `MessageStore.remove` takes the author and refuses with `NotAuthor`, never in a
|
|
36
|
+
// helper each handler must remember to call. The client sets the header; on the server,
|
|
37
|
+
// room.ts reads it. A call without it is about the default room, so `listRooms`, which is
|
|
38
|
+
// about none, sends none; yours may refuse such a call instead.
|
|
35
39
|
export const roomHeader = "x-room";
|
|
36
40
|
|
|
37
41
|
export const defaultRoom = "lobby";
|
|
38
42
|
|
|
39
|
-
// Public: `caller: Action.Anyone
|
|
43
|
+
// Public: `caller: Action.Anyone`, so a page shows the rooms before anyone has signed in. It
|
|
44
|
+
// tells a signed-out caller every room's name and how many messages it holds, a room appearing
|
|
45
|
+
// once someone posts: decide whether yours may.
|
|
40
46
|
export const ListRooms = Action.make("listRooms", {
|
|
41
47
|
description: "List the rooms someone has posted in, and how many messages each holds.",
|
|
42
48
|
success: {
|
|
@@ -9,7 +9,7 @@ import { defaultRoom, roomHeader } from "./Chat.js";
|
|
|
9
9
|
export class CurrentRoom extends Context.Service<CurrentRoom, string>()("live-rpc/CurrentRoom") {}
|
|
10
10
|
|
|
11
11
|
// Native Effect RPC middleware, declared as providing `CurrentRoom`. Listed in
|
|
12
|
-
// `ActionRpc.layer`'s `middleware`, it runs
|
|
12
|
+
// `ActionRpc.layer`'s `middleware`, it runs once per message.
|
|
13
13
|
export class ResolveRoom extends RpcMiddleware.Service<ResolveRoom, { provides: CurrentRoom }>()(
|
|
14
14
|
"live-rpc/ResolveRoom",
|
|
15
15
|
) {}
|
|
@@ -7,7 +7,7 @@ import { connection, openChat } from "./browser.js";
|
|
|
7
7
|
const visit = Effect.gen(function* () {
|
|
8
8
|
const chat = yield* openChat;
|
|
9
9
|
|
|
10
|
-
// Two members on the one socket
|
|
10
|
+
// Two members on the one socket.
|
|
11
11
|
const ada = chat.room("lobby", "ada");
|
|
12
12
|
const grace = chat.room("lobby", "grace");
|
|
13
13
|
|
|
@@ -26,8 +26,7 @@ const visit = Effect.gen(function* () {
|
|
|
26
26
|
const main = Effect.gen(function* () {
|
|
27
27
|
const url = yield* Config.String("CHAT_URL").pipe(Config.withDefault("ws://127.0.0.1:3000/rpc"));
|
|
28
28
|
|
|
29
|
-
//
|
|
30
|
-
// for it and closes when this `Effect.provide` ends. `Effect.scoped` holds the client alone.
|
|
29
|
+
// One socket for the whole visit, so both members' calls share it.
|
|
31
30
|
yield* visit.pipe(
|
|
32
31
|
Effect.scoped,
|
|
33
32
|
Effect.provide(connection(url).pipe(Layer.provide(NodeSocket.layerWebSocketConstructor))),
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Context, Effect, Layer, ManagedRuntime } from "effect";
|
|
2
|
+
import { RpcClient } from "effect/rpc";
|
|
3
|
+
import { Socket } from "effect/socket";
|
|
4
|
+
import { connection, openChat } from "./browser.js";
|
|
5
|
+
|
|
6
|
+
// The chat for a page that is not one Effect program: the same client, each call a promise.
|
|
7
|
+
// A `ManagedRuntime` keeps the protocol's layer, and so the socket, between calls. It builds
|
|
8
|
+
// the layer on the first call, which is when the socket opens, and `close` releases it. A
|
|
9
|
+
// failure `openChat` does not handle, such as `InvalidInput`, rejects the promise.
|
|
10
|
+
class Chat extends Context.Service<Chat, Effect.Success<typeof openChat>>()("live-rpc/Chat") {}
|
|
11
|
+
|
|
12
|
+
export const chatOver = <E>(protocol: Layer.Layer<RpcClient.Protocol, E>) => {
|
|
13
|
+
const runtime = ManagedRuntime.make(Layer.effect(Chat, openChat).pipe(Layer.provide(protocol)));
|
|
14
|
+
|
|
15
|
+
const call = <A, E2>(use: (chat: Chat["Service"]) => Effect.Effect<A, E2>) =>
|
|
16
|
+
runtime.runPromise(Effect.flatMap(Chat, use));
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
rooms: () => call((chat) => chat.rooms),
|
|
20
|
+
|
|
21
|
+
room: (name: string, token: string) => ({
|
|
22
|
+
read: (after: number) => call((chat) => chat.room(name, token).read(after)),
|
|
23
|
+
post: (text: string) => call((chat) => chat.room(name, token).post(text)),
|
|
24
|
+
remove: (id: number) => call((chat) => chat.room(name, token).remove(id)),
|
|
25
|
+
}),
|
|
26
|
+
|
|
27
|
+
close: () => runtime.dispose(),
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// What a page calls: `const chat = connect("wss://chat.example/rpc")`, on the browser's own
|
|
32
|
+
// WebSocket, then `await chat.room("lobby", token).post("Hello")`.
|
|
33
|
+
export const connect = (url: string) =>
|
|
34
|
+
chatOver(connection(url).pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)));
|
|
@@ -5,14 +5,13 @@ import { ResolveRoom, resolveRoom } from "./chat/room.js";
|
|
|
5
5
|
import { authenticate } from "./identity/verifier.js";
|
|
6
6
|
import { Rpc } from "./Rpc.js";
|
|
7
7
|
|
|
8
|
-
// The binding's rpcs on Effect's own RpcServer
|
|
9
|
-
//
|
|
10
|
-
// token.
|
|
8
|
+
// The binding's rpcs on Effect's own RpcServer, the protected ones behind `Login`'s verifier;
|
|
9
|
+
// `listRooms` needs no token.
|
|
11
10
|
//
|
|
12
|
-
// `
|
|
13
|
-
// `
|
|
14
|
-
//
|
|
15
|
-
//
|
|
11
|
+
// `ResolveRoom`, listed in `middleware`, provides `CurrentRoom`, which the handlers and
|
|
12
|
+
// `authorize` read. Only something that runs per message can, since each message names its own
|
|
13
|
+
// room: `Layer.succeed(CurrentRoom, "lobby")` provided here would leave the layer still
|
|
14
|
+
// requiring it, in its type.
|
|
16
15
|
//
|
|
17
16
|
// It still requires three things, each chosen by whoever serves it. The protocol and its
|
|
18
17
|
// serialization: server.ts gives it a WebSocket, and a test Effect's HTTP protocol, which
|
|
@@ -7,12 +7,12 @@ import { MessageStore } from "./chat/store.js";
|
|
|
7
7
|
import { rpc } from "./routes.js";
|
|
8
8
|
|
|
9
9
|
// Entrypoint: `node --import tsx examples/live-rpc/src/server.ts`.
|
|
10
|
-
//
|
|
11
|
-
//
|
|
10
|
+
// It chooses the protocol, the store and the port, and starts Node's HTTP server. It provides
|
|
11
|
+
// no caller and no room: those arrive with each message.
|
|
12
12
|
|
|
13
13
|
// The rpcs over a WebSocket at `/rpc`, the router's one route: Node's HTTP server answers the
|
|
14
|
-
// upgrade.
|
|
15
|
-
//
|
|
14
|
+
// upgrade. JSON, because a page's client speaks it: browser.ts gives its socket the same
|
|
15
|
+
// serialization.
|
|
16
16
|
const socket = rpc.pipe(
|
|
17
17
|
Layer.provide(RpcServer.layerProtocolWebsocket({ path: "/rpc" })),
|
|
18
18
|
Layer.provide(RpcSerialization.layerJson),
|
|
@@ -35,6 +35,8 @@ const server = Layer.unwrap(
|
|
|
35
35
|
websocket: { maxPayload: 64 * 1024 },
|
|
36
36
|
}),
|
|
37
37
|
),
|
|
38
|
+
// DEMO ONLY: messages live in this process's memory; a deployment provides a store that
|
|
39
|
+
// lasts.
|
|
38
40
|
Layer.provide(MessageStore.layerMemory),
|
|
39
41
|
);
|
|
40
42
|
}),
|
|
@@ -3,13 +3,14 @@ import { Effect, Layer } from "effect";
|
|
|
3
3
|
import { RpcClient, RpcSerialization, RpcServer } from "effect/rpc";
|
|
4
4
|
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
5
5
|
import * as Testing from "@gjermundgaraba/effect-actions/Testing";
|
|
6
|
-
import {
|
|
6
|
+
import { openChat } from "../src/browser.js";
|
|
7
|
+
import { chatOver } from "../src/page.js";
|
|
7
8
|
import { MessageStore } from "../src/chat/store.js";
|
|
8
9
|
import { rpc } from "../src/routes.js";
|
|
9
10
|
|
|
10
|
-
// The page's client against the real rpcs, in memory. `openChat`
|
|
11
|
-
// does not care which: a page gives it `connection(url)`, a
|
|
12
|
-
//
|
|
11
|
+
// The page's client against the real rpcs, in memory, served as in routes.test.ts. `openChat`
|
|
12
|
+
// needs a client protocol and does not care which: a page gives it `connection(url)`, a
|
|
13
|
+
// WebSocket, and this test Effect's HTTP protocol.
|
|
13
14
|
const served = RpcClient.layerProtocolHttp({ url: "/rpc" }).pipe(
|
|
14
15
|
Layer.provide(RpcSerialization.layerJson),
|
|
15
16
|
Layer.provide(
|
|
@@ -78,7 +79,7 @@ describe("the page's client", () => {
|
|
|
78
79
|
|
|
79
80
|
// `connect(url)` is this with a WebSocket. Each test's runtime builds its own layer, and so
|
|
80
81
|
// its own store.
|
|
81
|
-
it("answers a page that is not an Effect program with promises,
|
|
82
|
+
it("answers a page that is not an Effect program with promises, until it closes", async () => {
|
|
82
83
|
const chat = chatOver(served);
|
|
83
84
|
|
|
84
85
|
const posted = await chat.room("lobby", "ada").post("Deploying at noon.");
|
|
@@ -10,9 +10,8 @@ import { rpc } from "../src/routes.js";
|
|
|
10
10
|
import { Rpc } from "../src/Rpc.js";
|
|
11
11
|
|
|
12
12
|
// What the wire adds: the real rpcs, behind their real authentication and middleware,
|
|
13
|
-
// answering in memory. The
|
|
14
|
-
//
|
|
15
|
-
// protocol, and its client posts to it. No port is opened.
|
|
13
|
+
// answering in memory. The test gives them Effect's HTTP protocol, not the server's WebSocket,
|
|
14
|
+
// because `Testing.layer` answers HTTP requests; no port is opened.
|
|
16
15
|
const server = rpc.pipe(
|
|
17
16
|
Layer.provide(RpcServer.layerProtocolHttp({ path: "/rpc" })),
|
|
18
17
|
Layer.provide(RpcSerialization.layerJson),
|
|
@@ -30,18 +29,20 @@ const as = (token: string) => RpcClient.withHeaders({ authorization: `Bearer ${t
|
|
|
30
29
|
const inRoom = (room: string) => RpcClient.withHeaders({ [roomHeader]: room });
|
|
31
30
|
|
|
32
31
|
describe("the chat, over RPC", () => {
|
|
33
|
-
it.effect(
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
32
|
+
it.effect(
|
|
33
|
+
"answers the public rpc without a token and refuses a protected rpc without a known token",
|
|
34
|
+
() =>
|
|
35
|
+
Effect.gen(function* () {
|
|
36
|
+
const client = yield* ActionRpc.client(Rpc);
|
|
37
|
+
const signedOut = yield* Effect.flip(client.readMessages({ after: 0 }));
|
|
38
|
+
const stranger = yield* Effect.flip(client.readMessages({ after: 0 }).pipe(as("mallory")));
|
|
39
|
+
|
|
40
|
+
expect(yield* client.listRooms()).toEqual({ rooms: [] });
|
|
41
|
+
expect(signedOut).toBeInstanceOf(Action.Unauthenticated);
|
|
42
|
+
expect(signedOut.message).toBe("A bearer token is required.");
|
|
43
|
+
expect(stranger).toBeInstanceOf(Action.Unauthenticated);
|
|
44
|
+
expect(stranger.message).toBe("Unknown demo token.");
|
|
45
|
+
}).pipe(Effect.provide(served)),
|
|
45
46
|
);
|
|
46
47
|
|
|
47
48
|
it.effect("authenticates each call by its own token: two callers on one client", () =>
|
|
@@ -4,7 +4,7 @@ import { describe, expect, it } from "@effect/vitest";
|
|
|
4
4
|
import { Effect, Layer } from "effect";
|
|
5
5
|
import { HttpRouter, HttpServer } from "effect/http";
|
|
6
6
|
import { RpcSerialization, RpcServer } from "effect/rpc";
|
|
7
|
-
import { connect } from "../src/
|
|
7
|
+
import { connect } from "../src/page.js";
|
|
8
8
|
import { MessageStore } from "../src/chat/store.js";
|
|
9
9
|
import { rpc } from "../src/routes.js";
|
|
10
10
|
|
|
@@ -5,11 +5,6 @@ authentication of its own. It signs in to the farm once, with its own account, a
|
|
|
5
5
|
reach it may call every tool, so it runs as a local subprocess of an agent or on a network only
|
|
6
6
|
trusted clients are on.
|
|
7
7
|
|
|
8
|
-
Start here if you are putting an existing API in front of a model, then read
|
|
9
|
-
[ActionMcp.md](../../docs/ActionMcp.md). For tools that need a token, its "Protect tools" shows
|
|
10
|
-
the verifier, and [notes-service](../notes-service/) is a server whose tools belong to the
|
|
11
|
-
signed-in user.
|
|
12
|
-
|
|
13
8
|
## Run it
|
|
14
9
|
|
|
15
10
|
From the repository root, after `vp install` (from an installed package, see
|
|
@@ -28,8 +23,7 @@ echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":
|
|
|
28
23
|
vp test examples/mcp-tools
|
|
29
24
|
```
|
|
30
25
|
|
|
31
|
-
The farm is a fake, answered in memory, so any URL, user and password work
|
|
32
|
-
real HTTP client before deploying anything. `ALLOWED_ORIGINS` lists the browser origins the HTTP
|
|
26
|
+
The farm is a fake, answered in memory, so any URL, user and password work. `ALLOWED_ORIGINS` lists the browser origins the HTTP
|
|
33
27
|
endpoint accepts and answers with CORS headers, comma-separated; `HOST` is the interface it
|
|
34
28
|
listens on.
|
|
35
29
|
|
|
@@ -80,9 +74,9 @@ curl http://127.0.0.1:3000/mcp \
|
|
|
80
74
|
```
|
|
81
75
|
|
|
82
76
|
It answers `{"jsonrpc":"2.0","id":1,"result":{…,"structuredContent":{"printers":[…]},"isError":false}}`.
|
|
83
|
-
Every header is needed
|
|
84
|
-
|
|
85
|
-
|
|
77
|
+
Every header is needed, as `ActionMcp.request` builds it, and
|
|
78
|
+
[ActionMcp-failures.md](../../docs/ActionMcp-failures.md) lists what a missing one answers.
|
|
79
|
+
`tools/list` takes the same request with that method, no `MCP-Name` header,
|
|
86
80
|
and no `name` or `arguments`.
|
|
87
81
|
|
|
88
82
|
## Layout
|
|
@@ -105,40 +99,23 @@ test/
|
|
|
105
99
|
└── routes.test.ts over MCP in memory, with Testing.layer
|
|
106
100
|
```
|
|
107
101
|
|
|
108
|
-
- **The upstream is a service, built once.** `farm.ts` declares what the handlers need of the
|
|
109
|
-
farm, and `platform/print-farm.ts` is its Layer over the farm's HTTP API: it reads its URL and
|
|
110
|
-
credentials from `Config`, takes whichever `HttpClient` the entrypoint provides, and signs in
|
|
111
|
-
while it is built. The handlers get it already signed in and only delegate: `handlers.ts`
|
|
112
|
-
maps each tool onto a method of `PrintFarm`, which knows nothing of tools or HTTP, so a second
|
|
113
|
-
caller of the farm, a job or another surface, takes the service alone.
|
|
114
|
-
- **One declared error, mapped in one place.** A refusal, an unreachable farm, an answer of
|
|
115
|
-
another shape and one that never comes each leave `platform/print-farm.ts` as a `PrintFarmError` with a
|
|
116
|
-
message a model can act on. No handler catches anything.
|
|
117
|
-
- **What a model sends is checked by the contract.** A printer id becomes part of the farm's
|
|
118
|
-
URL, so its schema admits only what is safe there, and anything else is refused before a
|
|
119
|
-
handler runs.
|
|
120
|
-
- **One implementation, two transports.** `server.ts` and `stdio.ts` serve the same `printers`
|
|
121
|
-
with the same `serverInfo`; they differ in what they provide around it.
|
|
122
|
-
- **The contract is the tool.** A tool's name, description, input schema, hints and title all
|
|
123
|
-
come from `Printers.ts`.
|
|
124
|
-
|
|
125
102
|
## Where to look
|
|
126
103
|
|
|
127
|
-
| To see
|
|
128
|
-
|
|
|
129
|
-
| Public tools described for a model, with field descriptions
|
|
130
|
-
| `readOnly`, `title`, `
|
|
131
|
-
| One declared error shared by every tool
|
|
132
|
-
| An input a model cannot use to reach another URL
|
|
133
|
-
| A tool answering an image beside other fields
|
|
134
|
-
| An upstream client reading `Config`, signing in once
|
|
135
|
-
| An upstream's failures mapped to the declared error
|
|
136
|
-
| A builder yielding a service, and handlers that only delegate
|
|
137
|
-
| Server `instructions`, shared by both transports
|
|
138
|
-
| Allowed origins from `Config`, their CORS, and `/healthz`
|
|
139
|
-
| A request body limit, and where an unauthenticated server binds
|
|
140
|
-
| A stdio server whose logs go to stderr
|
|
141
|
-
| A fake upstream provided as a Layer, to a test or an entrypoint
|
|
142
|
-
| Testing tools and
|
|
143
|
-
| Testing listed hints, an image and the origin check in memory
|
|
144
|
-
| Testing CORS for one configured origin and for several
|
|
104
|
+
| To see | Read |
|
|
105
|
+
| ----------------------------------------------------------------------- | -------------------------------- |
|
|
106
|
+
| Public tools described for a model, with field descriptions | `src/printers/Printers.ts` |
|
|
107
|
+
| `readOnly`, `title`, `idempotentHint`, and destructive as MCP's default | `src/printers/Printers.ts` |
|
|
108
|
+
| One declared error shared by every tool | `src/printers/Printers.ts` |
|
|
109
|
+
| An input a model cannot use to reach another URL | `src/printers/Printers.ts` |
|
|
110
|
+
| A tool answering an image beside other fields | `src/printers/Printers.ts` |
|
|
111
|
+
| An upstream client reading `Config`, signing in once | `src/platform/print-farm.ts` |
|
|
112
|
+
| An upstream's failures mapped to the declared error | `src/platform/print-farm.ts` |
|
|
113
|
+
| A builder yielding a service, and handlers that only delegate | `src/printers/handlers.ts` |
|
|
114
|
+
| Server `instructions`, shared by both transports | `src/instructions.ts` |
|
|
115
|
+
| Allowed origins from `Config`, their CORS, and `/healthz` | `src/routes.ts` |
|
|
116
|
+
| A request body limit, and where an unauthenticated server binds | `src/server.ts` |
|
|
117
|
+
| A stdio server whose logs go to stderr | `src/stdio.ts` |
|
|
118
|
+
| A fake upstream provided as a Layer, to a test or an entrypoint | `src/platform/fake-farm.ts` |
|
|
119
|
+
| Testing tools and the upstream's failure messages in process | `test/printers/printers.test.ts` |
|
|
120
|
+
| Testing listed hints, an image and the origin check in memory | `test/routes.test.ts` |
|
|
121
|
+
| Testing CORS for one configured origin and for several | `test/routes.test.ts` |
|
|
@@ -40,8 +40,7 @@ const tool = { caller: Action.Anyone, error: PrintFarmError } as const;
|
|
|
40
40
|
// commands names them in camelCase, as notes-service does.
|
|
41
41
|
//
|
|
42
42
|
// A description is written for the model that picks the tool: what it does, when to call it,
|
|
43
|
-
// and what it answers.
|
|
44
|
-
// hints and the title a client shows its user.
|
|
43
|
+
// and what it answers.
|
|
45
44
|
export const ListPrinters = Action.make("list_printers", {
|
|
46
45
|
...tool,
|
|
47
46
|
description:
|
|
@@ -53,8 +52,7 @@ export const ListPrinters = Action.make("list_printers", {
|
|
|
53
52
|
mcp: { title: "List printers" },
|
|
54
53
|
});
|
|
55
54
|
|
|
56
|
-
// `image` is an `Action.Image
|
|
57
|
-
// the model can look at, after the JSON of `{ printer }`.
|
|
55
|
+
// `image` is an `Action.Image`, so a model sees the picture rather than reading its base64.
|
|
58
56
|
export const GetCameraSnapshot = Action.make("get_camera_snapshot", {
|
|
59
57
|
...tool,
|
|
60
58
|
description:
|
|
@@ -95,12 +93,10 @@ export const SetPaused = Action.make("set_paused", {
|
|
|
95
93
|
},
|
|
96
94
|
success: Printer,
|
|
97
95
|
readOnly: false,
|
|
98
|
-
// Destructive, MCP's default for a write: a pause interrupts a running print.
|
|
99
|
-
// that only adds, such as creating a record, says `destructiveHint: false`.
|
|
96
|
+
// Destructive, MCP's default for a write: a pause interrupts a running print.
|
|
100
97
|
mcp: { title: "Pause or resume a print", idempotentHint: true },
|
|
101
98
|
});
|
|
102
99
|
|
|
103
|
-
// Destructive, which is MCP's default for a write, so no `destructiveHint` is stated.
|
|
104
100
|
export const CancelPrint = Action.make("cancel_print", {
|
|
105
101
|
...tool,
|
|
106
102
|
description:
|
|
@@ -20,13 +20,6 @@ export const routes = Layer.unwrap(
|
|
|
20
20
|
// no origin, allows every one, so it is added only where an origin is configured. The
|
|
21
21
|
// methods are the endpoint's, the headers those an MCP client sends and must be able to
|
|
22
22
|
// read; an endpoint with authentication adds `Authorization` and exposes `WWW-Authenticate`.
|
|
23
|
-
//
|
|
24
|
-
// Given several origins it names the request's own in `Access-Control-Allow-Origin` when
|
|
25
|
-
// it is listed, and sends no such header otherwise. Given exactly one it compares nothing:
|
|
26
|
-
// every answer names that origin, a preflight from elsewhere included. Both refuse a page
|
|
27
|
-
// elsewhere, whose browser allows a call only where the header names the page's own origin,
|
|
28
|
-
// and the Origin check above has answered its request 403 either way. So a test of one
|
|
29
|
-
// configured origin asserts which origin is named, never that the header is absent.
|
|
30
23
|
const cors =
|
|
31
24
|
allowedOrigins.length === 0
|
|
32
25
|
? Layer.empty
|
|
@@ -10,7 +10,7 @@ import { routes } from "./routes.js";
|
|
|
10
10
|
// and FARM_PASSWORD set. MCP over HTTP, for clients that connect to a URL.
|
|
11
11
|
// These tools are public: no route asks for a token, so where the server listens is its access
|
|
12
12
|
// control, the loopback interface by default or a network only trusted clients are on. Tools
|
|
13
|
-
// that need a caller
|
|
13
|
+
// that need a caller are protected as docs/ActionMcp.md's "Protect tools" shows.
|
|
14
14
|
const server = Layer.unwrap(
|
|
15
15
|
Effect.gen(function* () {
|
|
16
16
|
const host = yield* Config.String("HOST").pipe(Config.withDefault("127.0.0.1"));
|
|
@@ -63,7 +63,7 @@ const farmThat = (
|
|
|
63
63
|
const door = { id: "mk4-1", name: "MK4 by the door" };
|
|
64
64
|
|
|
65
65
|
describe("printers, in process", () => {
|
|
66
|
-
it.effect("pauses, resumes and cancels a job,
|
|
66
|
+
it.effect("pauses, resumes and cancels a job, pause and cancel safe to repeat", () =>
|
|
67
67
|
Effect.gen(function* () {
|
|
68
68
|
const client = yield* Action.client(printers);
|
|
69
69
|
const pause = client.set_paused({ printer_id: "mk4-1", paused: true });
|
|
@@ -115,8 +115,7 @@ describe("printers, in process", () => {
|
|
|
115
115
|
}).pipe(Effect.provide(farmOver(noting(sent))));
|
|
116
116
|
});
|
|
117
117
|
|
|
118
|
-
// The message is all a model gets of a failure, so each test asserts it.
|
|
119
|
-
// expected error would not: an error's `message` is not among the properties it compares.
|
|
118
|
+
// The message is all a model gets of a failure, so each test asserts it.
|
|
120
119
|
it.effect("fails with the declared error, carrying the farm's own reason", () =>
|
|
121
120
|
Effect.gen(function* () {
|
|
122
121
|
const client = yield* Action.client(printers);
|
|
@@ -14,8 +14,7 @@ import { routes } from "../src/routes.js";
|
|
|
14
14
|
// What the entrypoint reads from the environment, given to the test as a Layer.
|
|
15
15
|
const farm = { FARM_URL: "https://farm.example.test", FARM_USER: "agent", FARM_PASSWORD: "secret" };
|
|
16
16
|
|
|
17
|
-
//
|
|
18
|
-
// fake farm is another `HttpClient`, provided to `printFarm` alone.
|
|
17
|
+
// The fake farm goes to `printFarm` alone, so the test's own requests still reach the routes.
|
|
19
18
|
const servedWith = (environment: Record<string, string>) =>
|
|
20
19
|
Testing.layer(routes).pipe(
|
|
21
20
|
Layer.provide(printFarm.pipe(Layer.provide(fakeFarm))),
|
|
@@ -57,7 +56,7 @@ const Listed = Schema.Struct({
|
|
|
57
56
|
});
|
|
58
57
|
|
|
59
58
|
describe("the tools, over MCP", () => {
|
|
60
|
-
it.effect("lists every tool with
|
|
59
|
+
it.effect("lists every tool with its title and hints as sent", () =>
|
|
61
60
|
Effect.gen(function* () {
|
|
62
61
|
// A raw request, for what no typed client shows: here, the listing itself.
|
|
63
62
|
const response = yield* HttpClient.execute(ActionMcp.request("tools/list"));
|
|
@@ -5,11 +5,6 @@ implementation, served as a JSON API and as MCP tools, with a CLI and a browser
|
|
|
5
5
|
same binding. Notes belong to the signed-in user; reading needs the `notes:read` scope and
|
|
6
6
|
writing `notes:write`.
|
|
7
7
|
|
|
8
|
-
Start here if you are building a service. The other examples follow the same layout. Then read
|
|
9
|
-
[Action.md](../../docs/Action.md) and the page of each surface you serve, which
|
|
10
|
-
[docs/README.md](../../docs/README.md) routes, one page per tool call. An error's message is
|
|
11
|
-
quoted in a `docs/*-failures.md`.
|
|
12
|
-
|
|
13
8
|
## Run it
|
|
14
9
|
|
|
15
10
|
From the repository root, after `vp install` (from an installed package, see
|
|
@@ -44,11 +39,10 @@ codex mcp add notes --url http://127.0.0.1:3000/mcp --bearer-token-env-var NOTES
|
|
|
44
39
|
Cloudflare Workers ([ActionMcp.md](../../docs/ActionMcp.md#serve-tools-over-http)).
|
|
45
40
|
|
|
46
41
|
The server reads `HOST` (default `127.0.0.1`), `PORT` (default 3000) and `PUBLIC_URL`, the
|
|
47
|
-
address it tells OAuth clients it is at; the CLI reads `NOTES_URL` and `NOTES_TOKEN`. Change the
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
The tokens are fixed names and the store is memory: replace both before deploying anything.
|
|
42
|
+
address it tells OAuth clients it is at; the CLI reads `NOTES_URL` and `NOTES_TOKEN`. Change the
|
|
43
|
+
port and set `PORT`, `PUBLIC_URL` and `NOTES_URL` to match. Every command asks for
|
|
44
|
+
`NOTES_TOKEN`, the public `get-status` included: without it a command prints one message on
|
|
45
|
+
stderr saying to set it, and exits 1. `--help` needs none.
|
|
52
46
|
|
|
53
47
|
## Retry a creation
|
|
54
48
|
|
|
@@ -62,24 +56,8 @@ command:
|
|
|
62
56
|
echo "First draft" | node --import tsx examples/notes-service/src/cli.ts create-note --request-id plan-1 --title Plan
|
|
63
57
|
```
|
|
64
58
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
different content fails with `RequestConflict`. A new key creates a new note, even when its
|
|
68
|
-
content is identical. Every attempt still runs authorization.
|
|
69
|
-
|
|
70
|
-
The replay result is a snapshot of the creation. Updating or deleting the note leaves that
|
|
71
|
-
snapshot intact: replay neither changes the note back nor recreates it. `importNotes` has no
|
|
72
|
-
request key; each import creates new notes. Calls without a key are not idempotent, so the
|
|
73
|
-
creation tool does not advertise `idempotentHint: true`.
|
|
74
|
-
|
|
75
|
-
[src/notes/store.ts](src/notes/store.ts) owns creation and replay recording.
|
|
76
|
-
|
|
77
|
-
**DEMO ONLY:** notes and replay records live in this process's memory, without eviction. They
|
|
78
|
-
do not survive a restart or coordinate separate server instances. A deployed store must commit
|
|
79
|
-
the mutation and replay record in one transaction, enforce uniqueness of caller, operation and
|
|
80
|
-
key, and define a retention window. An expired key no longer protects a retry. External side
|
|
81
|
-
effects need the upstream system's cooperation: a local record cannot make a separate network
|
|
82
|
-
call atomic. The library supplies neither replay storage nor automatic retries.
|
|
59
|
+
What a key promises, per caller, for changed content and after the note changes, is what
|
|
60
|
+
[test/notes/idempotency.test.ts](test/notes/idempotency.test.ts) asserts.
|
|
83
61
|
|
|
84
62
|
## Layout
|
|
85
63
|
|
|
@@ -113,19 +91,6 @@ test/
|
|
|
113
91
|
└── commands.test.ts the commands and their connection, against the same routes in memory
|
|
114
92
|
```
|
|
115
93
|
|
|
116
|
-
- **A folder per domain, not per mechanism.** Everything about notes is in `notes/`; there is no
|
|
117
|
-
`contracts/` or `handlers/` folder.
|
|
118
|
-
- **The PascalCase file is the contract.** `Notes.ts`, `Identity.ts`, `Status.ts` and `Api.ts`
|
|
119
|
-
import no server code, so a browser or a CLI imports them. The lowercase files beside them run
|
|
120
|
-
on the server, each named for its role, never the domain: `notes.ts` beside `Notes.ts` is one
|
|
121
|
-
file on a disk that ignores case (TS1149).
|
|
122
|
-
- **One entrypoint per way of running it.** Each chooses its own implementations: `server.ts`
|
|
123
|
-
picks the store and the HTTP server, `worker.ts` the routes for a serverless host, `stdio.ts`
|
|
124
|
-
names the local user, a test provides its own store. Nothing below an entrypoint knows which
|
|
125
|
-
it got.
|
|
126
|
-
- **In a monorepo** the contract files and `Api.ts` are the package the server, the CLI and the
|
|
127
|
-
website all depend on; the lowercase files and `routes.ts` are the server.
|
|
128
|
-
|
|
129
94
|
## Where to look
|
|
130
95
|
|
|
131
96
|
| To see | Read |
|
|
@@ -146,8 +111,9 @@ test/
|
|
|
146
111
|
| A bulk import as one action, its command reading a file | `src/notes/`, `src/commands.ts` |
|
|
147
112
|
| The CLI's connection, read from config when a command runs | `src/commands.ts` |
|
|
148
113
|
| A missing token as one message, not a `ConfigError` report | `src/commands.ts` |
|
|
149
|
-
| A promise client handling
|
|
150
|
-
| Testing handlers and authorization with no transport | `test/notes/
|
|
114
|
+
| A promise client handling a declared error by its tag | `src/browser.ts` |
|
|
115
|
+
| Testing handlers and authorization with no transport | `test/notes/in-process.test.ts` |
|
|
116
|
+
| Testing a revision race, two fibers taking turns | `test/notes/notes.test.ts` |
|
|
151
117
|
| Testing routes, tools and refusals in memory | `test/routes.test.ts` |
|
|
152
118
|
| Testing commands against the routes in memory | `test/commands.test.ts` |
|
|
153
119
|
| Testing `--help` and the connection with config as a Layer | `test/commands.test.ts` |
|
|
@@ -6,9 +6,6 @@ import { commands } from "./commands.js";
|
|
|
6
6
|
|
|
7
7
|
// Entrypoint: `node --import tsx examples/notes-service/src/cli.ts list-notes`.
|
|
8
8
|
// The commands of commands.ts, which read where the server is and the token when one runs.
|
|
9
|
-
|
|
10
|
-
// A result is printed on stdout. A failure goes to stderr, under an `ERROR` heading, as the JSON
|
|
11
|
-
// HTTP sends, such as `{"_tag":"NoteNotFound","id":"9"}`, and the command exits 1.
|
|
12
9
|
Command.run(commands, { version: "1.0.0" }).pipe(
|
|
13
10
|
Effect.provide([NodeHttpClient.layerUndici, NodeServices.layer]),
|
|
14
11
|
ActionCli.logToStderr,
|