@gjermundgaraba/effect-actions 0.10.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 +103 -43
- package/dist/Action.d.mts +31 -118
- package/dist/Action.mjs +15 -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 +25 -31
- package/dist/ActionMcp.mjs +140 -74
- 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 +32 -25
- package/dist/Testing.mjs +45 -30
- 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/mcp/protocol.mjs +149 -0
- 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 -424
- package/docs/ActionCli-failures.md +102 -0
- package/docs/ActionCli.md +206 -292
- package/docs/ActionHttp-failures.md +83 -0
- package/docs/ActionHttp.md +202 -281
- package/docs/ActionMcp-failures.md +31 -0
- package/docs/ActionMcp.md +229 -193
- 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 -117
- 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 -23
- package/docs/README.md +71 -35
- package/docs/Testing-failures.md +20 -0
- package/docs/Testing.md +186 -151
- package/docs/guarantees.md +35 -95
- package/docs/setup-failures.md +34 -0
- package/docs/setup.md +88 -87
- 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 +32 -14
- 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 -11
- package/dist/internal/mcp.mjs +0 -52
- 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,31 @@
|
|
|
1
|
+
import { Context, Schema } from "effect";
|
|
2
|
+
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
3
|
+
import * as Authentication from "@gjermundgaraba/effect-actions/Authentication";
|
|
4
|
+
|
|
5
|
+
export const scopes = { read: "notes:read", write: "notes:write" } as const;
|
|
6
|
+
|
|
7
|
+
export const Scope = Schema.Literals([scopes.read, scopes.write]);
|
|
8
|
+
|
|
9
|
+
export interface User {
|
|
10
|
+
readonly id: string;
|
|
11
|
+
readonly scopes: ReadonlyArray<typeof Scope.Type>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Who is calling. A protected action declares it, `caller: CurrentUser`; authentication
|
|
16
|
+
* provides it per request, and a local host, such as the stdio server, provides it itself.
|
|
17
|
+
*/
|
|
18
|
+
export class CurrentUser extends Context.Service<CurrentUser, User>()(
|
|
19
|
+
"notes-service/CurrentUser",
|
|
20
|
+
) {}
|
|
21
|
+
|
|
22
|
+
// How a remote caller proves it is CurrentUser: a bearer token, the default scheme. Clients
|
|
23
|
+
// import this descriptor with the binding; its verifier, in verifier.ts, stays on the server.
|
|
24
|
+
export const Login = Authentication.make("notes-service.Login", CurrentUser);
|
|
25
|
+
|
|
26
|
+
export const WhoAmI = Action.make("whoAmI", {
|
|
27
|
+
description: "Show who the credential identifies and which scopes it holds.",
|
|
28
|
+
success: { id: Schema.String, scopes: Schema.Array(Scope) },
|
|
29
|
+
readOnly: true,
|
|
30
|
+
caller: CurrentUser,
|
|
31
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
3
|
+
import { CurrentUser, scopes } from "./Identity.js";
|
|
4
|
+
|
|
5
|
+
// The one rule, read from each contract: a read needs the read scope, anything else the write
|
|
6
|
+
// scope. Every implementation of protected actions states it, so HTTP, MCP and stdio all run it
|
|
7
|
+
// before the handler. Naming the missing scope lets an OAuth client ask for it and retry: it is
|
|
8
|
+
// answered a challenge, which only a server publishing discovery (verifier.ts) can follow up.
|
|
9
|
+
// Where tokens are fixed, such as API keys, name no scope: the refusal is then a plain one.
|
|
10
|
+
// So `scopes` below goes when `protectedResource` in verifier.ts does: copy both or neither.
|
|
11
|
+
export const authorize = Effect.fn("authorize")(function* (action: Action.Any) {
|
|
12
|
+
const scope = action.readOnly ? scopes.read : scopes.write;
|
|
13
|
+
const user = yield* CurrentUser;
|
|
14
|
+
|
|
15
|
+
if (!user.scopes.includes(scope)) {
|
|
16
|
+
return yield* new Action.Forbidden({ message: `Requires ${scope}.`, scopes: [scope] });
|
|
17
|
+
}
|
|
18
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
3
|
+
import { authorize } from "./authorize.js";
|
|
4
|
+
import { CurrentUser, WhoAmI } from "./Identity.js";
|
|
5
|
+
|
|
6
|
+
export const identity = Action.implement(
|
|
7
|
+
WhoAmI,
|
|
8
|
+
() => Effect.map(CurrentUser, ({ id, scopes }) => ({ id, scopes })),
|
|
9
|
+
{ authorize },
|
|
10
|
+
);
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { scopes, type User } from "./Identity.js";
|
|
2
|
+
|
|
3
|
+
// DEMO ONLY: the people who may sign in, each under a token that is their name. A real service
|
|
4
|
+
// has no such table: its verifier reads the user from a signed token.
|
|
5
|
+
export const users = {
|
|
6
|
+
ada: { id: "ada", scopes: [scopes.read, scopes.write] },
|
|
7
|
+
grace: { id: "grace", scopes: [scopes.read, scopes.write] },
|
|
8
|
+
reader: { id: "reader", scopes: [scopes.read] },
|
|
9
|
+
} as const satisfies Readonly<Record<string, User>>;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Config, Effect, Redacted } from "effect";
|
|
2
|
+
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
3
|
+
import * as Authentication from "@gjermundgaraba/effect-actions/Authentication";
|
|
4
|
+
import { Login, scopes } from "./Identity.js";
|
|
5
|
+
import { users } from "./users.js";
|
|
6
|
+
|
|
7
|
+
const isToken = (token: string): token is keyof typeof users => Object.hasOwn(users, token);
|
|
8
|
+
|
|
9
|
+
// DEMO ONLY: a token is a user's name. A real verifier checks a signed token with your
|
|
10
|
+
// authorization server's library, its audience included. An unknown token is the built-in
|
|
11
|
+
// `Unauthenticated`: a 401 every client decodes.
|
|
12
|
+
const verify = (token: Redacted.Redacted<string>) => {
|
|
13
|
+
const name = Redacted.value(token);
|
|
14
|
+
|
|
15
|
+
return isToken(name)
|
|
16
|
+
? Effect.succeed(users[name])
|
|
17
|
+
: Effect.fail(new Action.Unauthenticated({ message: "Unknown demo token." }));
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// Server only: `Login`'s provider. Every layer serving a protected action requires it and
|
|
21
|
+
// authenticates before it decodes the input. As an OAuth protected resource it also publishes
|
|
22
|
+
// discovery, so an MCP client that gets a 401 finds where to sign in, asking for read first.
|
|
23
|
+
// The resource is the server's public URL, known only at startup, so it is read from config.
|
|
24
|
+
export const authenticate = Authentication.layer(Login, verify, {
|
|
25
|
+
protectedResource: Effect.map(
|
|
26
|
+
Config.String("PUBLIC_URL").pipe(Config.withDefault("http://localhost:3000")),
|
|
27
|
+
(url) =>
|
|
28
|
+
({
|
|
29
|
+
resource: `${url}/mcp`,
|
|
30
|
+
// DEMO ONLY: no server is there. Yours is your authorization server's issuer.
|
|
31
|
+
authorizationServers: ["https://auth.example.com"],
|
|
32
|
+
scopesSupported: [scopes.read, scopes.write],
|
|
33
|
+
scopesRequired: [scopes.read],
|
|
34
|
+
}) satisfies Authentication.ProtectedResource,
|
|
35
|
+
),
|
|
36
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
3
|
+
import { CurrentUser } from "../identity/Identity.js";
|
|
4
|
+
|
|
5
|
+
export const Note = Schema.Struct({
|
|
6
|
+
id: Schema.String,
|
|
7
|
+
title: Schema.String,
|
|
8
|
+
body: Schema.String,
|
|
9
|
+
revision: Schema.Finite,
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
export type Note = typeof Note.Type;
|
|
13
|
+
|
|
14
|
+
// A declared error is part of the contract: every client decodes it as this class, and its
|
|
15
|
+
// status is what HTTP answers with.
|
|
16
|
+
export class NoteNotFound extends Schema.TaggedError<NoteNotFound>()(
|
|
17
|
+
"NoteNotFound",
|
|
18
|
+
{ id: Schema.String },
|
|
19
|
+
{ httpApiStatus: 404 },
|
|
20
|
+
) {}
|
|
21
|
+
|
|
22
|
+
export class NoteChanged extends Schema.TaggedError<NoteChanged>()(
|
|
23
|
+
"NoteChanged",
|
|
24
|
+
{ id: Schema.String, revision: Schema.Finite },
|
|
25
|
+
{ httpApiStatus: 409 },
|
|
26
|
+
) {}
|
|
27
|
+
|
|
28
|
+
// A description on an input field is that field's help on every surface: beside its flag or
|
|
29
|
+
// argument in a command's `--help`, and in the input schema a model reads from the MCP tool
|
|
30
|
+
// listing and the OpenAPI document. A field without one has a flag with no help.
|
|
31
|
+
const Id = Schema.String.annotate({
|
|
32
|
+
description: "The note's id, as the list of your notes shows it.",
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const Title = Schema.String.check(Schema.isMinLength(1)).annotate({
|
|
36
|
+
description: "A short name for the note, at least one character.",
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const Body = Schema.String.annotate({ description: "The note's whole text." });
|
|
40
|
+
|
|
41
|
+
export const ListNotes = Action.make("listNotes", {
|
|
42
|
+
description: "List your notes, without their bodies.",
|
|
43
|
+
success: { notes: Schema.Array(Schema.Struct({ id: Schema.String, title: Schema.String })) },
|
|
44
|
+
readOnly: true,
|
|
45
|
+
caller: CurrentUser,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
export const GetNote = Action.make("getNote", {
|
|
49
|
+
description: "Read one of your notes.",
|
|
50
|
+
input: { id: Id },
|
|
51
|
+
success: Note,
|
|
52
|
+
error: NoteNotFound,
|
|
53
|
+
readOnly: true,
|
|
54
|
+
caller: CurrentUser,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
export const CreateNote = Action.make("createNote", {
|
|
58
|
+
description: "Create a note.",
|
|
59
|
+
input: { title: Title, body: Body },
|
|
60
|
+
success: Note,
|
|
61
|
+
readOnly: false,
|
|
62
|
+
caller: CurrentUser,
|
|
63
|
+
mcp: { destructiveHint: false },
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
export const UpdateNote = Action.make("updateNote", {
|
|
67
|
+
description:
|
|
68
|
+
"Replace a note's body. Pass the revision you read: the update is refused if the note changed since.",
|
|
69
|
+
input: {
|
|
70
|
+
id: Id,
|
|
71
|
+
body: Body.annotate({ description: "The text that replaces the note's body." }),
|
|
72
|
+
revision: Schema.Finite.annotate({
|
|
73
|
+
description: "The revision you read: 1 for a new note, one more after each update.",
|
|
74
|
+
}),
|
|
75
|
+
},
|
|
76
|
+
success: Note,
|
|
77
|
+
error: [NoteNotFound, NoteChanged],
|
|
78
|
+
readOnly: false,
|
|
79
|
+
caller: CurrentUser,
|
|
80
|
+
mcp: { destructiveHint: false },
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
export const DeleteNote = Action.make("deleteNote", {
|
|
84
|
+
description: "Delete a note for good.",
|
|
85
|
+
input: { id: Id },
|
|
86
|
+
error: NoteNotFound,
|
|
87
|
+
readOnly: false,
|
|
88
|
+
caller: CurrentUser,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
export const Notes = [ListNotes, GetNote, CreateNote, UpdateNote, DeleteNote] as const;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
3
|
+
import { authorize } from "../identity/authorize.js";
|
|
4
|
+
import { CurrentUser } from "../identity/Identity.js";
|
|
5
|
+
import { Notes } from "./Notes.js";
|
|
6
|
+
import { NoteStore } from "./store.js";
|
|
7
|
+
|
|
8
|
+
// The builder runs once, at startup, however many surfaces serve these actions: it is where a
|
|
9
|
+
// handler gets its services. `CurrentUser` is read per call. Each handler is a thin step from
|
|
10
|
+
// the contract to the store, and none checks a scope: `authorize` has run before it.
|
|
11
|
+
export const notes = Action.implement(
|
|
12
|
+
Notes,
|
|
13
|
+
Effect.gen(function* () {
|
|
14
|
+
const store = yield* NoteStore;
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
listNotes: () =>
|
|
18
|
+
Effect.gen(function* () {
|
|
19
|
+
const user = yield* CurrentUser;
|
|
20
|
+
const owned = yield* store.list(user.id);
|
|
21
|
+
|
|
22
|
+
return { notes: owned.map(({ id, title }) => ({ id, title })) };
|
|
23
|
+
}),
|
|
24
|
+
getNote: ({ id }) => Effect.flatMap(CurrentUser, (user) => store.get(user.id, id)),
|
|
25
|
+
createNote: ({ title, body }) =>
|
|
26
|
+
Effect.flatMap(CurrentUser, (user) => store.create(user.id, title, body)),
|
|
27
|
+
updateNote: ({ id, body, revision }) =>
|
|
28
|
+
Effect.flatMap(CurrentUser, (user) => store.update(user.id, id, body, revision)),
|
|
29
|
+
deleteNote: ({ id }) => Effect.flatMap(CurrentUser, (user) => store.remove(user.id, id)),
|
|
30
|
+
};
|
|
31
|
+
}),
|
|
32
|
+
{ authorize },
|
|
33
|
+
);
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { Context, Effect, Layer } from "effect";
|
|
2
|
+
import { type Note, NoteChanged, NoteNotFound } from "./Notes.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Where notes live, each owned by one user. Ownership is checked here, in the data access, so no
|
|
6
|
+
* surface and no handler can skip it.
|
|
7
|
+
*/
|
|
8
|
+
export class NoteStore extends Context.Service<
|
|
9
|
+
NoteStore,
|
|
10
|
+
{
|
|
11
|
+
readonly list: (owner: string) => Effect.Effect<ReadonlyArray<Note>>;
|
|
12
|
+
readonly get: (owner: string, id: string) => Effect.Effect<Note, NoteNotFound>;
|
|
13
|
+
readonly create: (owner: string, title: string, body: string) => Effect.Effect<Note>;
|
|
14
|
+
readonly update: (
|
|
15
|
+
owner: string,
|
|
16
|
+
id: string,
|
|
17
|
+
body: string,
|
|
18
|
+
revision: number,
|
|
19
|
+
) => Effect.Effect<Note, NoteNotFound | NoteChanged>;
|
|
20
|
+
readonly remove: (owner: string, id: string) => Effect.Effect<void, NoteNotFound>;
|
|
21
|
+
readonly count: Effect.Effect<number>;
|
|
22
|
+
}
|
|
23
|
+
>()("notes-service/NoteStore") {
|
|
24
|
+
// DEMO ONLY: memory, lost when the process stops. A real store is another Layer of this
|
|
25
|
+
// service, chosen by the entrypoint.
|
|
26
|
+
static readonly layerMemory = Layer.sync(NoteStore, () => {
|
|
27
|
+
// One map per owner: a note is only ever looked up among its owner's, whatever id is asked.
|
|
28
|
+
const notes = new Map<string, Map<string, Note>>();
|
|
29
|
+
|
|
30
|
+
const ownedBy = (owner: string) => notes.get(owner) ?? new Map<string, Note>();
|
|
31
|
+
|
|
32
|
+
const save = (owner: string, note: Note) => {
|
|
33
|
+
notes.set(owner, ownedBy(owner).set(note.id, note));
|
|
34
|
+
|
|
35
|
+
return note;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const get = Effect.fn("NoteStore.get")(function* (owner: string, id: string) {
|
|
39
|
+
const note = ownedBy(owner).get(id);
|
|
40
|
+
|
|
41
|
+
if (note === undefined) return yield* new NoteNotFound({ id });
|
|
42
|
+
|
|
43
|
+
return note;
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
return NoteStore.of({
|
|
47
|
+
list: (owner) => Effect.sync(() => Array.from(ownedBy(owner).values())),
|
|
48
|
+
get,
|
|
49
|
+
create: (owner, title, body) =>
|
|
50
|
+
Effect.sync(() => save(owner, { id: crypto.randomUUID(), title, body, revision: 1 })),
|
|
51
|
+
// No `yield*` between the read and the write: a fiber may be set aside at one, and two
|
|
52
|
+
// updates that each read revision 1 before either wrote would both succeed. A database
|
|
53
|
+
// does this with a conditional update or a transaction.
|
|
54
|
+
update: Effect.fn("NoteStore.update")(function* (owner, id, body, revision) {
|
|
55
|
+
const current = ownedBy(owner).get(id);
|
|
56
|
+
|
|
57
|
+
if (current === undefined) return yield* new NoteNotFound({ id });
|
|
58
|
+
|
|
59
|
+
if (current.revision !== revision) {
|
|
60
|
+
return yield* new NoteChanged({ id, revision: current.revision });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return save(owner, { ...current, body, revision: revision + 1 });
|
|
64
|
+
}),
|
|
65
|
+
remove: Effect.fn("NoteStore.remove")(function* (owner, id) {
|
|
66
|
+
if (!ownedBy(owner).delete(id)) return yield* new NoteNotFound({ id });
|
|
67
|
+
}),
|
|
68
|
+
count: Effect.sync(() =>
|
|
69
|
+
Array.from(notes.values()).reduce((total, owned) => total + owned.size, 0),
|
|
70
|
+
),
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Layer } from "effect";
|
|
2
|
+
import { HttpRouter, HttpServerResponse } from "effect/http";
|
|
3
|
+
import { OpenApi } from "effect/http-api";
|
|
4
|
+
import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
|
|
5
|
+
import * as ActionMcp from "@gjermundgaraba/effect-actions/ActionMcp";
|
|
6
|
+
import { Http } from "./Api.js";
|
|
7
|
+
import { identity } from "./identity/handlers.js";
|
|
8
|
+
import { Login } from "./identity/Identity.js";
|
|
9
|
+
import { authenticate } from "./identity/verifier.js";
|
|
10
|
+
import { notes } from "./notes/handlers.js";
|
|
11
|
+
import { status } from "./status/handlers.js";
|
|
12
|
+
|
|
13
|
+
// Every domain's implementation, once. Each surface below takes the same list.
|
|
14
|
+
const implementations = [status, notes, identity];
|
|
15
|
+
|
|
16
|
+
// JSON routes at `/api/<action>`, the binding's. A protected route authenticates before it
|
|
17
|
+
// reads its body; `getStatus` answers anyone.
|
|
18
|
+
const api = ActionHttp.layer(Http, implementations);
|
|
19
|
+
|
|
20
|
+
// The same actions as MCP tools at `/mcp`. Listing the tools and calling `getStatus` need no
|
|
21
|
+
// token, because `getStatus` is public: an endpoint of protected tools alone asks a token for
|
|
22
|
+
// the listing too. Any other call answers 401 with where to sign in, and so does a client of
|
|
23
|
+
// an MCP revision with sessions when it opens one, since a session may call any tool.
|
|
24
|
+
const mcp = ActionMcp.layerHttp(implementations, {
|
|
25
|
+
name: "notes-service",
|
|
26
|
+
version: "1.0.0",
|
|
27
|
+
instructions: "Notes belong to the signed-in user. Read a note before updating it.",
|
|
28
|
+
authentication: Login,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// The host's own routes sit beside the actions' on the same router.
|
|
32
|
+
const health = HttpRouter.add("GET", "/healthz", HttpServerResponse.text("ok"));
|
|
33
|
+
|
|
34
|
+
// Served to anyone, which is a choice: it tells a signed-out caller every action's name and
|
|
35
|
+
// shape. To keep them, leave the route out, or provide `Authentication.protect(Login).layer`
|
|
36
|
+
// to it.
|
|
37
|
+
const openApi = HttpRouter.add(
|
|
38
|
+
"GET",
|
|
39
|
+
"/api/openapi.json",
|
|
40
|
+
HttpServerResponse.jsonUnsafe(OpenApi.fromApi(Http.api)),
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
// What the host serves. It still requires `NoteStore`: the entrypoint chooses which one, and a
|
|
44
|
+
// test chooses its own.
|
|
45
|
+
export const routes = Layer.mergeAll(api, mcp, health, openApi).pipe(Layer.provide(authenticate));
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { NodeHttpServer, NodeRuntime } from "@effect/platform-node";
|
|
3
|
+
import { ByteSize, Config, Effect, Layer } from "effect";
|
|
4
|
+
import { HttpRouter, HttpServerRequest } from "effect/http";
|
|
5
|
+
import { NoteStore } from "./notes/store.js";
|
|
6
|
+
import { routes } from "./routes.js";
|
|
7
|
+
|
|
8
|
+
// Entrypoint: `node --import tsx examples/notes-service/src/server.ts`.
|
|
9
|
+
// This is the composition root of the server. It chooses the store, reads the configuration
|
|
10
|
+
// and starts Node's HTTP server; nothing below it knows which of those it got.
|
|
11
|
+
const server = Layer.unwrap(
|
|
12
|
+
Effect.gen(function* () {
|
|
13
|
+
const port = yield* Config.Port("PORT").pipe(Config.withDefault(3000));
|
|
14
|
+
|
|
15
|
+
return HttpRouter.serve(routes).pipe(
|
|
16
|
+
// `127.0.0.1` serves this machine alone. A server others reach reads its host from
|
|
17
|
+
// config: `Config.String("HOST").pipe(Config.withDefault("127.0.0.1"))`.
|
|
18
|
+
Layer.provide(NodeHttpServer.layer(createServer, { host: "127.0.0.1", port })),
|
|
19
|
+
// Request bodies have no size limit unless the host sets one. This covers every route.
|
|
20
|
+
Layer.provide(Layer.succeed(HttpServerRequest.MaxBodySize, ByteSize.mebibytes(1))),
|
|
21
|
+
Layer.provide(NoteStore.layerMemory),
|
|
22
|
+
);
|
|
23
|
+
}),
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
Layer.launch(server).pipe(NodeRuntime.runMain);
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
3
|
+
|
|
4
|
+
// Public: `caller: Action.Anyone`. It answers before anyone has signed in, on every surface.
|
|
5
|
+
export const GetStatus = Action.make("getStatus", {
|
|
6
|
+
description: "Report whether the service is up and how many notes it holds.",
|
|
7
|
+
success: { service: Schema.String, notes: Schema.Finite },
|
|
8
|
+
readOnly: true,
|
|
9
|
+
caller: Action.Anyone,
|
|
10
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
3
|
+
import { NoteStore } from "../notes/store.js";
|
|
4
|
+
import { GetStatus } from "./Status.js";
|
|
5
|
+
|
|
6
|
+
// A public action takes no `authorize`: nothing would run it.
|
|
7
|
+
export const status = Action.implement(
|
|
8
|
+
GetStatus,
|
|
9
|
+
Effect.gen(function* () {
|
|
10
|
+
const store = yield* NoteStore;
|
|
11
|
+
|
|
12
|
+
return () => Effect.map(store.count, (notes) => ({ service: "notes-service", notes }));
|
|
13
|
+
}),
|
|
14
|
+
);
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { NodeRuntime, NodeStdio } from "@effect/platform-node";
|
|
2
|
+
import { Effect } from "effect";
|
|
3
|
+
import * as ActionCli from "@gjermundgaraba/effect-actions/ActionCli";
|
|
4
|
+
import * as ActionMcp from "@gjermundgaraba/effect-actions/ActionMcp";
|
|
5
|
+
import { identity } from "./identity/handlers.js";
|
|
6
|
+
import { CurrentUser } from "./identity/Identity.js";
|
|
7
|
+
import { users } from "./identity/users.js";
|
|
8
|
+
import { notes } from "./notes/handlers.js";
|
|
9
|
+
import { NoteStore } from "./notes/store.js";
|
|
10
|
+
import { status } from "./status/handlers.js";
|
|
11
|
+
|
|
12
|
+
// Entrypoint: an MCP server a local agent starts as a subprocess, such as
|
|
13
|
+
// `claude mcp add notes -- node --import tsx examples/notes-service/src/stdio.ts`.
|
|
14
|
+
// The same implementations as the HTTP server, and the same `authorize`. No token arrives
|
|
15
|
+
// over stdio, so this host names the caller itself. It is a process of its own with services
|
|
16
|
+
// of its own: this in-memory store holds its notes, not the running server's. To share them,
|
|
17
|
+
// both provide a store they both reach, such as a database.
|
|
18
|
+
ActionMcp.runStdio([status, notes, identity], { name: "notes-service", version: "1.0.0" }).pipe(
|
|
19
|
+
// DEMO ONLY: a real host reads who started it, from its own login or configuration.
|
|
20
|
+
Effect.provideService(CurrentUser, users.ada),
|
|
21
|
+
Effect.provide([NodeStdio.layer, NoteStore.layerMemory]),
|
|
22
|
+
// stdout carries the protocol alone, so logs and failures go to stderr.
|
|
23
|
+
ActionCli.logToStderr,
|
|
24
|
+
NodeRuntime.runMain,
|
|
25
|
+
);
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { NodeServices } from "@effect/platform-node";
|
|
2
|
+
import { describe, expect, it } from "@effect/vitest";
|
|
3
|
+
import { ConfigProvider, Effect, FileSystem, Layer, Schema, Stdio, Stream } from "effect";
|
|
4
|
+
import { CliError, Command } from "effect/cli";
|
|
5
|
+
import { HttpClient, HttpClientRequest } from "effect/http";
|
|
6
|
+
import { TestConsole } from "effect/testing";
|
|
7
|
+
import * as Testing from "@gjermundgaraba/effect-actions/Testing";
|
|
8
|
+
import { commands, connection } from "../src/commands.js";
|
|
9
|
+
import { Note, NoteNotFound } from "../src/notes/Notes.js";
|
|
10
|
+
import { NoteStore } from "../src/notes/store.js";
|
|
11
|
+
import { routes } from "../src/routes.js";
|
|
12
|
+
|
|
13
|
+
// What the commands add to the routes: their syntax and what they print. They call the real
|
|
14
|
+
// routes in memory, as a client with a token does, where cli.ts connects them to a server.
|
|
15
|
+
const served = Testing.layer(routes).pipe(Layer.provide(NoteStore.layerMemory));
|
|
16
|
+
|
|
17
|
+
// Effect's own runner, given arguments instead of the process's. The console of `it.effect`
|
|
18
|
+
// keeps every line printed, so a run returns the lines it added.
|
|
19
|
+
const run = (token: string, args: ReadonlyArray<string>, stdin = "") =>
|
|
20
|
+
Effect.gen(function* () {
|
|
21
|
+
const before = (yield* TestConsole.logLines).length;
|
|
22
|
+
|
|
23
|
+
yield* Command.runWith(commands, { version: "0" })(args).pipe(
|
|
24
|
+
Effect.updateService(
|
|
25
|
+
HttpClient.HttpClient,
|
|
26
|
+
HttpClient.mapRequest(HttpClientRequest.bearerToken(token)),
|
|
27
|
+
),
|
|
28
|
+
Effect.provide(Stdio.layerTest({ stdin: Stream.make(new TextEncoder().encode(stdin)) })),
|
|
29
|
+
Effect.provide(NodeServices.layer),
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
return (yield* TestConsole.logLines).slice(before);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// The commands as cli.ts connects them, with what it reads from the environment given as a
|
|
36
|
+
// Layer, so the test decides whether a token is configured, whatever the shell holds.
|
|
37
|
+
const connected = (environment: Record<string, string>, args: ReadonlyArray<string>) =>
|
|
38
|
+
Command.runWith(commands.pipe(Command.provideEffect(HttpClient.HttpClient, connection)), {
|
|
39
|
+
version: "0",
|
|
40
|
+
})(args).pipe(
|
|
41
|
+
Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnvRecord(environment))),
|
|
42
|
+
Effect.provide(NodeServices.layer),
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
describe("the commands", () => {
|
|
46
|
+
it.effect("take an argument, a flag and stdin, and print what `render` returns", () =>
|
|
47
|
+
Effect.gen(function* () {
|
|
48
|
+
// A command without `render` prints its result as JSON.
|
|
49
|
+
const [created] = yield* run("ada", ["create-note", "--title", "Plan"], "First draft\n");
|
|
50
|
+
const { id } = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(Note))(created);
|
|
51
|
+
|
|
52
|
+
expect(yield* run("ada", ["get-note", id])).toEqual(["# Plan\n\nFirst draft"]);
|
|
53
|
+
|
|
54
|
+
yield* run("ada", ["update-note", id, "--revision", "1"], "Second draft\n");
|
|
55
|
+
|
|
56
|
+
expect(yield* run("ada", ["get-note", id])).toEqual(["# Plan\n\nSecond draft"]);
|
|
57
|
+
}).pipe(Effect.provide(served)),
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
it.effect("describe a flag in `--help` as the contract describes its field", () =>
|
|
61
|
+
Effect.gen(function* () {
|
|
62
|
+
// Help is one printed text. The description is the `revision` field's, from Notes.ts.
|
|
63
|
+
expect(yield* run("ada", ["update-note", "--help"])).toEqual([
|
|
64
|
+
expect.stringContaining("The revision you read: 1 for a new note"),
|
|
65
|
+
]);
|
|
66
|
+
}).pipe(Effect.provide(served)),
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
it.effect("run a command of their own beside the derived ones", () =>
|
|
70
|
+
Effect.gen(function* () {
|
|
71
|
+
yield* run("ada", ["create-note", "--title", "Plan"], "First draft\n");
|
|
72
|
+
yield* run("ada", ["create-note", "--title", "Budget"], "Too small\n");
|
|
73
|
+
|
|
74
|
+
// The command writes with the real file system, so the test gives it a real directory.
|
|
75
|
+
const files = yield* FileSystem.FileSystem;
|
|
76
|
+
const file = `${yield* files.makeTempDirectoryScoped()}/notes.md`;
|
|
77
|
+
|
|
78
|
+
expect(yield* run("ada", ["export", file])).toEqual([`Wrote 2 notes to ${file}`]);
|
|
79
|
+
expect(yield* files.readFileString(file)).toBe(
|
|
80
|
+
"# Plan\n\nFirst draft\n\n# Budget\n\nToo small",
|
|
81
|
+
);
|
|
82
|
+
}).pipe(Effect.provide(served), Effect.provide(NodeServices.layer)),
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
it.effect("fail as the server refuses, a command of their own included", () =>
|
|
86
|
+
Effect.gen(function* () {
|
|
87
|
+
const refused = yield* Effect.flip(run("mallory", ["export", "notes.md"]));
|
|
88
|
+
|
|
89
|
+
expect(refused).toBeInstanceOf(CliError.UserError);
|
|
90
|
+
expect(refused).toHaveProperty(
|
|
91
|
+
"userMessage",
|
|
92
|
+
'{"_tag":"Unauthenticated","message":"Unknown demo token."}',
|
|
93
|
+
);
|
|
94
|
+
// The command's own context is a line of its own on stderr, the JSON left as it is.
|
|
95
|
+
expect(yield* TestConsole.errorLines).toContain("The export to notes.md did not finish.");
|
|
96
|
+
|
|
97
|
+
// Not an action's error: the file system's, named in the message.
|
|
98
|
+
const unwritable = yield* Effect.flip(run("ada", ["export", "no-such-directory/notes.md"]));
|
|
99
|
+
|
|
100
|
+
expect(unwritable).toBeInstanceOf(CliError.UserError);
|
|
101
|
+
expect(unwritable).toHaveProperty("userMessage", expect.stringMatching(/^PlatformError: /));
|
|
102
|
+
|
|
103
|
+
const missing = yield* Effect.flip(run("ada", ["get-note", "9"]));
|
|
104
|
+
|
|
105
|
+
expect(missing).toHaveProperty("cause", new NoteNotFound({ id: "9" }));
|
|
106
|
+
}).pipe(Effect.provide(served)),
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
it.effect("call as the configured token names, and ask for one where none is configured", () =>
|
|
110
|
+
Effect.gen(function* () {
|
|
111
|
+
yield* connected({ NOTES_TOKEN: "reader" }, ["who-am-i"]);
|
|
112
|
+
|
|
113
|
+
expect(yield* TestConsole.logLines).toEqual([expect.stringContaining('"id": "reader"')]);
|
|
114
|
+
|
|
115
|
+
const refused = yield* Effect.flip(connected({}, ["list-notes"]));
|
|
116
|
+
|
|
117
|
+
expect(refused).toBeInstanceOf(CliError.UserError);
|
|
118
|
+
expect(refused).toHaveProperty(
|
|
119
|
+
"userMessage",
|
|
120
|
+
"NOTES_TOKEN is not set. Set it to your token: export NOTES_TOKEN=<your token>",
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
// What a person sees: that message, once, on stderr, and no report of a `ConfigError`.
|
|
124
|
+
expect(yield* TestConsole.errorLines).toEqual([
|
|
125
|
+
expect.stringContaining("NOTES_TOKEN is not set."),
|
|
126
|
+
]);
|
|
127
|
+
|
|
128
|
+
// The token is read when a command runs, so its help needs none.
|
|
129
|
+
yield* connected({}, ["list-notes", "--help"]);
|
|
130
|
+
|
|
131
|
+
expect(yield* TestConsole.logLines).toHaveLength(2);
|
|
132
|
+
}).pipe(Effect.provide(served)),
|
|
133
|
+
);
|
|
134
|
+
});
|