@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 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", // default "passthrough"
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[], // picomatch globs against the route path
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
- - In-process dispatch uses `light-my-request`, which splices Express's
88
- process-wide request/response prototypes for the duration of each
89
- dispatch — a narrow concurrency window under real concurrent traffic, a
90
- known tradeoff tracked in `packages/express/src/surface.ts`.
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
+ );