@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 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[], // picomatch globs against the route path
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 a token-broker resolver isn't supported
367
+ errors). Combining `oauth` with the hosted broker's resolver isn't supported
197
368
  yet.
198
- - In-process dispatch uses `light-my-request`, which splices Express's
199
- process-wide request/response prototypes for the duration of each
200
- dispatch — a narrow concurrency window under real concurrent traffic, a
201
- known tradeoff tracked in `packages/express/src/surface.ts`.
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
+ );