@exposify/express 0.3.8 → 0.3.10
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 +180 -7
- package/dist/cli.js +295 -0
- package/dist/index.cjs +367 -55
- package/dist/index.d.cts +30 -2
- package/dist/index.d.ts +30 -2
- package/dist/index.js +367 -40
- package/dist/scan-preload.cjs +7150 -0
- package/dist/scan-preload.js +7154 -0
- package/package.json +7 -4
package/README.md
CHANGED
|
@@ -17,6 +17,169 @@ 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
|
+
**No login found, and no sign-in method in the dashboard?** Then agents use
|
|
177
|
+
your app signed out: `/mcp` doesn't ask them to sign in, and your own checks
|
|
178
|
+
decide what they can do. The startup log says which it is
|
|
179
|
+
(`[agentready] How agents sign in: ...`), and so does the dashboard's "How
|
|
180
|
+
agents sign in" page. Adding a sign-in method there takes effect within a
|
|
181
|
+
minute.
|
|
182
|
+
|
|
20
183
|
## "Click Connect and log in" (OAuth)
|
|
21
184
|
|
|
22
185
|
By default the MCP client has to be handed a token by hand. If your app's
|
|
@@ -141,6 +304,11 @@ app.post("/orders/:id/refund", describe(refundOrder, {
|
|
|
141
304
|
}));
|
|
142
305
|
```
|
|
143
306
|
|
|
307
|
+
- **Hiding one method on a path:** `exclude` takes picomatch globs on the
|
|
308
|
+
path (`"/admin/**"`, hides every method), or `"METHOD /path"` entries that
|
|
309
|
+
match one method and that exact path. `exclude: ["GET /orders"]` with
|
|
310
|
+
`expose: ["POST /orders"]` hides the read and keeps the write.
|
|
311
|
+
|
|
144
312
|
- **`describe()`'s `input` envelope contract:** `input` is a flat Zod shape.
|
|
145
313
|
At call time, each top-level key is routed by name — a key matching one of
|
|
146
314
|
the route's `:param` segments becomes a path substitution, `query`
|
|
@@ -170,12 +338,15 @@ aiReady({
|
|
|
170
338
|
startupLog?: boolean, // default on, off under NODE_ENV=test
|
|
171
339
|
docs?: string[], // default ["README.md", "docs/**/*.md"]
|
|
172
340
|
expose?: string[] | "*", // e.g. ["GET /trends"]; default unset
|
|
173
|
-
exclude?: string[], //
|
|
174
|
-
docsAuth?: "public" | "required", // default "public" ("required" with oauth)
|
|
341
|
+
exclude?: string[], // path globs, or "METHOD /path" for one method
|
|
342
|
+
docsAuth?: "public" | "required", // default "public" ("required" with oauth or the hosted broker)
|
|
175
343
|
embedder?: (texts: string[]) => Promise<number[][]>,
|
|
176
344
|
telemetry?: { enabled: boolean; sink?(event): void; capturePayloads?: boolean },
|
|
177
345
|
cwd?: string, // default process.cwd()
|
|
178
346
|
mounts?: Record<string, unknown>, // explicit router/sub-app overrides
|
|
347
|
+
descriptions?: string | false, // default "exposify.descriptions.json" if present
|
|
348
|
+
openapi?: string | object, // your OpenAPI document: tool schemas + descriptions
|
|
349
|
+
login?: false | { path, fields, body? }, // your own login, for the hosted broker (found by itself)
|
|
179
350
|
});
|
|
180
351
|
```
|
|
181
352
|
|
|
@@ -193,12 +364,14 @@ default above.
|
|
|
193
364
|
correctly on Express 4 but silently drops the earlier prefix on Express 5.
|
|
194
365
|
- With `oauth`, only a global `scopes` set is checked at the edge; per-tool
|
|
195
366
|
scopes aren't supported (your routes' own `403`s still come back as tool
|
|
196
|
-
errors). Combining `oauth` with
|
|
367
|
+
errors). Combining `oauth` with the hosted broker's resolver isn't supported
|
|
197
368
|
yet.
|
|
198
|
-
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
369
|
+
- Tool calls are dispatched as real HTTP requests to a private listener on
|
|
370
|
+
`127.0.0.1` (an ephemeral port, opened on the first tool call, refusing
|
|
371
|
+
anything but the SDK's own requests). Each call carries the MCP caller's
|
|
372
|
+
socket address, `Host` and `Forwarded`/`X-Forwarded-*` headers, so
|
|
373
|
+
`req.ip`, `req.hostname`, `req.protocol` and `trust proxy` see the agent
|
|
374
|
+
exactly as they would for a direct request, never as localhost.
|
|
202
375
|
|
|
203
376
|
See the repo README for
|
|
204
377
|
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
|
+
);
|