Skip to content

Commit 2e24da7

Browse files
committed
Migrate to @executor-js/emulate 0.7.0's typed control-plane client
0.7.0 ships EmulatorClient + connectEmulator: one typed handle over the /_emulate control plane (credentials.mint, ledger.list/clear, seed, reset, manifest, ...) wherever the emulator runs — locally spawned or hosted. Every hand-rolled fetch + response cast goes away: - connect-handoff: mints via credentials.mint and asserts against LedgerEntry[] (match on request.body) instead of substring-searching the raw ledger text. - targets/cloud setAccessTokenTtl: client.seed() instead of a raw POST. - CLI ledger command: typed entries, JSON output. - emulate skill: the client table replaces the raw-endpoint table as the primary interface; raw /_emulate stays documented for curl use. Also folds the CLI onto src/ports.ts claimPorts (from the worktree-env PR this branch now stacks on) — the CLI's ad-hoc probe-and-walk is gone; a held block lock marks the instance and releases on down. Verified: connect-handoff green on selfhost + cloud (the ledger assertion exercises the hosted resend emulator's typed client), the token-expiry mcp-execute scenario green on cloud (exercises seed).
1 parent 2b73285 commit 2e24da7

6 files changed

Lines changed: 66 additions & 60 deletions

File tree

.claude/skills/emulate/SKILL.md

Lines changed: 37 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -17,55 +17,58 @@ auth code.
1717

1818
## Two ways to get one
1919

20-
**Local, programmatic** (what `e2e/setup/cloud.globalsetup.ts` does):
20+
**Local, programmatic** (what `e2e/setup/cloud.boot.ts` does):
2121

2222
```ts
2323
import { createEmulator } from "@executor-js/emulate";
2424
const github = await createEmulator({ service: "github", port: 4501 });
25-
// github.url, github.reset(), await github.close()
25+
// github.url, await github.close() — plus the full typed client below
2626
```
2727

2828
`baseUrl` sets the _advertised_ origin (redirects, form actions, spec
2929
`servers`) when a proxy fronts the emulator — the bind stays on `port`.
3030

31-
**Hosted, zero-setup** — every service runs on Cloudflare with Durable
32-
Object state:
31+
**Attach to a running one** (another process, or hosted on Cloudflare with
32+
Durable Object state at `https://<service>.emulators.dev` /
33+
`https://<service>.<instance>.emulators.dev` — catalog at
34+
`GET https://emulators.dev/_emulate/services`):
3335

36+
```ts
37+
import { connectEmulator } from "@executor-js/emulate";
38+
const resend = await connectEmulator({ baseUrl: "https://resend.emulators.dev" });
39+
// optional: { service: "resend" } verifies the manifest on connect
3440
```
35-
https://<service>.emulators.dev # service host
36-
https://<service>.<instance>.emulators.dev # your own stateful instance
37-
GET https://emulators.dev/_emulate/services # machine-readable catalog
38-
```
3941

40-
Create a private instance with `POST /_emulate/instances` on the service
41-
host. The e2e `connect-handoff` scenario uses `https://resend.emulators.dev`
42-
directly.
42+
Create a private hosted instance with `POST /_emulate/instances` on the
43+
service host.
44+
45+
## The typed control-plane client
4346

44-
## The control plane: `/_emulate/*`
47+
Both `createEmulator` and `connectEmulator` return the same `EmulatorClient`
48+
surface (since 0.7.0) — use it instead of hand-rolling `/_emulate` fetches:
4549

46-
Every running emulator self-describes. Start at `GET /_emulate/quickstart`
47-
(plain-text, written for agents) or `GET /_emulate/manifest` (machine-readable:
48-
surfaces, auth capabilities, per-operation spec coverage, connection snippets).
50+
| Call | Use |
51+
| ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ |
52+
| `client.openapiUrl` | The spec URL — feed it straight to Executor's addSpec to register the emulator as an integration |
53+
| `client.credentials.mint({type:"api-key"})` | `IssuedCredential` in the service's real shape: API keys, bearer tokens, OAuth/OIDC clients, client-credentials apps |
54+
| `client.ledger.list()` / `.clear()` | `LedgerEntry[]`: matched operationId, sanitized headers/body, auth identity, response status, webhook deliveries |
55+
| `client.seed({...})` | Add state via the service's seed schema (e.g. WorkOS `{oauth:{default_access_token_ttl_seconds:60}}` to compress token expiry) |
56+
| `client.reset()` | Reset state + logs, replay seed — works remotely, unlike the old local-only reset |
57+
| `client.manifest()` / `.quickstart()` / `.specs()` / `.coverage()` | What the service is, which operations are real vs partial |
58+
| `client.state()` / `.logs()` / `.connections()` | Store snapshot, webhook deliveries, copyable SDK/env/curl snippets |
4959

50-
| Endpoint | Use |
51-
| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
52-
| `GET /_emulate/openapi` | A real OpenAPI document for the service — feed it straight to Executor's addSpec to register the emulator as an integration |
53-
| `POST /_emulate/credentials` | Mint a credential in the service's real shape: `{"type":"api-key"}`, bearer tokens, OAuth/OIDC clients, client-credentials apps |
54-
| `GET /_emulate/ledger` | Request ledger: matched operationId, sanitized headers/body, auth identity, response status, webhook deliveries. `DELETE` clears it |
55-
| `POST /_emulate/seed` | Add state via the service's seed schema (e.g. WorkOS `{"oauth":{"default_access_token_ttl_seconds":60}}` to compress token expiry) |
56-
| `POST /_emulate/reset` | Reset state + logs, replay seed |
57-
| `GET /_emulate/state` | Current store snapshot |
58-
| `GET /_emulate/coverage` | Which operations are implemented vs partial |
59-
| `GET /_emulate/connections` | Copyable SDK/env/curl snippets resolved against this instance |
60+
The same routes exist as raw HTTP under `/_emulate/*` (start at
61+
`GET /_emulate/quickstart`, written for agents) for curl/browser use — but in
62+
TypeScript, reach for the client; the types are the point.
6063

6164
## Recipes
6265

6366
**Test an integration end-to-end for real** (the `connect-handoff` pattern):
64-
mint an API key via `/_emulate/credentials` → register the emulator's
65-
`/_emulate/openapi` spec with the product → invoke a tool through the
66-
product → assert the call landed by reading `/_emulate/ledger`. The ledger
67-
is the proof — "the product made this exact upstream call with this auth" —
68-
which beats asserting on the product's own response.
67+
`client.credentials.mint(...)` → register `client.openapiUrl` with the
68+
product → invoke a tool through the product → find the call in
69+
`client.ledger.list()` (match on `entry.request.body` / `operationId`). The
70+
ledger is the proof — "the product made this exact upstream call with this
71+
auth" — which beats asserting on the product's own response.
6972

7073
**Real OAuth/OIDC flows**: google/okta/microsoft/apple/clerk/workos mint
7174
OAuth clients and run real authorize/token endpoints. The WorkOS emulator
@@ -75,9 +78,10 @@ clients, and Vault KV. Real SDK + `WORKOS_API_URL` override = the product's
7578
untouched auth code against it. Set `EMULATE_WORKOS_AUDIENCE=<client_id>`
7679
before `createEmulator` so minted MCP access tokens carry the right audience.
7780

78-
**A live, human-pokeable cloud instance with zero .env**: see
79-
`e2e/scripts/cloud-demo.ts` and `e2e/setup/cloud.globalsetup.ts` — WorkOS +
80-
Autumn emulators + the app's real dev stack.
81+
**A live, human-pokeable cloud instance with zero .env**:
82+
`cd e2e && bun run cli up cloud --share` — WorkOS + Autumn emulators + the
83+
app's real dev stack (recipe in `e2e/setup/cloud.boot.ts`), fronted with
84+
tailscale HTTPS.
8185

8286
## Gotchas
8387

bun.lock

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

e2e/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
},
1919
"dependencies": {
2020
"@executor-js/api": "workspace:*",
21-
"@executor-js/emulate": "^0.6.0",
21+
"@executor-js/emulate": "^0.7.0",
2222
"@executor-js/mcporter": "^0.11.4",
2323
"@executor-js/plugin-graphql": "workspace:*",
2424
"@executor-js/plugin-mcp": "workspace:*",

e2e/scenarios/connect-handoff.test.ts

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { randomBytes } from "node:crypto";
1818
import { expect } from "@effect/vitest";
1919
import { Effect } from "effect";
2020
import { composePluginApi } from "@executor-js/api/server";
21+
import { connectEmulator } from "@executor-js/emulate";
2122
import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
2223

2324
import { scenario } from "../src/scenario";
@@ -96,21 +97,16 @@ const executeJson = (session: McpSession, code: string) =>
9697
return JSON.parse(result.text) as Record<string, unknown>;
9798
});
9899

99-
const mintEmulatorApiKey = Effect.promise(async () => {
100-
const response = await fetch(`${EMULATOR_BASE}/_emulate/credentials`, {
101-
method: "POST",
102-
headers: { "content-type": "application/json" },
103-
body: JSON.stringify({ type: "api-key" }),
104-
});
105-
const body = (await response.json()) as { credential?: { token?: string } };
106-
const token = body.credential?.token;
107-
if (!token) throw new Error(`emulator credential mint failed: ${JSON.stringify(body)}`);
108-
return token;
109-
});
100+
// The typed control-plane client — minting and ledger reads with real shapes
101+
// instead of hand-rolled fetch + casts.
102+
const emulator = Effect.promise(() => connectEmulator({ baseUrl: EMULATOR_BASE }));
110103

111-
const fetchLedgerText = Effect.promise(async () => {
112-
const response = await fetch(`${EMULATOR_BASE}/_emulate/ledger`);
113-
return response.text();
104+
const mintEmulatorApiKey = Effect.gen(function* () {
105+
const client = yield* emulator;
106+
const credential = yield* Effect.promise(() => client.credentials.mint({ type: "api-key" }));
107+
const token = credential.token;
108+
if (!token) throw new Error(`emulator credential mint failed: ${JSON.stringify(credential)}`);
109+
return token;
114110
});
115111

116112
scenario(
@@ -210,9 +206,13 @@ const runScenario = (input: {
210206
const sent = yield* executeJson(session, sendEmailCode(integration, emailSubject));
211207
expect(sent.ok, `email sent through the pasted connection: ${JSON.stringify(sent)}`).toBe(true);
212208

213-
const ledger = yield* fetchLedgerText;
209+
const emulatorClient = yield* emulator;
210+
const entries = yield* Effect.promise(() => emulatorClient.ledger.list());
211+
const recorded = entries.find((entry) =>
212+
JSON.stringify(entry.request.body ?? "").includes(emailSubject),
213+
);
214214
expect(
215-
ledger.includes(emailSubject),
215+
recorded?.summary,
216216
"the emulator's request ledger recorded the call made through Executor",
217-
).toBe(true);
217+
).toBeDefined();
218218
});

e2e/scripts/cli.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -437,8 +437,10 @@ const ledger = async (targetName: string, service = "workos") => {
437437
const state = readState(targetName);
438438
const url = state?.urls?.[service];
439439
if (!url) throw new Error(`no ${service} emulator url recorded for ${targetName}`);
440-
const response = await fetch(new URL("/_emulate/ledger", url));
441-
console.log(await response.text());
440+
const { connectEmulator } = await import("@executor-js/emulate");
441+
const client = await connectEmulator({ baseUrl: url });
442+
const entries = await client.ledger.list();
443+
console.log(JSON.stringify(entries, null, 2));
442444
};
443445

444446
// --- lifecycle commands ----------------------------------------------------

e2e/targets/cloud.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import { randomUUID } from "node:crypto";
88

99
import { Effect } from "effect";
1010

11+
import { connectEmulator } from "@executor-js/emulate";
12+
1113
import { e2ePort } from "../src/ports";
1214
import type { Identity, Target } from "../src/target";
1315

@@ -60,12 +62,10 @@ export const cloudTarget = (): Target => ({
6062
// scenarios can compress the lifecycle (the TtlControl service).
6163
setAccessTokenTtl: (seconds) =>
6264
Effect.promise(async () => {
63-
const response = await fetch(`http://127.0.0.1:${WORKOS_EMULATOR_PORT}/_emulate/seed`, {
64-
method: "POST",
65-
headers: { "content-type": "application/json" },
66-
body: JSON.stringify({ oauth: { default_access_token_ttl_seconds: seconds } }),
65+
const workos = await connectEmulator({
66+
baseUrl: `http://127.0.0.1:${WORKOS_EMULATOR_PORT}`,
6767
});
68-
if (!response.ok) throw new Error(`seeding emulator TTL failed (${response.status})`);
68+
await workos.seed({ oauth: { default_access_token_ttl_seconds: seconds } });
6969
}),
7070
newIdentity: ({ org = true } = {}) =>
7171
Effect.promise(async (): Promise<Identity> => {

0 commit comments

Comments
 (0)