@gjermundgaraba/effect-actions 0.11.0 → 0.12.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 +101 -40
- package/dist/Action.d.mts +29 -117
- package/dist/Action.mjs +14 -53
- package/dist/ActionCli.d.mts +60 -28
- package/dist/ActionCli.mjs +54 -48
- package/dist/ActionHttp.d.mts +18 -67
- package/dist/ActionHttp.mjs +20 -70
- package/dist/ActionMcp.d.mts +23 -29
- package/dist/ActionMcp.mjs +56 -114
- package/dist/ActionRpc.d.mts +147 -0
- package/dist/ActionRpc.mjs +113 -0
- package/dist/ActionToolkit.d.mts +5 -8
- package/dist/ActionToolkit.mjs +3 -3
- package/dist/Authentication.d.mts +15 -15
- package/dist/Authentication.mjs +42 -96
- package/dist/Testing.d.mts +28 -23
- package/dist/Testing.mjs +27 -44
- package/dist/{internal/authentication.d.mts → authentication/provider.d.mts} +41 -9
- package/dist/authentication/provider.mjs +12 -0
- package/dist/authentication/refusal.mjs +36 -0
- package/dist/{internal/cli.d.mts → cli/commands.d.mts} +16 -12
- package/dist/cli/commands.mjs +302 -0
- package/dist/{internal → contract}/call.d.mts +2 -2
- package/dist/contract/call.mjs +10 -0
- package/dist/{internal → contract}/errors.d.mts +13 -10
- package/dist/{internal → contract}/errors.mjs +9 -14
- package/dist/{internal → contract}/implementation.d.mts +37 -21
- package/dist/{internal → contract}/implementation.mjs +18 -49
- package/dist/contract/media.d.mts +13 -0
- package/dist/contract/media.mjs +18 -0
- package/dist/contract/rules.d.mts +25 -0
- package/dist/{internal/actions.mjs → contract/rules.mjs} +6 -42
- package/dist/{internal → http}/client.d.mts +7 -9
- package/dist/http/client.mjs +17 -0
- package/dist/http/declared.mjs +20 -0
- package/dist/http/schema-errors.mjs +9 -0
- package/dist/mcp/protocol.d.mts +17 -0
- package/dist/{internal/mcp.mjs → mcp/protocol.mjs} +29 -62
- package/dist/stdio/console.mjs +64 -0
- package/dist/{internal → testing}/memory.d.mts +1 -1
- package/dist/{internal → testing}/memory.mjs +5 -17
- package/dist/toolkit/tools.mjs +29 -0
- package/docs/Action-failures.md +47 -0
- package/docs/Action-in-process.md +93 -0
- package/docs/Action.md +206 -426
- package/docs/ActionCli-failures.md +102 -0
- package/docs/ActionCli.md +206 -293
- package/docs/ActionHttp-failures.md +83 -0
- package/docs/ActionHttp.md +202 -282
- package/docs/ActionMcp-failures.md +31 -0
- package/docs/ActionMcp.md +222 -271
- package/docs/ActionRpc-failures.md +21 -0
- package/docs/ActionRpc.md +178 -0
- package/docs/ActionToolkit-failures.md +15 -0
- package/docs/ActionToolkit.md +100 -119
- package/docs/Authentication-advanced.md +139 -0
- package/docs/Authentication-failures.md +99 -0
- package/docs/Authentication.md +142 -392
- package/docs/CONTEXT.md +39 -24
- package/docs/README.md +71 -35
- package/docs/Testing-failures.md +20 -0
- package/docs/Testing.md +186 -151
- package/docs/guarantees.md +35 -96
- package/docs/setup-failures.md +34 -0
- package/docs/setup.md +87 -91
- package/examples/README.md +68 -0
- package/examples/assistant/README.md +92 -0
- package/examples/assistant/src/agent.ts +92 -0
- package/examples/assistant/src/cli.ts +34 -0
- package/examples/assistant/src/commands.ts +25 -0
- package/examples/assistant/src/identity/Identity.ts +33 -0
- package/examples/assistant/src/identity/authorize.ts +24 -0
- package/examples/assistant/src/identity/handlers.ts +10 -0
- package/examples/assistant/src/identity/members.ts +9 -0
- package/examples/assistant/src/identity/permissions.ts +23 -0
- package/examples/assistant/src/scripted.ts +23 -0
- package/examples/assistant/src/tasks/Tasks.ts +63 -0
- package/examples/assistant/src/tasks/handlers.ts +25 -0
- package/examples/assistant/src/tasks/store.ts +96 -0
- package/examples/assistant/src/toolkit.ts +30 -0
- package/examples/assistant/test/commands.test.ts +57 -0
- package/examples/assistant/test/identity/identity.test.ts +43 -0
- package/examples/assistant/test/tasks/store.test.ts +48 -0
- package/examples/assistant/test/tasks/tasks.test.ts +75 -0
- package/examples/assistant/test/toolkit.test.ts +121 -0
- package/examples/kitchen-sink/README.md +317 -0
- package/examples/kitchen-sink/src/Actions.ts +19 -0
- package/examples/kitchen-sink/src/Api.ts +24 -0
- package/examples/kitchen-sink/src/Rpc.ts +12 -0
- package/examples/kitchen-sink/src/admin/Admin.ts +59 -0
- package/examples/kitchen-sink/src/admin/authenticate.ts +18 -0
- package/examples/kitchen-sink/src/admin/authorize.ts +27 -0
- package/examples/kitchen-sink/src/admin/grants.ts +22 -0
- package/examples/kitchen-sink/src/admin/handlers.ts +21 -0
- package/examples/kitchen-sink/src/admin/keys.ts +8 -0
- package/examples/kitchen-sink/src/agent.ts +47 -0
- package/examples/kitchen-sink/src/assistant/Assistant.ts +82 -0
- package/examples/kitchen-sink/src/assistant/conversations.ts +71 -0
- package/examples/kitchen-sink/src/assistant/demo-model.ts +33 -0
- package/examples/kitchen-sink/src/assistant/handlers.ts +146 -0
- package/examples/kitchen-sink/src/attachments/Attachments.ts +48 -0
- package/examples/kitchen-sink/src/attachments/download.ts +49 -0
- package/examples/kitchen-sink/src/attachments/handlers.ts +38 -0
- package/examples/kitchen-sink/src/attachments/store.ts +58 -0
- package/examples/kitchen-sink/src/browser.ts +33 -0
- package/examples/kitchen-sink/src/cli.ts +85 -0
- package/examples/kitchen-sink/src/config.ts +18 -0
- package/examples/kitchen-sink/src/identity/Identity.ts +61 -0
- package/examples/kitchen-sink/src/identity/authenticate.ts +87 -0
- package/examples/kitchen-sink/src/identity/authorize.ts +16 -0
- package/examples/kitchen-sink/src/identity/directory.ts +52 -0
- package/examples/kitchen-sink/src/identity/handlers.ts +9 -0
- package/examples/kitchen-sink/src/identity/members.ts +13 -0
- package/examples/kitchen-sink/src/identity/quota.ts +62 -0
- package/examples/kitchen-sink/src/issues/Issues.ts +109 -0
- package/examples/kitchen-sink/src/issues/assistant.ts +23 -0
- package/examples/kitchen-sink/src/issues/guide.ts +37 -0
- package/examples/kitchen-sink/src/issues/handlers.ts +37 -0
- package/examples/kitchen-sink/src/issues/store.ts +96 -0
- package/examples/kitchen-sink/src/issues/write-limit.ts +41 -0
- package/examples/kitchen-sink/src/memory.ts +30 -0
- package/examples/kitchen-sink/src/ops.ts +42 -0
- package/examples/kitchen-sink/src/request-policy.ts +63 -0
- package/examples/kitchen-sink/src/routes.ts +120 -0
- package/examples/kitchen-sink/src/sdk.ts +46 -0
- package/examples/kitchen-sink/src/seed.ts +65 -0
- package/examples/kitchen-sink/src/server.ts +62 -0
- package/examples/kitchen-sink/src/stdio.ts +34 -0
- package/examples/kitchen-sink/src/workspaces/Workspaces.ts +28 -0
- package/examples/kitchen-sink/src/workspaces/handlers.ts +23 -0
- package/examples/kitchen-sink/src/workspaces/registry.ts +46 -0
- package/examples/kitchen-sink/src/workspaces/tenant.ts +17 -0
- package/examples/kitchen-sink/test/admin/admin.test.ts +67 -0
- package/examples/kitchen-sink/test/assistant/assistant.test.ts +435 -0
- package/examples/kitchen-sink/test/attachments/attachments.test.ts +161 -0
- package/examples/kitchen-sink/test/identity/identity.test.ts +137 -0
- package/examples/kitchen-sink/test/identity/session.test.ts +110 -0
- package/examples/kitchen-sink/test/issues/assistant.test.ts +75 -0
- package/examples/kitchen-sink/test/issues/issues.test.ts +135 -0
- package/examples/kitchen-sink/test/request-policy.test.ts +125 -0
- package/examples/kitchen-sink/test/routes.test.ts +123 -0
- package/examples/kitchen-sink/test/support/scripted.ts +40 -0
- package/examples/live-rpc/README.md +107 -0
- package/examples/live-rpc/src/Rpc.ts +8 -0
- package/examples/live-rpc/src/browser.ts +98 -0
- package/examples/live-rpc/src/chat/Chat.ts +75 -0
- package/examples/live-rpc/src/chat/authorize.ts +16 -0
- package/examples/live-rpc/src/chat/handlers.ts +50 -0
- package/examples/live-rpc/src/chat/room.ts +23 -0
- package/examples/live-rpc/src/chat/store.ts +54 -0
- package/examples/live-rpc/src/client.ts +37 -0
- package/examples/live-rpc/src/identity/Identity.ts +19 -0
- package/examples/live-rpc/src/identity/members.ts +8 -0
- package/examples/live-rpc/src/identity/verifier.ts +23 -0
- package/examples/live-rpc/src/routes.ts +22 -0
- package/examples/live-rpc/src/server.ts +43 -0
- package/examples/live-rpc/test/browser.test.ts +105 -0
- package/examples/live-rpc/test/chat/chat.test.ts +69 -0
- package/examples/live-rpc/test/routes.test.ts +99 -0
- package/examples/live-rpc/test/server.test.ts +75 -0
- package/examples/mcp-tools/README.md +136 -0
- package/examples/mcp-tools/src/instructions.ts +12 -0
- package/examples/mcp-tools/src/printers/Printers.ts +119 -0
- package/examples/mcp-tools/src/printers/fake.ts +115 -0
- package/examples/mcp-tools/src/printers/farm.ts +136 -0
- package/examples/mcp-tools/src/printers/handlers.ts +29 -0
- package/examples/mcp-tools/src/routes.ts +52 -0
- package/examples/mcp-tools/src/server.ts +30 -0
- package/examples/mcp-tools/src/stdio.ts +26 -0
- package/examples/mcp-tools/test/printers/printers.test.ts +189 -0
- package/examples/mcp-tools/test/routes.test.ts +202 -0
- package/examples/notes-service/README.md +108 -0
- package/examples/notes-service/src/Api.ts +13 -0
- package/examples/notes-service/src/browser.ts +41 -0
- package/examples/notes-service/src/cli.ts +22 -0
- package/examples/notes-service/src/commands.ts +89 -0
- package/examples/notes-service/src/identity/Identity.ts +31 -0
- package/examples/notes-service/src/identity/authorize.ts +18 -0
- package/examples/notes-service/src/identity/handlers.ts +10 -0
- package/examples/notes-service/src/identity/users.ts +9 -0
- package/examples/notes-service/src/identity/verifier.ts +36 -0
- package/examples/notes-service/src/notes/Notes.ts +91 -0
- package/examples/notes-service/src/notes/handlers.ts +33 -0
- package/examples/notes-service/src/notes/store.ts +73 -0
- package/examples/notes-service/src/routes.ts +45 -0
- package/examples/notes-service/src/server.ts +26 -0
- package/examples/notes-service/src/status/Status.ts +10 -0
- package/examples/notes-service/src/status/handlers.ts +14 -0
- package/examples/notes-service/src/stdio.ts +25 -0
- package/examples/notes-service/test/commands.test.ts +134 -0
- package/examples/notes-service/test/notes/notes.test.ts +103 -0
- package/examples/notes-service/test/routes.test.ts +84 -0
- package/examples/quickstart/README.md +31 -0
- package/examples/quickstart/src/Api.ts +5 -0
- package/examples/quickstart/src/client.ts +13 -0
- package/examples/quickstart/src/greeting/Greeting.ts +12 -0
- package/examples/quickstart/src/greeting/handlers.ts +6 -0
- package/examples/quickstart/src/routes.ts +11 -0
- package/examples/quickstart/src/server.ts +16 -0
- package/examples/quickstart/test/greeting/greeting.test.ts +18 -0
- package/package.json +23 -5
- package/dist/internal/actions.d.mts +0 -9
- package/dist/internal/authentication.mjs +0 -11
- package/dist/internal/call.mjs +0 -12
- package/dist/internal/cli.mjs +0 -328
- package/dist/internal/client.mjs +0 -25
- package/dist/internal/console.mjs +0 -77
- package/dist/internal/declared.d.mts +0 -13
- package/dist/internal/declared.mjs +0 -34
- package/dist/internal/mcp.d.mts +0 -14
- package/dist/internal/media.d.mts +0 -8
- package/dist/internal/media.mjs +0 -26
- package/dist/internal/refusal.mjs +0 -67
- package/dist/internal/schema-errors.mjs +0 -20
- package/dist/internal/tools.mjs +0 -44
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { describe, expect, it } from "@effect/vitest";
|
|
2
|
+
import { ConfigProvider, Effect, Fiber, Layer } from "effect";
|
|
3
|
+
import {
|
|
4
|
+
HttpClient,
|
|
5
|
+
HttpClientError,
|
|
6
|
+
type HttpClientRequest,
|
|
7
|
+
HttpClientResponse,
|
|
8
|
+
} from "effect/http";
|
|
9
|
+
import { TestClock } from "effect/testing";
|
|
10
|
+
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
11
|
+
import { fakeFarm } from "../../src/printers/fake.js";
|
|
12
|
+
import { PrintFarm } from "../../src/printers/farm.js";
|
|
13
|
+
import { printers } from "../../src/printers/handlers.js";
|
|
14
|
+
import { PrintFarmError } from "../../src/printers/Printers.js";
|
|
15
|
+
|
|
16
|
+
// What the tools do, with no transport: `Action.client` runs the same decoding and the same
|
|
17
|
+
// handlers a server runs. A public action has no caller, so a call needs none provided.
|
|
18
|
+
|
|
19
|
+
// The real `PrintFarm` over an upstream of the test's: its requests, its sign-in and its error
|
|
20
|
+
// mapping all run, and only the network is replaced. Its settings come from a Layer too.
|
|
21
|
+
const farmOver = (upstream: Layer.Layer<HttpClient.HttpClient>) =>
|
|
22
|
+
PrintFarm.layer.pipe(
|
|
23
|
+
Layer.provide(upstream),
|
|
24
|
+
Layer.provide(
|
|
25
|
+
ConfigProvider.layer(
|
|
26
|
+
ConfigProvider.fromEnvRecord({
|
|
27
|
+
FARM_URL: "https://farm.example.test",
|
|
28
|
+
FARM_USER: "agent",
|
|
29
|
+
FARM_PASSWORD: "secret",
|
|
30
|
+
}),
|
|
31
|
+
),
|
|
32
|
+
),
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
// The fake farm, noting each request the service sends it. A request is noted as the service
|
|
36
|
+
// wrote it, before its client prepends FARM_URL, so the paths are relative.
|
|
37
|
+
const noting = (sent: Array<string>) =>
|
|
38
|
+
Layer.effect(
|
|
39
|
+
HttpClient.HttpClient,
|
|
40
|
+
Effect.map(
|
|
41
|
+
HttpClient.HttpClient,
|
|
42
|
+
HttpClient.tapRequest((request) =>
|
|
43
|
+
Effect.sync(() => sent.push(`${request.method} ${request.url}`)),
|
|
44
|
+
),
|
|
45
|
+
),
|
|
46
|
+
).pipe(Layer.provide(fakeFarm));
|
|
47
|
+
|
|
48
|
+
// A farm that lets the service sign in, then answers every other request with `answer`.
|
|
49
|
+
const farmThat = (
|
|
50
|
+
answer: (
|
|
51
|
+
request: HttpClientRequest.HttpClientRequest,
|
|
52
|
+
) => Effect.Effect<HttpClientResponse.HttpClientResponse, HttpClientError.HttpClientError>,
|
|
53
|
+
) =>
|
|
54
|
+
Layer.succeed(
|
|
55
|
+
HttpClient.HttpClient,
|
|
56
|
+
HttpClient.make((request, url) =>
|
|
57
|
+
url.pathname === "/api/login"
|
|
58
|
+
? Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ token: "session" })))
|
|
59
|
+
: answer(request),
|
|
60
|
+
),
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
const door = { id: "mk4-1", name: "MK4 by the door" };
|
|
64
|
+
|
|
65
|
+
describe("printers, in process", () => {
|
|
66
|
+
it.effect("pauses, resumes and cancels a job, each call safe to repeat", () =>
|
|
67
|
+
Effect.gen(function* () {
|
|
68
|
+
const client = yield* Action.client(printers);
|
|
69
|
+
const pause = client.set_paused({ printer_id: "mk4-1", paused: true });
|
|
70
|
+
const cancel = client.cancel_print({ printer_id: "mk4-1" });
|
|
71
|
+
const job = { file: "bracket.gcode", progress: 62 };
|
|
72
|
+
|
|
73
|
+
expect(yield* pause).toEqual({ ...door, state: "paused", job });
|
|
74
|
+
expect(yield* pause).toEqual({ ...door, state: "paused", job });
|
|
75
|
+
expect(yield* client.set_paused({ printer_id: "mk4-1", paused: false })).toEqual({
|
|
76
|
+
...door,
|
|
77
|
+
state: "printing",
|
|
78
|
+
job,
|
|
79
|
+
});
|
|
80
|
+
expect(yield* cancel).toEqual({ ...door, state: "idle", job: null });
|
|
81
|
+
expect(yield* cancel).toEqual({ ...door, state: "idle", job: null });
|
|
82
|
+
}).pipe(Effect.provide(farmOver(fakeFarm))),
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
it.effect("signs in once at startup, however many tools are called", () => {
|
|
86
|
+
const sent: Array<string> = [];
|
|
87
|
+
|
|
88
|
+
return Effect.gen(function* () {
|
|
89
|
+
const client = yield* Action.client(printers);
|
|
90
|
+
|
|
91
|
+
yield* client.list_printers();
|
|
92
|
+
yield* client.set_paused({ printer_id: "mk4-1", paused: true });
|
|
93
|
+
|
|
94
|
+
expect(sent).toEqual([
|
|
95
|
+
"POST /api/login",
|
|
96
|
+
"GET /api/printers",
|
|
97
|
+
"POST /api/printers/mk4-1/pause",
|
|
98
|
+
]);
|
|
99
|
+
}).pipe(Effect.provide(farmOver(noting(sent))));
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it.effect("refuses an id that could leave its place in the farm's URL, sending nothing", () => {
|
|
103
|
+
const sent: Array<string> = [];
|
|
104
|
+
|
|
105
|
+
return Effect.gen(function* () {
|
|
106
|
+
const client = yield* Action.client(printers);
|
|
107
|
+
|
|
108
|
+
for (const printer_id of ["..", "mk4-1/../..", "mk4-1?force=true", ""]) {
|
|
109
|
+
const refused = yield* Effect.flip(client.cancel_print({ printer_id }));
|
|
110
|
+
|
|
111
|
+
expect(refused).toBeInstanceOf(Action.InvalidInput);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
expect(sent).toEqual(["POST /api/login"]);
|
|
115
|
+
}).pipe(Effect.provide(farmOver(noting(sent))));
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// The message is all a model gets of a failure, so each test asserts it. `toEqual` with an
|
|
119
|
+
// expected error would not: an error's `message` is not among the properties it compares.
|
|
120
|
+
it.effect("fails with the declared error, carrying the farm's own reason", () =>
|
|
121
|
+
Effect.gen(function* () {
|
|
122
|
+
const client = yield* Action.client(printers);
|
|
123
|
+
const busy = yield* Effect.flip(client.start_print({ printer_id: "mk4-1", file: "a.gcode" }));
|
|
124
|
+
const unknown = yield* Effect.flip(client.get_camera_snapshot({ printer_id: "mk9" }));
|
|
125
|
+
|
|
126
|
+
expect(busy).toBeInstanceOf(PrintFarmError);
|
|
127
|
+
expect(busy.message).toBe(
|
|
128
|
+
"The print farm refused: MK4 by the door is busy with bracket.gcode.",
|
|
129
|
+
);
|
|
130
|
+
expect(unknown).toBeInstanceOf(PrintFarmError);
|
|
131
|
+
expect(unknown.message).toBe("The print farm refused: No printer has the id mk9.");
|
|
132
|
+
}).pipe(Effect.provide(farmOver(fakeFarm))),
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
it.effect("says so when the farm cannot be reached", () =>
|
|
136
|
+
Effect.gen(function* () {
|
|
137
|
+
const client = yield* Action.client(printers);
|
|
138
|
+
const error = yield* Effect.flip(client.list_printers());
|
|
139
|
+
|
|
140
|
+
expect(error).toBeInstanceOf(PrintFarmError);
|
|
141
|
+
expect(error.message).toBe("The print farm could not be reached.");
|
|
142
|
+
}).pipe(
|
|
143
|
+
Effect.provide(
|
|
144
|
+
farmOver(
|
|
145
|
+
farmThat((request) =>
|
|
146
|
+
Effect.fail(
|
|
147
|
+
new HttpClientError.HttpClientError({
|
|
148
|
+
reason: new HttpClientError.TransportError({ request }),
|
|
149
|
+
}),
|
|
150
|
+
),
|
|
151
|
+
),
|
|
152
|
+
),
|
|
153
|
+
),
|
|
154
|
+
),
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
it.effect("says so when the farm answers in a shape the service does not know", () =>
|
|
158
|
+
Effect.gen(function* () {
|
|
159
|
+
const client = yield* Action.client(printers);
|
|
160
|
+
const error = yield* Effect.flip(client.list_printers());
|
|
161
|
+
|
|
162
|
+
expect(error).toBeInstanceOf(PrintFarmError);
|
|
163
|
+
expect(error.message).toBe("The print farm sent an answer this server could not read.");
|
|
164
|
+
}).pipe(
|
|
165
|
+
Effect.provide(
|
|
166
|
+
farmOver(
|
|
167
|
+
farmThat((request) =>
|
|
168
|
+
Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ rows: [] }))),
|
|
169
|
+
),
|
|
170
|
+
),
|
|
171
|
+
),
|
|
172
|
+
),
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
// `it.effect` runs on a test clock, so the thirty seconds pass when the test says so.
|
|
176
|
+
it.effect("gives up on a farm that does not answer", () =>
|
|
177
|
+
Effect.gen(function* () {
|
|
178
|
+
const client = yield* Action.client(printers);
|
|
179
|
+
const call = yield* Effect.forkChild(Effect.flip(client.list_printers()));
|
|
180
|
+
|
|
181
|
+
yield* TestClock.adjust("30 seconds");
|
|
182
|
+
|
|
183
|
+
const error = yield* Fiber.join(call);
|
|
184
|
+
|
|
185
|
+
expect(error).toBeInstanceOf(PrintFarmError);
|
|
186
|
+
expect(error.message).toBe("The print farm did not answer in time.");
|
|
187
|
+
}).pipe(Effect.provide(farmOver(farmThat(() => Effect.never)))),
|
|
188
|
+
);
|
|
189
|
+
});
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { describe, expect, it } from "@effect/vitest";
|
|
2
|
+
import { ConfigProvider, Effect, Layer, Schema } from "effect";
|
|
3
|
+
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
|
|
4
|
+
import * as Testing from "@gjermundgaraba/effect-actions/Testing";
|
|
5
|
+
import { fakeFarm, picture } from "../src/printers/fake.js";
|
|
6
|
+
import { PrintFarm } from "../src/printers/farm.js";
|
|
7
|
+
import { PrintFarmError, Printers } from "../src/printers/Printers.js";
|
|
8
|
+
import { routes } from "../src/routes.js";
|
|
9
|
+
|
|
10
|
+
// What the MCP surface adds: the tools as listed, their results as sent, the origin check.
|
|
11
|
+
// The real routes answer in memory, and no port is opened.
|
|
12
|
+
|
|
13
|
+
// What the entrypoint reads from the environment, given to the test as a Layer.
|
|
14
|
+
const farm = { FARM_URL: "https://farm.example.test", FARM_USER: "agent", FARM_PASSWORD: "secret" };
|
|
15
|
+
|
|
16
|
+
// `Testing.layer` gives the test an `HttpClient` of its own, which reaches the routes. The
|
|
17
|
+
// fake farm is another `HttpClient`, provided to `PrintFarm.layer` alone.
|
|
18
|
+
const servedWith = (environment: Record<string, string>) =>
|
|
19
|
+
Testing.layer(routes).pipe(
|
|
20
|
+
Layer.provide(PrintFarm.layer.pipe(Layer.provide(fakeFarm))),
|
|
21
|
+
Layer.provide(ConfigProvider.layer(ConfigProvider.fromEnvRecord(environment))),
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
const served = servedWith({
|
|
25
|
+
...farm,
|
|
26
|
+
ALLOWED_ORIGINS: "https://console.example.test,https://wall.example.test",
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// A browser's preflight: what it asks before a cross-origin `POST` with an MCP client's headers.
|
|
30
|
+
const preflight = (origin: string) =>
|
|
31
|
+
HttpClient.execute(
|
|
32
|
+
HttpClientRequest.make("OPTIONS")("/mcp", {
|
|
33
|
+
headers: {
|
|
34
|
+
origin,
|
|
35
|
+
"access-control-request-method": "POST",
|
|
36
|
+
"access-control-request-headers": "content-type, mcp-protocol-version",
|
|
37
|
+
},
|
|
38
|
+
}),
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
// One tool of a `tools/list` answer, as far as this test reads it.
|
|
42
|
+
const Listed = Schema.Struct({
|
|
43
|
+
result: Schema.Struct({
|
|
44
|
+
tools: Schema.Array(
|
|
45
|
+
Schema.Struct({
|
|
46
|
+
name: Schema.String,
|
|
47
|
+
annotations: Schema.Struct({
|
|
48
|
+
title: Schema.String,
|
|
49
|
+
readOnlyHint: Schema.Boolean,
|
|
50
|
+
destructiveHint: Schema.Boolean,
|
|
51
|
+
idempotentHint: Schema.Boolean,
|
|
52
|
+
}),
|
|
53
|
+
}),
|
|
54
|
+
),
|
|
55
|
+
}),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("the tools, over MCP", () => {
|
|
59
|
+
it.effect("lists every tool with the hints its contract states", () =>
|
|
60
|
+
Effect.gen(function* () {
|
|
61
|
+
// A raw request, for what no typed client shows: here, the listing itself.
|
|
62
|
+
const response = yield* HttpClient.execute(Testing.mcpRequest("tools/list"));
|
|
63
|
+
const { result } = yield* HttpClientResponse.schemaBodyJson(Listed)(response);
|
|
64
|
+
|
|
65
|
+
const hints = (readOnlyHint: boolean, destructiveHint: boolean, idempotentHint: boolean) => ({
|
|
66
|
+
readOnlyHint,
|
|
67
|
+
destructiveHint,
|
|
68
|
+
idempotentHint,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
expect(result.tools).toEqual([
|
|
72
|
+
{
|
|
73
|
+
name: "list_printers",
|
|
74
|
+
annotations: { title: "List printers", ...hints(true, false, false) },
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
name: "get_camera_snapshot",
|
|
78
|
+
annotations: { title: "Camera snapshot", ...hints(true, false, false) },
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
name: "start_print",
|
|
82
|
+
annotations: { title: "Start a print", ...hints(false, false, false) },
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
name: "set_paused",
|
|
86
|
+
annotations: { title: "Pause or resume a print", ...hints(false, false, true) },
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
name: "cancel_print",
|
|
90
|
+
annotations: { title: "Cancel a print", ...hints(false, true, true) },
|
|
91
|
+
},
|
|
92
|
+
]);
|
|
93
|
+
}).pipe(Effect.provide(served)),
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
it.effect("answers a camera's picture as an image beside the printer", () =>
|
|
97
|
+
Effect.gen(function* () {
|
|
98
|
+
// A client method per tool: it sends `tools/call` and decodes the result, the image
|
|
99
|
+
// from its image block, as the handler returned it.
|
|
100
|
+
const mcp = yield* Testing.mcpClient(Printers);
|
|
101
|
+
|
|
102
|
+
expect(yield* mcp.get_camera_snapshot({ printer_id: "mk4-1" })).toEqual({
|
|
103
|
+
printer: {
|
|
104
|
+
id: "mk4-1",
|
|
105
|
+
name: "MK4 by the door",
|
|
106
|
+
state: "printing",
|
|
107
|
+
job: { file: "bracket.gcode", progress: 62 },
|
|
108
|
+
},
|
|
109
|
+
image: { data: picture, mimeType: "image/png" },
|
|
110
|
+
});
|
|
111
|
+
}).pipe(Effect.provide(served)),
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
it.effect("answers the farm's refusal as the tool's declared error, message and all", () =>
|
|
115
|
+
Effect.gen(function* () {
|
|
116
|
+
const mcp = yield* Testing.mcpClient(Printers);
|
|
117
|
+
const error = yield* Effect.flip(mcp.get_camera_snapshot({ printer_id: "mini-1" }));
|
|
118
|
+
|
|
119
|
+
expect(error).toBeInstanceOf(PrintFarmError);
|
|
120
|
+
expect(error.message).toBe("The print farm refused: Mini on the shelf is offline.");
|
|
121
|
+
}).pipe(Effect.provide(served)),
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
it.effect("accepts the browser origins the configuration lists, and no other", () =>
|
|
125
|
+
Effect.gen(function* () {
|
|
126
|
+
const from = (origin: string) =>
|
|
127
|
+
HttpClient.execute(Testing.mcpRequest("tools/list", {}, { headers: { origin } }));
|
|
128
|
+
|
|
129
|
+
expect((yield* from("https://console.example.test")).status).toBe(200);
|
|
130
|
+
expect((yield* from("https://wall.example.test")).status).toBe(200);
|
|
131
|
+
expect((yield* from("https://elsewhere.example.test")).status).toBe(403);
|
|
132
|
+
}).pipe(Effect.provide(served)),
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
it.effect("answers a listed origin's CORS preflight, and its headers on a tool's answer", () =>
|
|
136
|
+
Effect.gen(function* () {
|
|
137
|
+
const asked = yield* preflight("https://console.example.test");
|
|
138
|
+
|
|
139
|
+
expect(asked.status).toBe(204);
|
|
140
|
+
expect(asked.headers["access-control-allow-origin"]).toBe("https://console.example.test");
|
|
141
|
+
expect(asked.headers["access-control-allow-methods"]).toBe("POST, DELETE");
|
|
142
|
+
expect(asked.headers["access-control-allow-headers"]).toContain("MCP-Protocol-Version");
|
|
143
|
+
|
|
144
|
+
const listed = yield* HttpClient.execute(
|
|
145
|
+
Testing.mcpRequest(
|
|
146
|
+
"tools/list",
|
|
147
|
+
{},
|
|
148
|
+
{ headers: { origin: "https://console.example.test" } },
|
|
149
|
+
),
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
expect(listed.headers["access-control-allow-origin"]).toBe("https://console.example.test");
|
|
153
|
+
expect(listed.headers["access-control-expose-headers"]).toContain("Mcp-Session-Id");
|
|
154
|
+
|
|
155
|
+
// Two origins are configured, so each request's own is compared with the list.
|
|
156
|
+
expect((yield* preflight("https://elsewhere.example.test")).headers).not.toHaveProperty(
|
|
157
|
+
"access-control-allow-origin",
|
|
158
|
+
);
|
|
159
|
+
}).pipe(Effect.provide(served)),
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
it.effect("names its one configured origin to every preflight, and still refuses any other", () =>
|
|
163
|
+
Effect.gen(function* () {
|
|
164
|
+
// With exactly one origin, Effect's CORS compares nothing: the header is that origin
|
|
165
|
+
// whoever asks. A page elsewhere is still refused, twice: its browser reads an origin
|
|
166
|
+
// that is not its own, and the endpoint answers its request 403.
|
|
167
|
+
const asked = yield* preflight("https://elsewhere.example.test");
|
|
168
|
+
|
|
169
|
+
expect(asked.status).toBe(204);
|
|
170
|
+
expect(asked.headers["access-control-allow-origin"]).toBe("https://console.example.test");
|
|
171
|
+
|
|
172
|
+
const called = yield* HttpClient.execute(
|
|
173
|
+
Testing.mcpRequest(
|
|
174
|
+
"tools/list",
|
|
175
|
+
{},
|
|
176
|
+
{ headers: { origin: "https://elsewhere.example.test" } },
|
|
177
|
+
),
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
expect(called.status).toBe(403);
|
|
181
|
+
expect(called.headers["access-control-allow-origin"]).toBe("https://console.example.test");
|
|
182
|
+
}).pipe(
|
|
183
|
+
Effect.provide(servedWith({ ...farm, ALLOWED_ORIGINS: "https://console.example.test" })),
|
|
184
|
+
),
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
it.effect("allows no origin where none is configured", () =>
|
|
188
|
+
Effect.gen(function* () {
|
|
189
|
+
const asked = yield* preflight("https://console.example.test");
|
|
190
|
+
|
|
191
|
+
expect(asked.headers).not.toHaveProperty("access-control-allow-origin");
|
|
192
|
+
}).pipe(Effect.provide(servedWith(farm))),
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
it.effect("serves the host's own route beside the tools", () =>
|
|
196
|
+
Effect.gen(function* () {
|
|
197
|
+
const health = yield* HttpClient.get("/healthz");
|
|
198
|
+
|
|
199
|
+
expect(yield* health.text).toBe("ok");
|
|
200
|
+
}).pipe(Effect.provide(served)),
|
|
201
|
+
);
|
|
202
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# notes-service
|
|
2
|
+
|
|
3
|
+
A small service as most applications of this library are built: one set of contracts, one
|
|
4
|
+
implementation, served as a JSON API and as MCP tools, with a CLI and a browser client on the
|
|
5
|
+
same binding. Notes belong to the signed-in user; reading needs the `notes:read` scope and
|
|
6
|
+
writing `notes:write`.
|
|
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
|
+
## Run it
|
|
14
|
+
|
|
15
|
+
From the repository root, after `vp install` (from an installed package, see
|
|
16
|
+
[Run one](../README.md#run-one)):
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
node --import tsx examples/notes-service/src/server.ts # 127.0.0.1:3000
|
|
20
|
+
|
|
21
|
+
export NOTES_TOKEN=ada # a demo token: ada, grace or reader
|
|
22
|
+
node --import tsx examples/notes-service/src/cli.ts --help
|
|
23
|
+
echo "First draft" | node --import tsx examples/notes-service/src/cli.ts create-note --title Plan
|
|
24
|
+
node --import tsx examples/notes-service/src/cli.ts list-notes
|
|
25
|
+
node --import tsx examples/notes-service/src/cli.ts export notes.md # a command written by hand
|
|
26
|
+
|
|
27
|
+
vp test examples/notes-service
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
An agent host connects to the running server, and so to the notes the API holds:
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
claude mcp add --transport http notes http://127.0.0.1:3000/mcp --header "Authorization: Bearer ada"
|
|
34
|
+
codex mcp add notes --url http://127.0.0.1:3000/mcp --bearer-token-env-var NOTES_TOKEN
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`src/stdio.ts` is the other way in: a subprocess the host starts, with a store of its own.
|
|
38
|
+
|
|
39
|
+
The server reads `PORT` (default 3000) and `PUBLIC_URL`, the address it tells OAuth clients it
|
|
40
|
+
is at; the CLI reads `NOTES_URL` and `NOTES_TOKEN`. Change the port and set all three to match.
|
|
41
|
+
Every command asks for `NOTES_TOKEN`, the public `get-status` included: without it a command
|
|
42
|
+
prints one message on stderr saying to set it, and exits 1. `--help` needs none.
|
|
43
|
+
|
|
44
|
+
The tokens are fixed names and the store is memory: replace both before deploying anything.
|
|
45
|
+
|
|
46
|
+
## Layout
|
|
47
|
+
|
|
48
|
+
```text
|
|
49
|
+
src/
|
|
50
|
+
├── notes/
|
|
51
|
+
│ ├── Notes.ts contract: schemas, errors, actions
|
|
52
|
+
│ ├── store.ts NoteStore, the service the handlers call, and its Layer
|
|
53
|
+
│ └── handlers.ts Action.implement: contract to store
|
|
54
|
+
├── identity/
|
|
55
|
+
│ ├── Identity.ts contract: CurrentUser, scopes, the Login descriptor, whoAmI
|
|
56
|
+
│ ├── authorize.ts the one authorization rule
|
|
57
|
+
│ ├── users.ts the demo users
|
|
58
|
+
│ ├── verifier.ts Authentication.layer: tokens to users, server only
|
|
59
|
+
│ └── handlers.ts
|
|
60
|
+
├── status/
|
|
61
|
+
│ ├── Status.ts contract: a public action
|
|
62
|
+
│ └── handlers.ts
|
|
63
|
+
├── Api.ts the HTTP binding of every domain's actions
|
|
64
|
+
├── routes.ts every surface of the host: /api, /mcp, /healthz, the OpenAPI document
|
|
65
|
+
├── server.ts entrypoint: the HTTP server
|
|
66
|
+
├── stdio.ts entrypoint: MCP for a local agent
|
|
67
|
+
├── commands.ts the commands calling the server, one written by hand, and their connection
|
|
68
|
+
├── cli.ts entrypoint: the commands, given that connection
|
|
69
|
+
└── browser.ts client module: promises for a web page
|
|
70
|
+
test/
|
|
71
|
+
├── notes/ in process, with Action.client
|
|
72
|
+
├── routes.test.ts over HTTP and MCP in memory, with Testing.layer
|
|
73
|
+
└── commands.test.ts the commands and their connection, against the same routes in memory
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
- **A folder per domain, not per mechanism.** Everything about notes is in `notes/`; there is no
|
|
77
|
+
`contracts/` or `handlers/` folder.
|
|
78
|
+
- **The PascalCase file is the contract.** `Notes.ts`, `Identity.ts`, `Status.ts` and `Api.ts`
|
|
79
|
+
import no server code, so a browser or a CLI imports them. The lowercase files beside them run
|
|
80
|
+
on the server.
|
|
81
|
+
- **One entrypoint per way of running it.** Each chooses its own implementations: `server.ts`
|
|
82
|
+
picks the store and the HTTP server, `stdio.ts` names the local user, a test provides its own
|
|
83
|
+
store. Nothing below an entrypoint knows which it got.
|
|
84
|
+
- **In a monorepo** the contract files and `Api.ts` are the package the server, the CLI and the
|
|
85
|
+
website all depend on; the lowercase files and `routes.ts` are the server.
|
|
86
|
+
|
|
87
|
+
## Where to look
|
|
88
|
+
|
|
89
|
+
| To see | Read |
|
|
90
|
+
| ------------------------------------------------------------- | -------------------------------- |
|
|
91
|
+
| A contract with declared errors and their statuses | `src/notes/Notes.ts` |
|
|
92
|
+
| Input fields described, for `--help` and a tool's schema | `src/notes/Notes.ts` |
|
|
93
|
+
| Handlers getting a service once and the caller per call | `src/notes/handlers.ts` |
|
|
94
|
+
| A public action beside protected ones | `src/status/` |
|
|
95
|
+
| Authorization from `readOnly`, refusing with a scope | `src/identity/authorize.ts` |
|
|
96
|
+
| A token verifier and OAuth discovery read from config | `src/identity/verifier.ts` |
|
|
97
|
+
| One implementation served over HTTP and MCP | `src/routes.ts` |
|
|
98
|
+
| Config, a body limit and the host's own routes | `src/server.ts`, `src/routes.ts` |
|
|
99
|
+
| The same actions over stdio, the host naming the caller | `src/stdio.ts` |
|
|
100
|
+
| A CLI derived from the binding: an argument, a field on stdin | `src/commands.ts` |
|
|
101
|
+
| A command written by hand beside the derived ones | `src/commands.ts` |
|
|
102
|
+
| The CLI's connection, read from config when a command runs | `src/commands.ts`, `src/cli.ts` |
|
|
103
|
+
| A missing token as one message, not a `ConfigError` report | `src/commands.ts` |
|
|
104
|
+
| A promise client handling each declared error by its tag | `src/browser.ts` |
|
|
105
|
+
| Testing handlers and authorization with no transport | `test/notes/notes.test.ts` |
|
|
106
|
+
| Testing routes, tools and refusals in memory | `test/routes.test.ts` |
|
|
107
|
+
| Testing commands against the routes in memory | `test/commands.test.ts` |
|
|
108
|
+
| Testing `--help` and the connection with config as a Layer | `test/commands.test.ts` |
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
|
|
2
|
+
import { Login, WhoAmI } from "./identity/Identity.js";
|
|
3
|
+
import { Notes } from "./notes/Notes.js";
|
|
4
|
+
import { GetStatus } from "./status/Status.js";
|
|
5
|
+
|
|
6
|
+
// The HTTP binding: every action at `POST /api/<name>`, the protected ones requiring `Login`.
|
|
7
|
+
// It is plain data and imports no server code, so the server, the CLI and a browser page all
|
|
8
|
+
// import this one value, and each gets its routes or its typed methods from it. An action
|
|
9
|
+
// added here is also a `notes` command: commands.ts derives one per action of this binding.
|
|
10
|
+
export const Http = ActionHttp.make([GetStatus, ...Notes, WhoAmI], {
|
|
11
|
+
prefix: "/api",
|
|
12
|
+
authentication: Login,
|
|
13
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { HttpClient, HttpClientRequest } from "effect/http";
|
|
3
|
+
import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
|
|
4
|
+
import { Http } from "./Api.js";
|
|
5
|
+
import type { Note } from "./notes/Notes.js";
|
|
6
|
+
|
|
7
|
+
// Client module: what a web page imports. It holds the binding and nothing of the server, so a
|
|
8
|
+
// bundler gets the contracts and Effect's fetch client, and no handler.
|
|
9
|
+
//
|
|
10
|
+
// Without `baseUrl` the routes are called on the page's own origin. A page served elsewhere
|
|
11
|
+
// passes the API's URL, and the server must then allow its origin (CORS).
|
|
12
|
+
export const connect = (token: string, baseUrl?: string) => {
|
|
13
|
+
// Built once, outside any Effect. Every method is typed from its action.
|
|
14
|
+
const api = ActionHttp.fetchClient(Http, {
|
|
15
|
+
transformClient: HttpClient.mapRequest(HttpClientRequest.bearerToken(token)),
|
|
16
|
+
...(baseUrl === undefined ? {} : { baseUrl }),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
// Each method returns a promise. A failure rejects it with the decoded error, so in a `catch`
|
|
20
|
+
// `error instanceof Action.Unauthenticated` tells an expired sign-in from anything else.
|
|
21
|
+
return {
|
|
22
|
+
notes: () => Effect.runPromise(api.listNotes()),
|
|
23
|
+
|
|
24
|
+
// A declared error can also be handled by its tag before the promise: a missing note is
|
|
25
|
+
// `undefined` here, and every other failure still rejects.
|
|
26
|
+
note: (id: string): Promise<Note | undefined> =>
|
|
27
|
+
api.getNote({ id }).pipe(
|
|
28
|
+
Effect.catchTag("NoteNotFound", () => Effect.succeed(undefined)),
|
|
29
|
+
Effect.runPromise,
|
|
30
|
+
),
|
|
31
|
+
|
|
32
|
+
// A stale revision is refused, never overwritten: the error carries the current one.
|
|
33
|
+
save: (note: Note, body: string) =>
|
|
34
|
+
api.updateNote({ id: note.id, body, revision: note.revision }).pipe(
|
|
35
|
+
Effect.catchTag("NoteChanged", ({ revision }) =>
|
|
36
|
+
Effect.fail(new Error(`Someone else saved revision ${revision}. Reload the note.`)),
|
|
37
|
+
),
|
|
38
|
+
Effect.runPromise,
|
|
39
|
+
),
|
|
40
|
+
};
|
|
41
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { NodeHttpClient, NodeRuntime, NodeServices } from "@effect/platform-node";
|
|
2
|
+
import { Effect } from "effect";
|
|
3
|
+
import { Command } from "effect/cli";
|
|
4
|
+
import { HttpClient } from "effect/http";
|
|
5
|
+
import * as ActionCli from "@gjermundgaraba/effect-actions/ActionCli";
|
|
6
|
+
import { commands, connection } from "./commands.js";
|
|
7
|
+
|
|
8
|
+
// Entrypoint: `node --import tsx examples/notes-service/src/cli.ts list-notes`.
|
|
9
|
+
// The commands of commands.ts, connected to a server.
|
|
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
|
+
);
|
|
15
|
+
|
|
16
|
+
// A result is printed on stdout. A failure goes to stderr, under an `ERROR` heading, as the JSON
|
|
17
|
+
// HTTP sends, such as `{"_tag":"NoteNotFound","id":"9"}`, and the command exits 1.
|
|
18
|
+
Command.run(notes, { version: "1.0.0" }).pipe(
|
|
19
|
+
Effect.provide([NodeHttpClient.layerUndici, NodeServices.layer]),
|
|
20
|
+
ActionCli.logToStderr,
|
|
21
|
+
NodeRuntime.runMain,
|
|
22
|
+
);
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { Config, Console, Effect, FileSystem, flow } from "effect";
|
|
2
|
+
import { Argument, CliError, Command } from "effect/cli";
|
|
3
|
+
import { HttpClient, HttpClientRequest } from "effect/http";
|
|
4
|
+
import * as ActionCli from "@gjermundgaraba/effect-actions/ActionCli";
|
|
5
|
+
import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
|
|
6
|
+
import { Http } from "./Api.js";
|
|
7
|
+
|
|
8
|
+
// A command of your own, for what no single action does: this one calls two and writes a file.
|
|
9
|
+
// It is a native Effect CLI command, so its syntax and its output are what you write: `notes
|
|
10
|
+
// export notes.md` takes its argument from `Argument`, as a flag would come from `Flag`. It
|
|
11
|
+
// calls the server through the binding's typed client, on the `HttpClient` the derived commands
|
|
12
|
+
// use, and writes with the `FileSystem` every Effect CLI command has.
|
|
13
|
+
const exportNotes = Command.make(
|
|
14
|
+
"export",
|
|
15
|
+
{ file: Argument.String("file").pipe(Argument.withDescription("The Markdown file to write.")) },
|
|
16
|
+
({ file }) =>
|
|
17
|
+
Effect.gen(function* () {
|
|
18
|
+
const client = yield* ActionHttp.client(Http);
|
|
19
|
+
const files = yield* FileSystem.FileSystem;
|
|
20
|
+
const { notes } = yield* client.listNotes();
|
|
21
|
+
const whole = yield* Effect.forEach(notes, ({ id }) => client.getNote({ id }));
|
|
22
|
+
|
|
23
|
+
yield* files.writeFileString(
|
|
24
|
+
file,
|
|
25
|
+
whole.map((note) => `# ${note.title}\n\n${note.body}`).join("\n\n"),
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
yield* Console.log(`Wrote ${whole.length} notes to ${file}`);
|
|
29
|
+
}).pipe(
|
|
30
|
+
// Context of your own for a failure goes to stderr before it, on a line of its own. A
|
|
31
|
+
// `UserError` wrapped around the failure instead would put its JSON mid-sentence. The
|
|
32
|
+
// line prints for every failure of the command, a file that cannot be written included,
|
|
33
|
+
// so it says only what is true of them all.
|
|
34
|
+
Effect.tapError(() => Console.error(`The export to ${file} did not finish.`)),
|
|
35
|
+
// Once, around the whole command: it then fails as a derived command does, an action's
|
|
36
|
+
// error printed as its JSON on stderr and any other, a file that cannot be written for
|
|
37
|
+
// one, named. A failure left as it is would be reported as a crash, with an `ERROR (#n)`
|
|
38
|
+
// line and a stack trace.
|
|
39
|
+
ActionCli.toUserError(Http),
|
|
40
|
+
),
|
|
41
|
+
).pipe(Command.withDescription("Write every note to a file, as Markdown."));
|
|
42
|
+
|
|
43
|
+
// A command per action of the binding, a flag per input field, each calling the server over
|
|
44
|
+
// HTTP. It imports the binding alone, never a handler. It is apart from cli.ts so a test runs
|
|
45
|
+
// it: test/commands.test.ts.
|
|
46
|
+
export const commands = ActionCli.remote(Http, {
|
|
47
|
+
name: "notes",
|
|
48
|
+
commands: {
|
|
49
|
+
// `notes create-note --title Draft < draft.md`: the body is piped in, not a flag.
|
|
50
|
+
createNote: { stdin: "body" },
|
|
51
|
+
getNote: { positional: ["id"], render: (note) => `# ${note.title}\n\n${note.body}` },
|
|
52
|
+
// `notes update-note <id> --revision 1 < draft.md`: an argument, a flag and stdin together.
|
|
53
|
+
updateNote: { positional: ["id"], stdin: "body" },
|
|
54
|
+
},
|
|
55
|
+
// `Command.withSubcommands` on the result would replace the derived commands, so your own
|
|
56
|
+
// go here.
|
|
57
|
+
subcommands: [exportNotes],
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// Where the server is and who is calling, read from config: the `HttpClient` every command
|
|
61
|
+
// above calls with. It is an Effect, not a value, so that cli.ts provides it on the commands
|
|
62
|
+
// and it runs when one of them does.
|
|
63
|
+
export const connection = Effect.gen(function* () {
|
|
64
|
+
const url = yield* Config.String("NOTES_URL").pipe(Config.withDefault("http://127.0.0.1:3000"));
|
|
65
|
+
|
|
66
|
+
// Required, since every command but `get-status` is refused without one. A failure of what a
|
|
67
|
+
// command is provided is no action's error: left as it is, a missing token is reported as a
|
|
68
|
+
// crash, the log line `ERROR (#n): ConfigError: SchemaError(Expected string at
|
|
69
|
+
// ["NOTES_TOKEN"])`. Mapped to a `UserError`, it is printed as any failure of a command is:
|
|
70
|
+
// the message under an `ERROR` heading on stderr, and exit 1. `userMessage` is all that is
|
|
71
|
+
// printed, never `cause`: what the user needs from the cause goes into the message. A CLI
|
|
72
|
+
// whose public commands must answer signed out reads the token with `Config.option` instead
|
|
73
|
+
// and adds the header when it is there.
|
|
74
|
+
const token = yield* Config.Redacted("NOTES_TOKEN").pipe(
|
|
75
|
+
Effect.mapError(
|
|
76
|
+
(cause) =>
|
|
77
|
+
new CliError.UserError({
|
|
78
|
+
cause,
|
|
79
|
+
userMessage:
|
|
80
|
+
"NOTES_TOKEN is not set. Set it to your token: export NOTES_TOKEN=<your token>",
|
|
81
|
+
}),
|
|
82
|
+
),
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
return HttpClient.mapRequest(
|
|
86
|
+
yield* HttpClient.HttpClient,
|
|
87
|
+
flow(HttpClientRequest.prependUrl(url), HttpClientRequest.bearerToken(token)),
|
|
88
|
+
);
|
|
89
|
+
});
|