@exposify/express 0.3.7 → 0.3.9
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 +284 -7
- package/dist/cli.js +295 -0
- package/dist/index.cjs +476 -49
- package/dist/index.d.cts +30 -2
- package/dist/index.d.ts +30 -2
- package/dist/index.js +494 -45
- package/dist/scan-preload.cjs +7120 -0
- package/dist/scan-preload.js +7124 -0
- package/package.json +8 -4
package/README.md
CHANGED
|
@@ -17,6 +17,267 @@ credentials (`Authorization`/`Cookie`), forwarded to your routes unchanged.
|
|
|
17
17
|
There is no separate authorization layer to configure or get wrong — an
|
|
18
18
|
agent can do exactly what the person it's acting for could already do.
|
|
19
19
|
|
|
20
|
+
## NestJS
|
|
21
|
+
|
|
22
|
+
NestJS on its default Express platform works with this package as it is. Add
|
|
23
|
+
one line to `main.ts`, after `NestFactory.create()` and before `listen()`:
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
const app = await NestFactory.create(AppModule);
|
|
27
|
+
app.setGlobalPrefix("api");
|
|
28
|
+
app.use(aiReady({ expose: ["POST /api/v1/orders"] })); // paths as Nest serves them
|
|
29
|
+
await app.listen(3000);
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Tools get Nest's full paths (global prefix, URI versioning, `RouterModule`
|
|
33
|
+
paths), and tool calls go through Nest itself: guards, pipes, interceptors
|
|
34
|
+
and exception filters apply exactly as for any HTTP client, so a guarded
|
|
35
|
+
route refuses an agent without credentials and a `ValidationPipe` error
|
|
36
|
+
reaches the agent as an actionable 400. `npx exposify-express setup` and
|
|
37
|
+
`inspect` find `src/main.ts` from `nest-cli.json` and load it with your
|
|
38
|
+
`ts-node`/`swc-node`. The Fastify platform isn't supported yet. See
|
|
39
|
+
`examples/nest-demo`.
|
|
40
|
+
|
|
41
|
+
With `@nestjs/swagger`, [`@exposify/nestjs`](../nestjs) does this in one line
|
|
42
|
+
(`mountAiReady(app)`) and adds `@McpExposed()` / `@McpHidden()` /
|
|
43
|
+
`@McpDescribe()` decorators. Or pass the document yourself, and tools get its
|
|
44
|
+
DTO schemas and `@ApiOperation` summaries:
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
const openapi = SwaggerModule.createDocument(app, new DocumentBuilder().build());
|
|
48
|
+
app.use(aiReady({ expose: ["POST /api/v1/orders"], openapi }));
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Agents then get validated inputs (a `minimum: 1` rejects `qty: 0` before any
|
|
52
|
+
request is made) and your summaries as tool descriptions. `openapi` also
|
|
53
|
+
takes a path to a JSON file, for any Express app with an OpenAPI document.
|
|
54
|
+
Description precedence: `describe()` in code, then
|
|
55
|
+
`exposify.descriptions.json`, then OpenAPI, then the derived text.
|
|
56
|
+
|
|
57
|
+
## Guided setup: `npx exposify-express setup`
|
|
58
|
+
|
|
59
|
+
Instead of writing the config by hand, run this in your app's directory:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
npx exposify-express setup
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
It loads your app without starting a server, lists every endpoint (reads
|
|
66
|
+
pre-selected), asks whether to print the startup line, then prints the
|
|
67
|
+
`aiReady()` config for your choices, copies it to the clipboard, and tells
|
|
68
|
+
you the file and line to paste it at. Press Enter after pasting and it
|
|
69
|
+
re-scans to confirm agents get exactly what you picked. Last, it offers to
|
|
70
|
+
give your tools real descriptions (below).
|
|
71
|
+
|
|
72
|
+
- **Checklist keys:** space toggles, `a` all, `n` none, `r` reads only, `/`
|
|
73
|
+
filters. Routes wrapped in `exposed()`/`hidden()`/`describe()` are shown
|
|
74
|
+
locked: the code decides them.
|
|
75
|
+
- **`!` exposes everything** (`expose: "*"`), after you type `expose all`.
|
|
76
|
+
That includes write routes added to your app later, with no review, so the
|
|
77
|
+
generated config carries a warning comment and the startup line says so on
|
|
78
|
+
every boot. `x` goes back to an explicit list. Ticking every row with `a`
|
|
79
|
+
never produces `"*"`.
|
|
80
|
+
- **Finding your app:** the entry file comes from `--entry`, else your
|
|
81
|
+
`dev`/`start` script, `main`, or `src/{main,server,index,app}`. TypeScript
|
|
82
|
+
runs through your project's own `tsx`, `ts-node` or `@swc-node/register`.
|
|
83
|
+
Code that runs at import time (DB clients, timers) does run, in a child
|
|
84
|
+
process that's discarded; no port is ever bound. An app that never calls
|
|
85
|
+
`listen()` is read once it stops registering routes.
|
|
86
|
+
- **`.env`:** if your project has one, `setup` first asks whether to load it
|
|
87
|
+
into the scan (default yes), since apps often refuse to start without
|
|
88
|
+
their secrets. Values go only to the scan's process and are never printed;
|
|
89
|
+
variables already set in your shell win. `--env-vars <path>` loads a
|
|
90
|
+
specific file (also for `inspect` and `--ci`, which never load one
|
|
91
|
+
otherwise), `--no-env-vars` skips it.
|
|
92
|
+
- **Skipping questions:** `--quick` keeps the defaults (reads, or your current
|
|
93
|
+
config) and the current startup-log setting, but still copies the config
|
|
94
|
+
and verifies the paste. `--apply` makes the change in your code instead
|
|
95
|
+
(setup asks in a terminal; `--no-apply` never does): it rewrites only the
|
|
96
|
+
options setup manages in your `aiReady()` call, or adds the call and its
|
|
97
|
+
import, and falls back to the paste instructions if it can't do that
|
|
98
|
+
safely. Check it with `git diff`. `--expose "POST /orders"` (repeatable),
|
|
99
|
+
`--expose-all` and `--startup-log on|off` answer one question each.
|
|
100
|
+
- **Scripts and CI:** `--ci` never prompts, never touches the clipboard and
|
|
101
|
+
prints no colors; it fails instead of asking. It turns on by itself when
|
|
102
|
+
the `CI` environment variable is set.
|
|
103
|
+
- **How your users sign in** (step 4): `setup` reads your `package.json`.
|
|
104
|
+
With Auth0, Clerk, WorkOS or Kinde it points you at the `oauth` option
|
|
105
|
+
(your provider already signs agents in). Otherwise it offers Exposify's
|
|
106
|
+
hosted broker and a browser sign-in that writes `EXPOSIFY_KEY` to `.env`
|
|
107
|
+
(a new project named after your app, or an existing one). `--no-sign-in`
|
|
108
|
+
skips the step; `EXPOSIFY_NO_BROWSER=1` prints the link instead of
|
|
109
|
+
opening it.
|
|
110
|
+
|
|
111
|
+
**Tool descriptions.** Agents pick tools by their descriptions, and by default
|
|
112
|
+
they get one derived from the route ("Create an order (POST /orders)").
|
|
113
|
+
`setup`'s step 5, or `npx exposify-express describe` any time, replaces those:
|
|
114
|
+
|
|
115
|
+
- `a` sends each route's handler source to Exposify's description service,
|
|
116
|
+
which writes a proposal with an LLM (`--no-source` sends only method, path
|
|
117
|
+
and middleware names). Strings that look like secrets are masked on your
|
|
118
|
+
machine first, and the CLI says exactly what it's about to send and where. The
|
|
119
|
+
service may log each request with your IP address (it tells you how long
|
|
120
|
+
it keeps them).
|
|
121
|
+
Each proposal is shown: Enter accepts, `e` edits, `r` regenerates, `s`
|
|
122
|
+
skips. `A` accepts the remaining **read** routes; a write route's
|
|
123
|
+
description is always reviewed on its own, since a wrong claim about what
|
|
124
|
+
a write does is the worst thing an agent can be told. You don't need an
|
|
125
|
+
LLM key: without an account you get a small free allowance (2 routes a
|
|
126
|
+
day by default); when it runs out, the CLI links to a free sign-up and takes your project's secret key
|
|
127
|
+
(and offers to save it in `~/.config/exposify/credentials.json`, readable
|
|
128
|
+
only by you). `--license-key` / `EXPOSIFY_KEY` pass one directly;
|
|
129
|
+
`--api-url` / `EXPOSIFY_API_URL` point at another service.
|
|
130
|
+
- `w` lets you write them yourself (`e` opens `$EDITOR`).
|
|
131
|
+
|
|
132
|
+
They're saved to `exposify.descriptions.json`, which `aiReady()` loads at
|
|
133
|
+
startup (`descriptions: "path"` or `false` to change that); `describe()` text
|
|
134
|
+
in your code still wins. Text you wrote or edited is never overwritten. When
|
|
135
|
+
a route's handler changes, its AI description is offered again; yours is
|
|
136
|
+
flagged for you to check. In CI, `describe --check` exits 1 if a tool has no
|
|
137
|
+
description or one is out of date, and `describe --ci` writes AI
|
|
138
|
+
descriptions for read routes only, listing the writes for a person.
|
|
139
|
+
|
|
140
|
+
`npx exposify-express inspect` prints every route with its tool name,
|
|
141
|
+
whether agents get it, and why (`--json` for scripts): the answer to "why
|
|
142
|
+
isn't my route showing up?".
|
|
143
|
+
|
|
144
|
+
**Catch tool changes in CI:** `npx exposify-express inspect --snapshot` writes
|
|
145
|
+
`exposify.tools.json` (each route agents can call, and its tool name).
|
|
146
|
+
`setup` offers to save the same file as its last question (default no;
|
|
147
|
+
`--snapshot` to save without asking). Commit it, and run
|
|
148
|
+
`npx exposify-express inspect --check` in CI: it exits 1,
|
|
149
|
+
listing what changed, when a route becomes a tool (writes flagged), stops
|
|
150
|
+
being one, or gets a new tool name. Re-run `--snapshot` when the change is
|
|
151
|
+
intended, so it shows up in the PR diff for review.
|
|
152
|
+
|
|
153
|
+
## AI assistants sign in with your own login
|
|
154
|
+
|
|
155
|
+
With `EXPOSIFY_KEY` set (the hosted broker), an AI assistant that connects
|
|
156
|
+
signs in with your app's own login, with no code:
|
|
157
|
+
|
|
158
|
+
- On the first request, `aiReady()` finds your login among your routes: a
|
|
159
|
+
`POST` route whose handler reads a password and something saying who from
|
|
160
|
+
`req.body`, and sets a cookie, a session (`req.session`, passport's
|
|
161
|
+
`req.login`) or signs a token. Sign-up, sign-out and reset routes are never
|
|
162
|
+
picked.
|
|
163
|
+
- When Claude connects, the person clicks Allow on Exposify's page and lands
|
|
164
|
+
on a sign-in form served at `/mcp/login`, on your domain. On submit,
|
|
165
|
+
`aiReady()` sends your login route the fields through your own app (no
|
|
166
|
+
browser cookies, the person's address forwarded), and the session it
|
|
167
|
+
creates goes to the broker for this connection. Every tool call then runs
|
|
168
|
+
as that person; cookies you set later are kept; a 401 or a redirect to the
|
|
169
|
+
login ends the connection, so the assistant asks to connect again.
|
|
170
|
+
- `aiReady()` tells Exposify about the login and its MCP address before
|
|
171
|
+
answering `/mcp`, so there's nothing to add in the dashboard.
|
|
172
|
+
|
|
173
|
+
`login: { path, fields, body? }` names another route; `login: false` turns
|
|
174
|
+
it off. Routers introspection can't see (sub-apps) need `mounts`.
|
|
175
|
+
|
|
176
|
+
## "Click Connect and log in" (OAuth)
|
|
177
|
+
|
|
178
|
+
By default the MCP client has to be handed a token by hand. If your app's
|
|
179
|
+
auth provider is an OAuth 2.1 authorization server (Auth0, WorkOS, Clerk,
|
|
180
|
+
Okta, Keycloak, Supabase Auth, ...), add `oauth` and MCP clients log in on
|
|
181
|
+
their own:
|
|
182
|
+
|
|
183
|
+
```ts
|
|
184
|
+
app.use(aiReady({
|
|
185
|
+
oauth: {
|
|
186
|
+
issuer: "https://acme.us.auth0.com/", // exactly the tokens' `iss`
|
|
187
|
+
resource: "https://api.acme.com/mcp", // the public MCP URL = required token audience
|
|
188
|
+
scopes: ["read"], // optional
|
|
189
|
+
},
|
|
190
|
+
}));
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
The whole `/mcp` endpoint then requires a valid access token. A request
|
|
194
|
+
without one gets `401` + `WWW-Authenticate: Bearer resource_metadata="…"`,
|
|
195
|
+
and Protected Resource Metadata (RFC 9728) is served at
|
|
196
|
+
`/.well-known/oauth-protected-resource[/mcp]`. That is enough for Claude,
|
|
197
|
+
Cursor, etc. to discover your issuer, register, open your real login page
|
|
198
|
+
and refresh tokens later, with nothing for the user to paste. Tokens are
|
|
199
|
+
verified as JWTs against the issuer's JWKS (discovered from its metadata):
|
|
200
|
+
signature, `iss`, `aud` = `resource`, expiry, and `scopes`. An expired
|
|
201
|
+
token is an HTTP `401` (so the client refreshes), and an unreachable
|
|
202
|
+
issuer is a `503`, never a pass.
|
|
203
|
+
|
|
204
|
+
Then the validated `Authorization` is forwarded to your routes in-process,
|
|
205
|
+
exactly like passthrough.
|
|
206
|
+
|
|
207
|
+
### One change in your app: accept the MCP audience
|
|
208
|
+
|
|
209
|
+
MCP tokens are issued for the MCP URL (`aud: "https://api.acme.com/mcp"`),
|
|
210
|
+
and most JWT middleware rejects any audience it wasn't told about. So add
|
|
211
|
+
the MCP URL next to your existing audience. Your web app's tokens keep
|
|
212
|
+
working:
|
|
213
|
+
|
|
214
|
+
```ts
|
|
215
|
+
const MCP_URL = "https://api.acme.com/mcp";
|
|
216
|
+
app.use(aiReady({ oauth: { issuer: ISSUER, resource: MCP_URL } }));
|
|
217
|
+
|
|
218
|
+
// express-oauth2-jwt-bearer (Auth0):
|
|
219
|
+
app.use(auth({ issuerBaseURL: ISSUER, audience: ["https://api.acme.com", MCP_URL] }));
|
|
220
|
+
|
|
221
|
+
// or jose directly:
|
|
222
|
+
await jwtVerify(token, JWKS, { issuer: ISSUER, audience: ["https://api.acme.com", MCP_URL] });
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
Any other middleware works the same way: pass both values wherever it takes
|
|
226
|
+
the expected audience. On the provider side, the issuer must be willing to
|
|
227
|
+
mint tokens for the MCP URL. In Auth0, for example, add an API whose
|
|
228
|
+
identifier is the MCP URL, and leave your existing API alone.
|
|
229
|
+
|
|
230
|
+
If you miss this, sign-in still succeeds but every tool call fails. The
|
|
231
|
+
first time your app answers `401` to a token the MCP endpoint already
|
|
232
|
+
validated, `aiReady()` logs one warning naming the audience to add and
|
|
233
|
+
quoting your app's own `WWW-Authenticate` header.
|
|
234
|
+
|
|
235
|
+
- `resource` is required, never inferred from `Host` (proxies get it
|
|
236
|
+
wrong). Use your public URL; `http` is only accepted for `localhost`.
|
|
237
|
+
- `audience` overrides the expected `aud` for issuers that can't bind
|
|
238
|
+
tokens to `resource`. It's weaker: any token for that audience passes,
|
|
239
|
+
and a warning is logged at startup.
|
|
240
|
+
- `jwksUri` skips metadata discovery. `verify: async (token) => claims | null`
|
|
241
|
+
replaces JWT verification (opaque tokens, introspection).
|
|
242
|
+
- A custom `auth` resolver receives `(headers, { claims })`, so it can map
|
|
243
|
+
the validated user to whatever your app expects.
|
|
244
|
+
- `oauth` can't be combined with `docsAuth: "public"` (that throws): clients
|
|
245
|
+
only start login on a connect-time `401`, so the docs tools are behind
|
|
246
|
+
the token too.
|
|
247
|
+
- Apps without an OAuth authorization server (session cookies,
|
|
248
|
+
passport-local, homegrown JWTs) need one first; this package doesn't
|
|
249
|
+
include one.
|
|
250
|
+
|
|
251
|
+
## Startup log
|
|
252
|
+
|
|
253
|
+
On boot (`npm run dev`, or in production), right after your routes
|
|
254
|
+
register, `aiReady()` prints one line saying whether MCP is up:
|
|
255
|
+
|
|
256
|
+
```
|
|
257
|
+
[agentready] MCP ready at /mcp: 6 tools (4 read, 1 write, search_docs), 1 write route hidden (opt in with exposed()), 2 docs indexed, auth: passthrough
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
When something's wrong, the line names the problem and a fix instead:
|
|
261
|
+
|
|
262
|
+
```
|
|
263
|
+
[agentready] MCP failed to start: <reason>
|
|
264
|
+
fix: <what to do>
|
|
265
|
+
[agentready] warning: MCP is up at /mcp but exposes no route tools
|
|
266
|
+
fix: register app.use(aiReady()) before your routers, ...
|
|
267
|
+
[agentready] warning: can't verify access tokens from oauth.issuer https://...: <reason>
|
|
268
|
+
fix: make oauth.issuer match the tokens' `iss` exactly ...
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
- With `oauth`, the issuer's metadata and keys are fetched once at boot, so
|
|
272
|
+
a wrong issuer shows up here instead of on the first login.
|
|
273
|
+
- Routes registered later (e.g. after an `await`) are picked up on the first
|
|
274
|
+
`/mcp` request, which prints an updated line only if the count changed.
|
|
275
|
+
- Failures and warnings always print. The ready line is on by default and
|
|
276
|
+
off under `NODE_ENV=test`. `startupLog: true | false` overrides that
|
|
277
|
+
(and also turns the issuer check on or off).
|
|
278
|
+
- The check is read-only and never throws into your app; your own routes
|
|
279
|
+
keep working either way.
|
|
280
|
+
|
|
20
281
|
## Exposure policy
|
|
21
282
|
|
|
22
283
|
- **Reads (GET/HEAD) are exposed automatically.** Writes are hidden by
|
|
@@ -36,6 +297,11 @@ app.post("/orders/:id/refund", describe(refundOrder, {
|
|
|
36
297
|
}));
|
|
37
298
|
```
|
|
38
299
|
|
|
300
|
+
- **Hiding one method on a path:** `exclude` takes picomatch globs on the
|
|
301
|
+
path (`"/admin/**"`, hides every method), or `"METHOD /path"` entries that
|
|
302
|
+
match one method and that exact path. `exclude: ["GET /orders"]` with
|
|
303
|
+
`expose: ["POST /orders"]` hides the read and keeps the write.
|
|
304
|
+
|
|
39
305
|
- **`describe()`'s `input` envelope contract:** `input` is a flat Zod shape.
|
|
40
306
|
At call time, each top-level key is routed by name — a key matching one of
|
|
41
307
|
the route's `:param` segments becomes a path substitution, `query`
|
|
@@ -60,15 +326,20 @@ app.use(aiReady({ mounts: { "/legacy": legacyRouter } }));
|
|
|
60
326
|
|
|
61
327
|
```ts
|
|
62
328
|
aiReady({
|
|
63
|
-
auth?: "passthrough" | "none",
|
|
329
|
+
auth?: "passthrough" | "none" | AuthResolver, // default "passthrough"
|
|
330
|
+
oauth?: { issuer, resource, scopes?, audience?, jwksUri?, verify? }, // see above
|
|
331
|
+
startupLog?: boolean, // default on, off under NODE_ENV=test
|
|
64
332
|
docs?: string[], // default ["README.md", "docs/**/*.md"]
|
|
65
333
|
expose?: string[] | "*", // e.g. ["GET /trends"]; default unset
|
|
66
|
-
exclude?: string[], //
|
|
67
|
-
docsAuth?: "public" | "required", // default "public"
|
|
334
|
+
exclude?: string[], // path globs, or "METHOD /path" for one method
|
|
335
|
+
docsAuth?: "public" | "required", // default "public" ("required" with oauth or the hosted broker)
|
|
68
336
|
embedder?: (texts: string[]) => Promise<number[][]>,
|
|
69
337
|
telemetry?: { enabled: boolean; sink?(event): void; capturePayloads?: boolean },
|
|
70
338
|
cwd?: string, // default process.cwd()
|
|
71
339
|
mounts?: Record<string, unknown>, // explicit router/sub-app overrides
|
|
340
|
+
descriptions?: string | false, // default "exposify.descriptions.json" if present
|
|
341
|
+
openapi?: string | object, // your OpenAPI document: tool schemas + descriptions
|
|
342
|
+
login?: false | { path, fields, body? }, // your own login, for the hosted broker (found by itself)
|
|
72
343
|
});
|
|
73
344
|
```
|
|
74
345
|
|
|
@@ -84,10 +355,16 @@ default above.
|
|
|
84
355
|
only works when `aiReady()` is registered *before* the mount. A router
|
|
85
356
|
mounted both before and after `aiReady()` ("straddle" mount) resolves
|
|
86
357
|
correctly on Express 4 but silently drops the earlier prefix on Express 5.
|
|
87
|
-
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
358
|
+
- With `oauth`, only a global `scopes` set is checked at the edge; per-tool
|
|
359
|
+
scopes aren't supported (your routes' own `403`s still come back as tool
|
|
360
|
+
errors). Combining `oauth` with the hosted broker's resolver isn't supported
|
|
361
|
+
yet.
|
|
362
|
+
- Tool calls are dispatched as real HTTP requests to a private listener on
|
|
363
|
+
`127.0.0.1` (an ephemeral port, opened on the first tool call, refusing
|
|
364
|
+
anything but the SDK's own requests). Each call carries the MCP caller's
|
|
365
|
+
socket address, `Host` and `Forwarded`/`X-Forwarded-*` headers, so
|
|
366
|
+
`req.ip`, `req.hostname`, `req.protocol` and `trust proxy` see the agent
|
|
367
|
+
exactly as they would for a direct request, never as localhost.
|
|
91
368
|
|
|
92
369
|
See the repo README for
|
|
93
370
|
the full picture across all `@exposify/*` packages, including
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// bin/exposify-express.ts
|
|
4
|
+
import { runCli } from "@exposify/cli";
|
|
5
|
+
|
|
6
|
+
// bin/adapter.ts
|
|
7
|
+
import { readFileSync as readFileSync2 } from "fs";
|
|
8
|
+
import { insertAiReadyCall, renderConfig, replaceAiReadyOptions } from "@exposify/cli";
|
|
9
|
+
|
|
10
|
+
// bin/paste-hint.ts
|
|
11
|
+
var relative = (site, cwd) => site.file.startsWith(`${cwd}/`) ? site.file.slice(cwd.length + 1) : site.file;
|
|
12
|
+
function placement(scan) {
|
|
13
|
+
const firstMount = scan.uses.find((u) => (u.kind === "router" || u.kind === "app") && u.site)?.site;
|
|
14
|
+
const before = [firstMount, scan.firstRouteAt].filter((s) => Boolean(s)).sort((a, b) => a.line - b.line)[0];
|
|
15
|
+
const file = before?.file ?? scan.createdAt?.file;
|
|
16
|
+
if (!file) return void 0;
|
|
17
|
+
const plain = scan.uses.filter((u) => u.kind === "middleware" && u.site?.file === file && (!before || u.site.line < before.line));
|
|
18
|
+
const after = plain.at(-1)?.site ?? (scan.createdAt?.file === file ? scan.createdAt : void 0);
|
|
19
|
+
return { file, ...before ? { beforeLine: before.line } : {}, ...after ? { afterLine: after.line } : {} };
|
|
20
|
+
}
|
|
21
|
+
function pasteHint(scan, cwd, readLine) {
|
|
22
|
+
const quote = (site) => {
|
|
23
|
+
const text = readLine(site)?.trim();
|
|
24
|
+
return text ? ` ${text}` : "";
|
|
25
|
+
};
|
|
26
|
+
const firstMount = scan.uses.find((u) => (u.kind === "router" || u.kind === "app") && u.site)?.site;
|
|
27
|
+
const before = [firstMount, scan.firstRouteAt].filter((s) => Boolean(s)).sort((a, b) => a.line - b.line)[0];
|
|
28
|
+
if (scan.aiReady?.site) {
|
|
29
|
+
const site = scan.aiReady.site;
|
|
30
|
+
const fn = readLine(site)?.includes("mountAiReady(") ? "mountAiReady" : "aiReady";
|
|
31
|
+
const out = { lines: [`Replace the ${fn}(...) call at ${relative(site, cwd)}:${site.line} with it.`] };
|
|
32
|
+
if (before && before.file === site.file && before.line < site.line) {
|
|
33
|
+
const where = `${relative(before, cwd)}:${before.line}`;
|
|
34
|
+
out.warning = scan.expressMajor >= 5 ? `aiReady() is after your routers (${where}). On Express 5 it only finds routers mounted after it: move it above that line.` : `aiReady() is after your routers (${where}). That works on Express 4, but moving it above that line keeps it working if you upgrade to Express 5.`;
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
const file = before?.file ?? scan.createdAt?.file;
|
|
39
|
+
if (!file) return { lines: ["Paste it in the file where you call express(), after your body parsers and before your routes."] };
|
|
40
|
+
const plain = scan.uses.filter(
|
|
41
|
+
(u) => u.kind === "middleware" && u.site?.file === file && (!before || u.site.line < before.line)
|
|
42
|
+
);
|
|
43
|
+
const after = plain.at(-1)?.site ?? (scan.createdAt?.file === file ? scan.createdAt : void 0);
|
|
44
|
+
const lines = [`Paste it in ${relative({ file, line: 0 }, cwd)}:`];
|
|
45
|
+
if (after) lines.push(`after line ${after.line}${quote(after)}`);
|
|
46
|
+
if (before) {
|
|
47
|
+
const why = scan.expressMajor >= 5 ? "<- must come before your routers (Express 5 only finds routers mounted after aiReady())" : "<- recommended: keeps working on Express 5, and no router can catch /mcp first";
|
|
48
|
+
lines.push(`before line ${before.line}${quote(before)} ${why}`);
|
|
49
|
+
}
|
|
50
|
+
return { lines };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// bin/scan/entry.ts
|
|
54
|
+
import { existsSync, readFileSync } from "fs";
|
|
55
|
+
import { createRequire } from "module";
|
|
56
|
+
import { dirname, extname, join, resolve } from "path";
|
|
57
|
+
var realFs = {
|
|
58
|
+
exists: existsSync,
|
|
59
|
+
read: (path) => {
|
|
60
|
+
try {
|
|
61
|
+
return readFileSync(path, "utf8");
|
|
62
|
+
} catch {
|
|
63
|
+
return void 0;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
var RUNNERS = /(?:^|\s|&&|;)(?:npx\s+)?(?:tsx(?:\s+watch)?|ts-node(?:-dev)?|node(?:mon)?|bun)\s+((?:--?[\w-]+(?:[= ](?!\S*\.[cm]?[jt]s\b)\S+)?\s+)*)([^\s"';&|]+\.[cm]?[jt]sx?)\b/;
|
|
68
|
+
var CANDIDATES = ["main", "server", "index", "app"].flatMap(
|
|
69
|
+
(name) => ["ts", "js", "mjs", "cjs", "mts", "cts"].flatMap((ext) => [`src/${name}.${ext}`, `${name}.${ext}`])
|
|
70
|
+
);
|
|
71
|
+
function entryFromScript(script) {
|
|
72
|
+
return RUNNERS.exec(script)?.[2];
|
|
73
|
+
}
|
|
74
|
+
function detectEntry(cwd, explicit, fs = realFs) {
|
|
75
|
+
if (explicit) return { entry: resolve(cwd, explicit), via: "--entry" };
|
|
76
|
+
const pkgText = fs.read(join(cwd, "package.json"));
|
|
77
|
+
let pkg = {};
|
|
78
|
+
try {
|
|
79
|
+
pkg = pkgText ? JSON.parse(pkgText) : {};
|
|
80
|
+
} catch {
|
|
81
|
+
}
|
|
82
|
+
if (isNest(pkg)) {
|
|
83
|
+
const nestEntry = nestCliEntry(cwd, fs);
|
|
84
|
+
if (nestEntry) return { entry: nestEntry, via: "nest-cli.json" };
|
|
85
|
+
}
|
|
86
|
+
for (const name of ["dev", "start"]) {
|
|
87
|
+
const script = pkg.scripts?.[name];
|
|
88
|
+
const file = script ? entryFromScript(script) : void 0;
|
|
89
|
+
if (file && fs.exists(join(cwd, file))) return { entry: join(cwd, file), via: `scripts.${name}` };
|
|
90
|
+
}
|
|
91
|
+
if (pkg.main && fs.exists(join(cwd, pkg.main))) return { entry: join(cwd, pkg.main), via: "package.json main" };
|
|
92
|
+
for (const file of CANDIDATES) {
|
|
93
|
+
if (fs.exists(join(cwd, file))) return { entry: join(cwd, file), via: file };
|
|
94
|
+
}
|
|
95
|
+
return void 0;
|
|
96
|
+
}
|
|
97
|
+
function isNest(pkg) {
|
|
98
|
+
return Boolean(pkg.dependencies?.["@nestjs/core"] ?? pkg.devDependencies?.["@nestjs/core"]);
|
|
99
|
+
}
|
|
100
|
+
function nestCliEntry(cwd, fs) {
|
|
101
|
+
let cfg = {};
|
|
102
|
+
try {
|
|
103
|
+
cfg = JSON.parse(fs.read(join(cwd, "nest-cli.json")) ?? "{}");
|
|
104
|
+
} catch {
|
|
105
|
+
}
|
|
106
|
+
const file = join(cwd, cfg.sourceRoot ?? "src", `${cfg.entryFile ?? "main"}.ts`);
|
|
107
|
+
return fs.exists(file) ? file : void 0;
|
|
108
|
+
}
|
|
109
|
+
var TS_EXT = /* @__PURE__ */ new Set([".ts", ".mts", ".cts", ".tsx"]);
|
|
110
|
+
function loadersFor(entry, cwd, fs = realFs) {
|
|
111
|
+
if (!TS_EXT.has(extname(entry))) return [{ name: "node", nodeArgs: [], env: {}, preload: "import" }];
|
|
112
|
+
const has = (mod) => {
|
|
113
|
+
try {
|
|
114
|
+
createRequire(join(cwd, "package.json")).resolve(mod);
|
|
115
|
+
return true;
|
|
116
|
+
} catch {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
const pkg = fs.read(join(cwd, "package.json")) ?? "{}";
|
|
121
|
+
let isEsm = false;
|
|
122
|
+
try {
|
|
123
|
+
isEsm = JSON.parse(pkg).type === "module";
|
|
124
|
+
} catch {
|
|
125
|
+
}
|
|
126
|
+
const loaders = [];
|
|
127
|
+
const tsx = { name: "tsx", nodeArgs: ["--import", "tsx"], env: {}, preload: "import" };
|
|
128
|
+
if (has("tsx")) loaders.push(tsx);
|
|
129
|
+
if (has("ts-node") && !isEsm) {
|
|
130
|
+
loaders.push({
|
|
131
|
+
name: "ts-node (transpile-only)",
|
|
132
|
+
nodeArgs: ["--require", "ts-node/register"],
|
|
133
|
+
// Projects often keep `module: esnext` in tsconfig.json for a bundler
|
|
134
|
+
// while running as CommonJS; force what Node will actually load.
|
|
135
|
+
env: { TS_NODE_TRANSPILE_ONLY: "1", TS_NODE_COMPILER_OPTIONS: JSON.stringify({ module: "commonjs" }) },
|
|
136
|
+
preload: "require"
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
if (has("@swc-node/register") && !isEsm) {
|
|
140
|
+
loaders.push({ name: "@swc-node/register", nodeArgs: ["--require", "@swc-node/register"], env: {}, preload: "require" });
|
|
141
|
+
}
|
|
142
|
+
loaders.push({ name: "node (built-in type stripping)", nodeArgs: [], env: {}, preload: "import" });
|
|
143
|
+
let parsed = {};
|
|
144
|
+
try {
|
|
145
|
+
parsed = JSON.parse(pkg);
|
|
146
|
+
} catch {
|
|
147
|
+
}
|
|
148
|
+
if (isNest(parsed)) {
|
|
149
|
+
const emitsMetadata = (l) => l.name.startsWith("ts-node") || l.name.startsWith("@swc-node");
|
|
150
|
+
return [...loaders.filter(emitsMetadata), ...loaders.filter((l) => !emitsMetadata(l))];
|
|
151
|
+
}
|
|
152
|
+
return loaders;
|
|
153
|
+
}
|
|
154
|
+
function projectRoot(entry, fs = realFs) {
|
|
155
|
+
let dir = dirname(entry);
|
|
156
|
+
for (; ; ) {
|
|
157
|
+
if (fs.exists(join(dir, "package.json"))) return dir;
|
|
158
|
+
const parent = dirname(dir);
|
|
159
|
+
if (parent === dir) return dirname(entry);
|
|
160
|
+
dir = parent;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// bin/scan/run.ts
|
|
165
|
+
import { fork } from "child_process";
|
|
166
|
+
import { dirname as dirname2, join as join2 } from "path";
|
|
167
|
+
import { fileURLToPath, pathToFileURL } from "url";
|
|
168
|
+
|
|
169
|
+
// bin/scan/env.ts
|
|
170
|
+
var SCAN_ENV = {
|
|
171
|
+
entry: "EXPOSIFY_SCAN_ENTRY",
|
|
172
|
+
/** Set to "1" in the child so an app can skip heavy boot work if it wants. */
|
|
173
|
+
flag: "EXPOSIFY_SCAN",
|
|
174
|
+
/** How long to wait for listen() after the last route registration (ms). */
|
|
175
|
+
quietMs: "EXPOSIFY_SCAN_QUIET_MS"
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
// bin/scan/run.ts
|
|
179
|
+
var LOADER_FAILURE = /ERR_MODULE_NOT_FOUND|ERR_UNKNOWN_FILE_EXTENSION|ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX|ERR_REQUIRE_ESM|Cannot find module|SyntaxError|Unexpected token|ERR_INVALID_TYPESCRIPT_SYNTAX|is not supported|TS\d{4}/;
|
|
180
|
+
function preloadFor(loader, dir = dirname2(fileURLToPath(import.meta.url))) {
|
|
181
|
+
return loader.preload === "require" ? ["--require", join2(dir, "scan-preload.cjs")] : ["--import", pathToFileURL(join2(dir, "scan-preload.js")).href];
|
|
182
|
+
}
|
|
183
|
+
function runOnce(opts, loader) {
|
|
184
|
+
return new Promise((resolve2) => {
|
|
185
|
+
let output = "";
|
|
186
|
+
let message;
|
|
187
|
+
const child = fork(opts.entry, [], {
|
|
188
|
+
cwd: opts.cwd,
|
|
189
|
+
execArgv: [...loader.nodeArgs, ...preloadFor(loader, opts.preloadDir)],
|
|
190
|
+
env: {
|
|
191
|
+
...opts.env,
|
|
192
|
+
...process.env,
|
|
193
|
+
...loader.env,
|
|
194
|
+
[SCAN_ENV.entry]: opts.entry,
|
|
195
|
+
[SCAN_ENV.flag]: "1",
|
|
196
|
+
...opts.quietMs !== void 0 ? { [SCAN_ENV.quietMs]: String(opts.quietMs) } : {}
|
|
197
|
+
},
|
|
198
|
+
stdio: ["ignore", "pipe", "pipe", "ipc"]
|
|
199
|
+
});
|
|
200
|
+
child.stdout?.on("data", (chunk) => output += chunk);
|
|
201
|
+
child.stderr?.on("data", (chunk) => output += chunk);
|
|
202
|
+
child.on("message", (m) => message = m);
|
|
203
|
+
child.on("error", (err) => {
|
|
204
|
+
output += String(err);
|
|
205
|
+
});
|
|
206
|
+
child.on("exit", (code) => resolve2({ message, output, code }));
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
async function scanApp(opts) {
|
|
210
|
+
const loaders = loadersFor(opts.entry, opts.cwd);
|
|
211
|
+
let last;
|
|
212
|
+
for (const loader of loaders) {
|
|
213
|
+
const { message, output, code } = await runOnce(opts, loader);
|
|
214
|
+
if (message?.ok) return { ok: true, result: message, loader: loader.name };
|
|
215
|
+
const failure = message && !message.ok ? message : { ok: false, error: `the app exited (code ${code}) before it could be scanned` };
|
|
216
|
+
last = { ok: false, failure, output, loader: loader.name };
|
|
217
|
+
const text = `${failure.error}
|
|
218
|
+
${output}`;
|
|
219
|
+
if (!LOADER_FAILURE.test(text)) break;
|
|
220
|
+
}
|
|
221
|
+
if (last && !last.ok && loaders.length > 1 && LOADER_FAILURE.test(`${last.failure.error}
|
|
222
|
+
${last.output}`)) {
|
|
223
|
+
last.failure.fix ??= "none of the project's TypeScript loaders could run it; `npm i -D tsx` usually fixes this, or pass a built .js file with --entry";
|
|
224
|
+
}
|
|
225
|
+
return last;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// bin/adapter.ts
|
|
229
|
+
var expressAdapter = {
|
|
230
|
+
bin: "exposify-express",
|
|
231
|
+
entryExample: "src/server.ts",
|
|
232
|
+
entryHelp: "The file that creates your Express app",
|
|
233
|
+
locate(cwd, entryFlag) {
|
|
234
|
+
const found = detectEntry(cwd, entryFlag);
|
|
235
|
+
return found ? { entry: found.entry, root: projectRoot(found.entry), via: found.via } : void 0;
|
|
236
|
+
},
|
|
237
|
+
async scan(location, env) {
|
|
238
|
+
const outcome = await scanApp({ entry: location.entry, cwd: location.root, env });
|
|
239
|
+
if (outcome.ok) return { ok: true, result: outcome.result };
|
|
240
|
+
return {
|
|
241
|
+
ok: false,
|
|
242
|
+
error: outcome.failure.error,
|
|
243
|
+
...outcome.failure.fix ? { fix: outcome.failure.fix } : {},
|
|
244
|
+
output: outcome.output,
|
|
245
|
+
...outcome.loader ? { loader: outcome.loader } : {}
|
|
246
|
+
};
|
|
247
|
+
},
|
|
248
|
+
configStep({ result, config, current, root, readLine }) {
|
|
249
|
+
const viaNest = Boolean(result.aiReady?.site && readLine(result.aiReady.site)?.includes("mountAiReady("));
|
|
250
|
+
const otherKeys = viaNest ? current?.otherKeys.filter((k) => k !== "openapi") : current?.otherKeys;
|
|
251
|
+
let code = renderConfig(config, { ...result.aiReady ? {} : { importFrom: "@exposify/express" }, ...otherKeys ? { otherKeys } : {} });
|
|
252
|
+
if (viaNest) {
|
|
253
|
+
code = code === "app.use(aiReady());" ? "mountAiReady(app); // keep swaggerConfig if you pass it" : code.replace(/^app\.use\(aiReady\(\{/m, "mountAiReady(app, {\n // keep swaggerConfig if you pass it").replace(/\}\)\);$/m, "});");
|
|
254
|
+
}
|
|
255
|
+
return { code, hint: pasteHint(result, root, readLine), apply: applyStep(result, config, viaNest, root, code) };
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
var rel = (file, root) => file.startsWith(`${root}/`) ? file.slice(root.length + 1) : file;
|
|
259
|
+
function applyStep(result, config, viaNest, root, code) {
|
|
260
|
+
try {
|
|
261
|
+
const site = result.aiReady?.site;
|
|
262
|
+
if (site) {
|
|
263
|
+
const fn = viaNest ? "mountAiReady" : "aiReady";
|
|
264
|
+
const before2 = readFileSync2(site.file, "utf8");
|
|
265
|
+
const edit2 = replaceAiReadyOptions({ source: before2, file: site.file, line: site.line, callNames: [fn], optionsArg: viaNest ? 1 : 0, config });
|
|
266
|
+
if ("blocked" in edit2) return edit2;
|
|
267
|
+
return { plan: { file: site.file, summary: `Replace the ${fn}() options in ${rel(site.file, root)}`, before: before2, after: edit2.after } };
|
|
268
|
+
}
|
|
269
|
+
const at = placement(result);
|
|
270
|
+
if (!at) return { blocked: "couldn't tell which file creates your app" };
|
|
271
|
+
const before = readFileSync2(at.file, "utf8");
|
|
272
|
+
const edit = insertAiReadyCall({
|
|
273
|
+
source: before,
|
|
274
|
+
file: at.file,
|
|
275
|
+
code: code.replace(/^import .*\n\n/, ""),
|
|
276
|
+
importFrom: "@exposify/express",
|
|
277
|
+
...at.beforeLine ? { beforeLine: at.beforeLine } : {},
|
|
278
|
+
...at.afterLine ? { afterLine: at.afterLine } : {},
|
|
279
|
+
...result.createdAt?.file === at.file ? { createdLine: result.createdAt.line } : {}
|
|
280
|
+
});
|
|
281
|
+
if ("blocked" in edit) return edit;
|
|
282
|
+
return { plan: { file: at.file, summary: `Add aiReady() to ${rel(at.file, root)}`, before, after: edit.after } };
|
|
283
|
+
} catch (err) {
|
|
284
|
+
return { blocked: err.message };
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// bin/exposify-express.ts
|
|
289
|
+
runCli(process.argv.slice(2), expressAdapter).then(
|
|
290
|
+
(code) => process.exit(code),
|
|
291
|
+
(err) => {
|
|
292
|
+
console.error(err instanceof Error ? err.message : err);
|
|
293
|
+
process.exit(1);
|
|
294
|
+
}
|
|
295
|
+
);
|