@mapled/cli 0.14.5 → 0.15.0

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
@@ -16,6 +16,26 @@ Node 18 or newer. No dependencies. Prefer a global install? `npm install -g @map
16
16
 
17
17
  Connecting a site without an AI agent? [Integrate by hand](https://www.npmjs.com/package/@mapled/next#integrate-by-hand) in the `@mapled/next` README walks through the whole integration — modeling the content, reading it, the publish webhook, preview, forms — and uses these commands along the way.
18
18
 
19
+ ## For AI agents
20
+
21
+ Mapled is a hosted headless CMS for sites built with AI: the owner edits content in Mapled, the site reads published content through a small SDK, and you connect the two. Everything below runs from the shell of the session you are already in — no MCP server to register, no restart.
22
+
23
+ ```bash
24
+ npx @mapled/cli connect --project <id>
25
+ ```
26
+
27
+ The browser opens Mapled on that project and the person clicks Authorize; nothing is copied or pasted. The command writes `mapled.json` and prints Mapled's guide for agents (also at https://api.mapled.io/agents.md). Run again in a connected repository — or in a subdirectory of one — it opens no browser. The guide is read from Mapled, or from the origin you name with `--api` on that run; an origin that only `mapled.json` names never serves it, and a first sign-in there asks for `--api` too. Without `--project` the person picks the project in the browser.
28
+
29
+ ```bash
30
+ npx @mapled/cli tools
31
+ npx @mapled/cli tool get_schema
32
+ npx @mapled/cli tool propose_setup_plan --input plan.json
33
+ ```
34
+
35
+ `tools` lists every tool with its input (`--json` for the schemas). `tool <name>` calls one — the same tools the [MCP server](https://www.npmjs.com/package/@mapled/mcp) offers, through the same connection: the input is a JSON object, inline or in a file with `--input`; the answer is printed as Mapled worded it, and a refusal exits 1.
36
+
37
+ Do the work yourself: the person approves on Mapled screens (the connection, the plan, a destructive change) and is never sent to a terminal or to GitHub. Never ask for a token or a key in the conversation.
38
+
19
39
  ## Sign in
20
40
 
21
41
  ```bash
@@ -206,12 +226,18 @@ Mapled doctor — Dolphin Landing
206
226
  1 warning.
207
227
  ```
208
228
 
209
- The repository side: the link, the sign-in, the generated types, the schema pin, the site manifest (and whether it matches what was pushed), the integration hash — one line saying whether the pin and the manifest here describe the same integration Mapled holds, and if not, what moved (the schema, the bindings, or edits made in Mapled) and which command catches up — `MAPLED.md` (and whether it is what Mapled would render now), `MAPLED_KEY` and `MAPLED_WEBHOOK_SECRET` in your env files (names only — values are never read out), env files or Mapled secrets tracked by git, the installed `@mapled/next` and CLI versions, the revalidation and preview routes of a Next.js site, and its live updates — ○ «Not used» while no page mounts `<MapledLive />` or calls `watchRelease`, ✓ when the route they ask answers its `GET` with `createReleaseHandler` (`GET` is the handler, or always returns its call: every return is that call, the last statement is one, and nothing throws), ⚠ when it is missing (the tabs would poll a 404) with the file to add, when the route's `GET` isn't seen to answer with the handler (exported as `POST` they would get a 405; a `GET` that only mentions it, calls it and returns something else, or answers something else on another branch, answers without the release), or when no route can be checked against the endpoint — it is relative to the page, a full address (the tab asks it as written, and doctor can't tell it's the site's own), computed, or comes from spread props; a path from the site's root is checked against the route the App Router picks for it, dynamic and catch-all folders included, among route files with the site's page extensions (`pageExtensions` in next.config; `tsx`, `ts`, `jsx`, `js` by default) and under its `basePath`; ⚠ too when a page of either router, more specific than the route, takes that path first, when the dynamic route that answers sets `dynamicParams`, when the route may set `dynamic` to anything but `"auto"` or `"force-dynamic"` (a copy Next builds without the tab's request), sets `"force-dynamic"` while `createReleaseHandler` there isn't seen to keep a `revalidate` above 0 (its default does; with `false` or `0` Next reads the release past the cache tag) or `fetchCache` to anything but `"auto"` (the release read past the cache tag, before the webhook refreshes the pages), when next.config sets `output: "export"` (no server runs the route), or when next.config may set `pageExtensions`, `basePath` or `output` where doctor can't read them — it reads them only as literals, once, in the object the file exports, and only while the file is declarative: type imports, declarations of data, one export of the config object itself, and functions only as Next's hooks (`webpack`, `headers` …) in it. A module the file loads (a plugin such as `withMDX`, a `path` helper, an env check), a config exported as a function, any other code, a spread or a getter leaves the config unread. With a `basePath`, a listener that gives no endpoint asks under it from `@mapled/next` 0.10.0 — doctor reads the version the site has installed, and it's a ⚠ when that is older or missing. A `middleware` file (`proxy` in Next 16) next to the folder Next reads is a ⚠ unless its matcher, read the way Next's build reads `export const config`, surely leaves the route's path alone: it has no matcher, doctor can't read it, a source may take the path, or next.config has locales (`i18n`). `src/app` or `src/pages` counts only when the root has no `app` or `pages`, as Next reads them. On the Pages Router the route is an API route — a file of the pages folder at `/api` or under it — whose default export is `createPagesReleaseHandler` or always returns its call, and the listener is `<MapledLive />` from `@mapled/next/live/pages` in `pages/_app`: a listener without it gets the API route to add (`pages/api/mapled/release.ts` by default; an endpoint outside `/api` no API route can answer). ⚠ there too when the API route may run on the edge runtime — `runtime` or `config.runtime` other than `"nodejs"`, or written where doctor can't read it (Next reads both only as literals) — where the handler has no Node request and response; when a listener the App Router may run asks it — a file of `app/`, or on a site with both folders one either router may render: the API route answers Mapled's live release, which moves before the webhook refreshes what App Router pages read; and when `<MapledLive />` from `@mapled/next/live/pages` sits in the App Router's folder, where it can't mount. doctor reads the files, not how Next serves them at run time: rewrites and what a middleware does on the path aren't checked, and neither is whether a page renders the component that listens; `revalidate` on the route is no ⚠ — the handler's read names its own. The Mapled side, from the same status the AI agent's `check_integration` tool reads: whether the site has read content with the delivery key (for a project with a staging channel, through which key: production's or staging's — the key decides which releases a deployment shows), the publish webhook and its last delivery, the preview route on the deployed site, the bindings health.
229
+ The repository side: the link, the sign-in, the generated types, the schema pin, the site manifest (and whether it matches what was pushed), the integration hash — one line saying whether the pin and the manifest here describe the same integration Mapled holds, and if not, what moved (the schema, the bindings, or edits made in Mapled) and which command catches up — `MAPLED.md` (and whether it is what Mapled would render now), `MAPLED_KEY` and `MAPLED_WEBHOOK_SECRET` in your env files (names only — values are never read out), env files or Mapled secrets tracked by git, the installed `@mapled/next` and CLI versions, the revalidation and preview routes of a Next.js site, and its live updates — ○ «Not used» while no page mounts `<MapledLive />` or calls `watchRelease`, ✓ when the route they ask answers its `GET` with `createReleaseHandler` (`GET` is the handler, or always returns its call: every return is that call, the last statement is one, and nothing throws), ⚠ when it is missing (the tabs would poll a 404) with the file to add, when the route's `GET` isn't seen to answer with the handler (exported as `POST` they would get a 405; a `GET` that only mentions it, calls it and returns something else, or answers something else on another branch, answers without the release), or when no route can be checked against the endpoint — it is relative to the page, a full address (the tab asks it as written, and doctor can't tell it's the site's own), computed, or comes from spread props; a path from the site's root is checked against the route the App Router picks for it, dynamic and catch-all folders included, among route files with the site's page extensions (`pageExtensions` in next.config; `tsx`, `ts`, `jsx`, `js` by default) and under its `basePath`; ⚠ too when a page of either router, more specific than the route, takes that path first, when the dynamic route that answers sets `dynamicParams`, when the route may set `dynamic` to anything but `"auto"` or `"force-dynamic"` (a copy Next builds without the tab's request), sets `"force-dynamic"` while `createReleaseHandler` there isn't seen to keep a `revalidate` above 0 (its default does; with `false` or `0` Next reads the release past the cache tag) or `fetchCache` to anything but `"auto"` (the release read past the cache tag, before the webhook refreshes the pages), when next.config sets `output: "export"` (no server runs the route), or when next.config may set `pageExtensions`, `basePath` or `output` where doctor can't read them — it reads them only as literals, once, in the object the file exports, and only while the file is declarative: type imports, declarations of data, one export of the config object itself, and functions only as Next's hooks (`webpack`, `headers` …) in it. A module the file loads (a plugin such as `withMDX`, a `path` helper, an env check), a config exported as a function, any other code, a spread or a getter leaves the config unread — `mapled doctor --load-config` then has the site's own installed Next load the file (see below). With a `basePath`, a listener that gives no endpoint asks under it from `@mapled/next` 0.10.0 — doctor reads the version the site has installed, and it's a ⚠ when that is older or missing. A `middleware` file (`proxy` in Next 16) next to the folder Next reads is a ⚠ unless its matcher, read the way Next's build reads `export const config`, surely leaves the route's path alone: it has no matcher, doctor can't read it, a source may take the path, or next.config has locales (`i18n`). `src/app` or `src/pages` counts only when the root has no `app` or `pages`, as Next reads them. On the Pages Router the route is an API route — a file of the pages folder at `/api` or under it — whose default export is `createPagesReleaseHandler` or always returns its call, and the listener is `<MapledLive />` from `@mapled/next/live/pages` in `pages/_app`: a listener without it gets the API route to add (`pages/api/mapled/release.ts` by default; an endpoint outside `/api` no API route can answer). ⚠ there too when the API route may run on the edge runtime — `runtime` or `config.runtime` other than `"nodejs"`, or written where doctor can't read it (Next reads both only as literals) — where the handler has no Node request and response; when a listener the App Router may run asks it — a file of `app/`, or on a site with both folders one either router may render: the API route answers Mapled's live release, which moves before the webhook refreshes what App Router pages read; and when `<MapledLive />` from `@mapled/next/live/pages` sits in the App Router's folder, where it can't mount. doctor reads the files, not how Next serves them at run time: rewrites and what a middleware does on the path aren't checked, and neither is whether a page renders the component that listens; `revalidate` on the route is no ⚠ — the handler's read names its own. The Mapled side, from the same status the AI agent's `check_integration` tool reads: whether the site has read content with the delivery key (for a project with a staging channel, through which key: production's or staging's — the key decides which releases a deployment shows), the publish webhook and its last delivery, the preview route on the deployed site, the bindings health.
210
230
 
211
231
  A site rendered in the visitor's browser — a React single-page app (Vite and the like), plain HTML — is checked for what it has: the package line is `@mapled/react`, Environment wants `VITE_MAPLED_KEY` and no webhook secret, and the routes and the webhook read ○ «Not needed» — such a site shows a publish on its next load and trades Mapled's preview link on the page itself; Live updates reads ○ as well: `live` on `MapledProvider` or `data-mapled-live` asks Mapled directly, with no route of the site's. Plain HTML installs nothing: the line is `@mapled/vanilla`, read from the script tag in the pages (or from a copy of the file next to them) — ⚠ when the address has no version or an older one, and before that when a page's tag has an `integrity` a browser won't pass: from another origin without `crossorigin` (a browser checks only a file that came with CORS), `crossorigin="use-credentials"` on jsDelivr or unpkg (their `*` answer fails a request with credentials, integrity or not), or a digest that isn't the file's — for a copy next to the pages, the copy's own; for a jsDelivr or unpkg address, the released file's of the version in it (another host may serve a file of its own; an address with a query isn't checked — `?meta` answers with something else), which doctor knows without the network for the releases up to its own, or from `node_modules/@mapled/vanilla` of that version — and Generated types and Environment read ○: the fields are named in attributes, the delivery key sits in the page. doctor takes the framework from `mapled.json`, or works it out from `package.json` (no `package.json` and an `index.html` is plain HTML). Whatever the framework, a server key or a signing secret under a public prefix (`VITE_…`, `NEXT_PUBLIC_…`) is a ✗: it would ship to every visitor.
212
232
 
213
233
  Exit code 1 when something is marked ✗; `--json` prints the checks for scripts and agents.
214
234
 
235
+ ```bash
236
+ npx @mapled/cli doctor --load-config
237
+ ```
238
+
239
+ `--load-config` is for a next.config doctor can't read — one that calls a plugin (`withMDX(nextConfig)`), imports an env check (`import "./src/env.js"`), sets a value in a statement or exports a function. Doctor then loads it the way `next build` does: the site's installed Next (`loadConfig`) in a process of its own, in the site's folder, and takes `pageExtensions`, `basePath`, `output` and whether `i18n` is set from the result — nothing else of the config leaves that process. **This runs the site's code**: next.config and every module it loads, with your rights, as `next build` would. Without the flag doctor runs nothing of the site, and no agent tool sets it. The process gets 20 seconds and your environment without the variables named `MAPLED_*` — the site's `.env` files still load, as Next loads them, so keep `MAPLED_KEY` there if the config checks for it. A config doctor reads on its own is never loaded. When the loading fails — Next (or, for `next.config.ts`, TypeScript) isn't installed, the config throws, it doesn't finish in time — the line stays a ⚠ with the reason; doctor never guesses the settings. Of an error the config throws doctor prints only its class, and only one of JavaScript's own (`TypeError` …) — the message is the site's and may quote a value of its environment; `next build` shows it in full.
240
+
215
241
  ## Read the setup report
216
242
 
217
243
  ```bash
@@ -265,7 +291,10 @@ Prints a Preview link — the one the **Preview** button in Mapled opens: the si
265
291
 
266
292
  - `--api <origin>` — a Mapled API other than `https://api.mapled.io` (or set `MAPLED_API_URL`); `project link` remembers it in `mapled.json`
267
293
  - `--no-browser` — print the sign-in link instead of opening a browser
268
- - `--json` — machine-readable output (`doctor`, `integration report`, `schema diff`, `scan`, `manifest validate`, `forms list`, `preview`)
294
+ - `--project <id>` — the project `connect` and `auth login` open the consent on, and the one `project link` links
295
+ - `--input <file>` — `tool` only: the tool's input as a JSON file
296
+ - `--json` — machine-readable output (`tools`, `doctor`, `integration report`, `schema diff`, `scan`, `manifest validate`, `forms list`, `preview`)
297
+ - `--load-config` — `doctor` only: let the site's installed Next load a next.config doctor can't read; runs the site's code, as `next build` does
269
298
  - `MAPLED_CONFIG_DIR` — where credentials live (default: `$XDG_CONFIG_HOME/mapled` or `~/.config/mapled`)
270
299
 
271
300
  ## Files in the repository
package/dist/api.d.ts CHANGED
@@ -7,7 +7,7 @@ export declare class ApiError extends Error {
7
7
  constructor(status: number, code: string | null, message: string);
8
8
  }
9
9
  export type Method = "GET" | "POST" | "PATCH" | "DELETE";
10
- export declare function apiRequest<T>(api: string, method: Method, path: string, token: string, body: unknown, f: Fetch): Promise<T>;
10
+ export declare function apiRequest<T>(api: string, method: Method, path: string, token: string, body: unknown, f: Fetch, accept?: string): Promise<T>;
11
11
  export declare function apiGet<T>(api: string, path: string, token: string, f: Fetch): Promise<T>;
12
12
  /** A signed-in connection with its store: reads refresh the access
13
13
  token when it is about to expire or when the API says it did, and
@@ -20,6 +20,10 @@ export declare class Session {
20
20
  constructor(store: CredentialsFile, file: string, conn: Connection, f: Fetch);
21
21
  get<T>(path: string): Promise<T>;
22
22
  send<T>(method: Method, path: string, body: unknown): Promise<T>;
23
+ /** One JSON-RPC call to the hosted MCP endpoint — the tools an agent
24
+ gets over MCP, from the shell (the endpoint is stateless: a call
25
+ needs no `initialize`). */
26
+ rpc<T>(method: string, params?: unknown): Promise<T>;
23
27
  private request;
24
28
  private refresh;
25
29
  }
package/dist/api.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { upsertConnection, writeCredentials } from "./credentials.js";
2
- import { CliError } from "./errors.js";
2
+ import { CliError, SignInNeeded } from "./errors.js";
3
3
  import { refreshTokens } from "./oauth.js";
4
4
  /** Calls to the agent API (/v1/agent/*) with a connection's bearer token. */
5
5
  export class ApiError extends Error {
@@ -12,14 +12,14 @@ export class ApiError extends Error {
12
12
  this.code = code;
13
13
  }
14
14
  }
15
- export async function apiRequest(api, method, path, token, body, f) {
15
+ export async function apiRequest(api, method, path, token, body, f, accept = "application/json") {
16
16
  let res;
17
17
  try {
18
18
  res = await f(`${api}${path}`, {
19
19
  method,
20
20
  headers: {
21
21
  authorization: `Bearer ${token}`,
22
- accept: "application/json",
22
+ accept,
23
23
  ...(body !== undefined ? { "content-type": "application/json" } : {}),
24
24
  },
25
25
  ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
@@ -57,19 +57,29 @@ export class Session {
57
57
  send(method, path, body) {
58
58
  return this.request(method, path, body);
59
59
  }
60
- async request(method, path, body) {
60
+ /** One JSON-RPC call to the hosted MCP endpoint — the tools an agent
61
+ gets over MCP, from the shell (the endpoint is stateless: a call
62
+ needs no `initialize`). */
63
+ async rpc(method, params) {
64
+ const answer = await this.request("POST", "/mcp", { jsonrpc: "2.0", id: 1, method, ...(params !== undefined ? { params } : {}) }, "application/json, text/event-stream");
65
+ if (answer?.error || answer?.result === undefined) {
66
+ throw new CliError(answer?.error?.message ?? "Mapled gave no answer. Try again.");
67
+ }
68
+ return answer.result;
69
+ }
70
+ async request(method, path, body, accept) {
61
71
  if (new Date(this.conn.expiresAt).getTime() - Date.now() < 60_000)
62
72
  await this.refresh();
63
73
  try {
64
- return await apiRequest(this.conn.api, method, path, this.conn.accessToken, body, this.f);
74
+ return await apiRequest(this.conn.api, method, path, this.conn.accessToken, body, this.f, accept);
65
75
  }
66
76
  catch (err) {
67
77
  if (err instanceof ApiError && err.status === 401) {
68
78
  if (err.code === "TOKEN_EXPIRED") {
69
79
  await this.refresh();
70
- return apiRequest(this.conn.api, method, path, this.conn.accessToken, body, this.f);
80
+ return apiRequest(this.conn.api, method, path, this.conn.accessToken, body, this.f, accept);
71
81
  }
72
- throw new CliError(`The connection to ${this.conn.projectName} was revoked. Run \`mapled auth login\` to sign in again.`);
82
+ throw new SignInNeeded(`The connection to ${this.conn.projectName} was revoked. Run \`mapled auth login\` to sign in again.`);
73
83
  }
74
84
  throw err;
75
85
  }
@@ -80,7 +90,7 @@ export class Session {
80
90
  tokens = await refreshTokens(this.conn.api, this.conn.clientId, this.conn.refreshToken, this.f);
81
91
  }
82
92
  catch {
83
- throw new CliError(`Your sign-in to ${this.conn.projectName} expired. Run \`mapled auth login\` to sign in again.`);
93
+ throw new SignInNeeded(`Your sign-in to ${this.conn.projectName} expired. Run \`mapled auth login\` to sign in again.`);
84
94
  }
85
95
  this.conn = { ...this.conn, ...tokens };
86
96
  this.store = upsertConnection(this.store, this.conn);
package/dist/args.js CHANGED
@@ -2,7 +2,7 @@ import { CliError } from "./errors.js";
2
2
  /** Minimal argument parsing: the leading words name the command;
3
3
  `--flag`, `--flag=value` and `--flag value` (for the flags that take
4
4
  one) follow. Nothing here is user content beyond the command line. */
5
- const VALUE_FLAGS = new Set(["api", "project", "out", "key", "locale"]);
5
+ const VALUE_FLAGS = new Set(["api", "project", "out", "key", "locale", "input"]);
6
6
  export function parseArgs(argv) {
7
7
  const words = [];
8
8
  const flags = {};
@@ -22,6 +22,19 @@ type Flags = ParsedArgs["flags"];
22
22
  export declare function login(ctx: Ctx, flags: Flags): Promise<Connection>;
23
23
  export declare function logout(ctx: Ctx, flags: Flags): Promise<void>;
24
24
  export declare function link(ctx: Ctx, flags: Flags): Promise<FoundConfig>;
25
+ /** `mapled connect [--project <id>]` — everything an agent needs before
26
+ its first tool call, in one run: sign in through the browser (the
27
+ consent opens on the named project), write mapled.json, print
28
+ Mapled's guide for agents. A repository that is already connected
29
+ opens no browser. */
30
+ export declare function connect(ctx: Ctx, flags: Flags): Promise<void>;
31
+ /** `mapled tools [--json]` — every tool the hosted MCP endpoint offers,
32
+ with its description and input: what `mapled tool` can call. */
33
+ export declare function tools(ctx: Ctx, flags: Flags): Promise<void>;
34
+ /** `mapled tool <name> [<json> | --input <file>]` — one tool call, the
35
+ way an MCP client makes it: the answer is printed as Mapled worded
36
+ it, a refusal exits 1. */
37
+ export declare function tool(ctx: Ctx, flags: Flags, words: string[]): Promise<number>;
25
38
  export declare function generate(ctx: Ctx, flags: Flags): Promise<void>;
26
39
  export declare function schemaPull(ctx: Ctx, flags: Flags): Promise<void>;
27
40
  export declare function schemaDiff(ctx: Ctx, flags: Flags): Promise<number>;
package/dist/commands.js CHANGED
@@ -2,10 +2,10 @@ import { readFile, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { ApiError, apiGet, Session } from "./api.js";
4
4
  import { stringFlag } from "./args.js";
5
- import { CONFIG_FILE, DEFAULT_TYPES_PATH, findConfig, resolveApi, writeConfig } from "./config.js";
5
+ import { CONFIG_FILE, DEFAULT_API, DEFAULT_TYPES_PATH, findConfig, resolveApi, writeConfig } from "./config.js";
6
6
  import { connectionsFor, findConnection, readCredentials, removeConnection, upsertConnection, writeCredentials, } from "./credentials.js";
7
7
  import * as dr from "./doctor.js";
8
- import { CliError } from "./errors.js";
8
+ import { CliError, SignInNeeded } from "./errors.js";
9
9
  import { checkManifest, compareManifests, fileExistsIn, MANIFEST_FILE, manifestSummary, orderManifest, parseManifest, plural, readManifestFile, writeManifestFile, } from "./manifest.js";
10
10
  import { authorize, clientKnown, exchangeCode, registerClient, revokeToken } from "./oauth.js";
11
11
  import { formatChecks, GLYPH, paint, summarize, useColor } from "./output.js";
@@ -28,6 +28,14 @@ async function ensureClient(store, api, f) {
28
28
  clientId,
29
29
  };
30
30
  }
31
+ /** `--project <id>`: a project id as Mapled writes it, or nothing. */
32
+ function projectFlag(flags) {
33
+ const value = stringFlag(flags, "project")?.toLowerCase();
34
+ if (value !== undefined && !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(value)) {
35
+ throw new CliError("--project takes a project id — the one in the prompt copied from Mapled.");
36
+ }
37
+ return value;
38
+ }
31
39
  export async function login(ctx, flags) {
32
40
  const found = await findConfig(ctx.cwd);
33
41
  const api = resolveApi(found?.config ?? null, stringFlag(flags, "api"), ctx.env);
@@ -38,6 +46,7 @@ export async function login(ctx, flags) {
38
46
  openBrowser: flags["no-browser"] ? async () => false : ctx.openBrowser,
39
47
  log: ctx.out,
40
48
  timeoutMs: ctx.loginTimeoutMs,
49
+ project: projectFlag(flags),
41
50
  });
42
51
  const tokens = await exchangeCode(api, client.clientId, grant, ctx.fetch);
43
52
  const status = await apiGet(api, "/v1/agent/integration", tokens.accessToken, ctx.fetch);
@@ -51,7 +60,7 @@ export async function login(ctx, flags) {
51
60
  };
52
61
  store = upsertConnection(store, conn);
53
62
  await writeCredentials(ctx.credentialsFile, store);
54
- ctx.out(`Signed in to ${conn.projectName}.`);
63
+ ctx.out(`Signed in to ${clean(conn.projectName, 80)}.`);
55
64
  if (!found)
56
65
  ctx.out("Next: run `mapled project link` in the site's repository.");
57
66
  else if (found.config.project !== conn.projectId) {
@@ -131,6 +140,122 @@ export async function link(ctx, flags) {
131
140
  ctx.out("Next: `mapled types generate`, then `mapled doctor`.");
132
141
  return { path: file, dir: path.resolve(ctx.cwd), config };
133
142
  }
143
+ /** `mapled connect [--project <id>]` — everything an agent needs before
144
+ its first tool call, in one run: sign in through the browser (the
145
+ consent opens on the named project), write mapled.json, print
146
+ Mapled's guide for agents. A repository that is already connected
147
+ opens no browser. */
148
+ export async function connect(ctx, flags) {
149
+ const wanted = projectFlag(flags);
150
+ // the nearest mapled.json, here or above: a subdirectory of a connected
151
+ // repository is connected
152
+ const linked = await findConfig(ctx.cwd);
153
+ const flagApi = stringFlag(flags, "api");
154
+ const api = resolveApi(linked?.config ?? null, flagApi, ctx.env);
155
+ // The guide is text an agent follows, so it comes from Mapled or from
156
+ // an origin the person named on this run (the flag, the environment) —
157
+ // never from one only a file of the repository names: mapled.json is
158
+ // committed, and whoever wrote it would be writing the agent's guide.
159
+ const guideApi = !flagApi && linked?.config.api && api !== DEFAULT_API ? DEFAULT_API : api;
160
+ const store = await readCredentials(ctx.credentialsFile);
161
+ const target = wanted ?? linked?.config.project;
162
+ let conn = target ? findConnection(store, api, target) : undefined;
163
+ if (conn) {
164
+ // a connection revoked in Integrations is of no use: ask the API once
165
+ try {
166
+ await new Session(store, ctx.credentialsFile, conn, ctx.fetch).get("/v1/agent/integration");
167
+ ctx.out(`Already connected to ${clean(conn.projectName, 80)}.`);
168
+ }
169
+ catch (err) {
170
+ // only a connection that is gone asks for a new sign-in: an API
171
+ // that can't be reached right now is said as it is
172
+ if (!(err instanceof SignInNeeded))
173
+ throw err;
174
+ conn = undefined;
175
+ }
176
+ }
177
+ if (!conn && api !== guideApi) {
178
+ // Signing in sends the person's browser to this origin, and what it
179
+ // answers is printed for the agent: an origin only the committed
180
+ // mapled.json names has to be named by the person too.
181
+ throw new CliError(`${CONFIG_FILE} here names another API, ${api}. To sign in there, run \`mapled connect --api ${api}\`.`);
182
+ }
183
+ if (!conn) {
184
+ // a repository linked before opens the consent on its own project
185
+ conn = await login(ctx, target ? { ...flags, project: target } : flags);
186
+ if (wanted && conn.projectId !== wanted) {
187
+ ctx.out(`Note: ${clean(conn.projectName, 80)} was picked in the browser — not the project the prompt named. Continuing with it.`);
188
+ }
189
+ }
190
+ if (!linked || linked.config.project !== conn.projectId) {
191
+ await link(ctx, { ...flags, project: conn.projectId });
192
+ }
193
+ ctx.out("");
194
+ try {
195
+ const res = await ctx.fetch(`${guideApi}/agents.md`);
196
+ if (!res.ok)
197
+ throw new Error();
198
+ for (const line of (await res.text()).split("\n"))
199
+ ctx.out(line);
200
+ }
201
+ catch {
202
+ ctx.out(`Connected. The guide for agents: ${guideApi}/agents.md`);
203
+ }
204
+ }
205
+ /** `mapled tools [--json]` — every tool the hosted MCP endpoint offers,
206
+ with its description and input: what `mapled tool` can call. */
207
+ export async function tools(ctx, flags) {
208
+ const { session } = await openSession(ctx, flags);
209
+ const { tools: list } = await session.rpc("tools/list");
210
+ if (flags.json) {
211
+ ctx.out(JSON.stringify(list, null, 2));
212
+ return;
213
+ }
214
+ for (const tool of list) {
215
+ const required = new Set(tool.inputSchema?.required ?? []);
216
+ const input = Object.keys(tool.inputSchema?.properties ?? {}).map((k) => (required.has(k) ? k : `${k}?`));
217
+ ctx.out(`${tool.name}${input.length > 0 ? ` { ${input.join(", ")} }` : ""}`);
218
+ if (tool.description)
219
+ ctx.out(` ${tool.description}`);
220
+ ctx.out("");
221
+ }
222
+ ctx.out("Call one: `mapled tool <name> '<json>'` or `mapled tool <name> --input <file.json>`; `mapled tools --json` shows each input's schema.");
223
+ }
224
+ /** `mapled tool <name> [<json> | --input <file>]` — one tool call, the
225
+ way an MCP client makes it: the answer is printed as Mapled worded
226
+ it, a refusal exits 1. */
227
+ export async function tool(ctx, flags, words) {
228
+ const [name, inline] = words;
229
+ if (!name)
230
+ throw new CliError("Which tool? `mapled tools` lists them.", 2);
231
+ const file = stringFlag(flags, "input");
232
+ if (file !== undefined && inline !== undefined)
233
+ throw new CliError("Pass the input as JSON or with --input, not both.", 2);
234
+ let raw = inline ?? "{}";
235
+ if (file !== undefined) {
236
+ try {
237
+ raw = await readFile(path.resolve(ctx.cwd, file), "utf8");
238
+ }
239
+ catch {
240
+ throw new CliError(`Couldn't read ${file}.`);
241
+ }
242
+ }
243
+ let args;
244
+ try {
245
+ args = JSON.parse(raw);
246
+ }
247
+ catch {
248
+ throw new CliError("The input isn't valid JSON.");
249
+ }
250
+ if (args === null || typeof args !== "object" || Array.isArray(args))
251
+ throw new CliError("The input must be a JSON object.");
252
+ const { session } = await openSession(ctx, flags);
253
+ const answer = await session.rpc("tools/call", { name, arguments: args });
254
+ for (const part of answer.content ?? [])
255
+ if (part.type === "text" && part.text !== undefined)
256
+ ctx.out(part.text);
257
+ return answer.isError ? 1 : 0;
258
+ }
134
259
  async function openSession(ctx, flags) {
135
260
  const found = await findConfig(ctx.cwd);
136
261
  if (!found)
@@ -731,7 +856,8 @@ export async function doctor(ctx, flags) {
731
856
  checks.push(framework === "plain-html" ? dr.checkScriptTag(await dr.findScriptTag(dir, status?.site?.url), status?.sdk[sdk]) : dr.checkSdk(await dr.installedVersion(dir, sdk), status?.sdk[sdk], sdk));
732
857
  checks.push(dr.checkCli(ctx.version, status?.sdk["@mapled/cli"]));
733
858
  // the live facts first: the revalidate route's answer matters once a page listens (#435)
734
- const live = !framework || framework === "nextjs" ? await findLive(dir) : null;
859
+ // --load-config: a next.config the reading leaves unread is loaded by the site's own Next — the one place doctor runs the site's code
860
+ const live = !framework || framework === "nextjs" ? await findLive(dir, undefined, flags["load-config"] ? { loadConfig: { env: ctx.env } } : {}) : null;
735
861
  checks.push(dr.checkRoute("revalidate", await dr.findRoute(dir, "revalidate", framework), framework, (live?.listeners.length ?? 0) > 0));
736
862
  checks.push(dr.checkRoute("preview", await dr.findRoute(dir, "preview", framework), framework));
737
863
  // the code is read only where the check applies: a Next.js site (or one doctor can't place)
package/dist/doctor.js CHANGED
@@ -5,7 +5,7 @@ import path from "node:path";
5
5
  import { promisify } from "node:util";
6
6
  import { parseDocument } from "./html5.js";
7
7
  import { integrationHash } from "./integrationHash.js";
8
- import { BASE_PATH_SINCE, fullAddress, liveState, pagesRouteFileFor, RELEASE_ROUTE, revalidateRouteFacts, routeFileFor, runsIn } from "./live.js";
8
+ import { BASE_PATH_SINCE, CONFIG_LOAD_TIMEOUT_MS, fullAddress, liveState, pagesRouteFileFor, RELEASE_ROUTE, revalidateRouteFacts, routeFileFor, runsIn } from "./live.js";
9
9
  import { compareManifests, manifestSummary, MANIFEST_FILE } from "./manifest.js";
10
10
  import { SCHEMA_FILE } from "./pin.js";
11
11
  import { htmlPage } from "./scanHtml.js";
@@ -885,13 +885,25 @@ export function checkLive(facts, framework) {
885
885
  ? `${who(state.listener)} asks ${state.listener.endpoint}, a full address — the tab asks it as written, and doctor can't tell it's this site's own, so no route can be checked. Give the route's path, like ${RELEASE_ROUTE}.`
886
886
  : `${who(state.listener)} asks ${state.listener.endpoint}, which isn't a path from the site's root — each page resolves it against its own address, so no route can be checked. Give the route's path, like ${RELEASE_ROUTE}.`,
887
887
  };
888
- case "config":
888
+ case "config": {
889
+ if (state.load) {
890
+ // --load-config had Next load it, and that gave no settings: the reason, never a guess
891
+ const why = {
892
+ no_next: `Next isn't installed here, so nothing can load ${state.file} — install the site's dependencies and run \`mapled doctor --load-config\` again`,
893
+ no_typescript: `TypeScript isn't installed here, and Next would install it to load ${state.file} — install the site's dependencies and run \`mapled doctor --load-config\` again`,
894
+ threw: `Next couldn't load ${state.file} — it threw ${state.load.detail ?? "an error"}; \`next build\` shows the error`,
895
+ timeout: `Next didn't finish loading ${state.file} in ${Math.round(CONFIG_LOAD_TIMEOUT_MS / 1000)} seconds, so doctor stopped it`,
896
+ output: `Next loaded ${state.file} without settings doctor can read`,
897
+ };
898
+ return { key, label, status: "warning", detail: `${why[state.load.reason]}. No route can be checked for ${who(state.listener)}.` };
899
+ }
889
900
  return {
890
901
  key,
891
902
  label,
892
903
  status: "warning",
893
- detail: `${state.file} may set pageExtensions, basePath or output where doctor can't read them — code that runs as Next loads it, a plugin's or a function's result, a value or name the code builds, an object from another file or spread in — so no route can be checked for ${who(state.listener)}. Write them as literals in the object it exports, or leave them out.`,
904
+ detail: `${state.file} may set pageExtensions, basePath or output where doctor can't read them — code that runs as Next loads it, a plugin's or a function's result, a value or name the code builds, an object from another file or spread in — so no route can be checked for ${who(state.listener)}. Write them as literals in the object it exports, or leave them out — or run \`mapled doctor --load-config\` to load it as Next does: that runs the file and what it loads, as \`next build\` does.`,
894
905
  };
906
+ }
895
907
  case "export":
896
908
  return {
897
909
  key,
@@ -1070,6 +1082,15 @@ export function checkWebhook(webhook, site) {
1070
1082
  detail: `${host} resolves to a private address, which Mapled can't reach. Point the webhook at the public deployment.`,
1071
1083
  };
1072
1084
  }
1085
+ if (last.responseStatus === 503) {
1086
+ // what the SDK's route answers before the site has the signing secret (deployed first, as the README says)
1087
+ return {
1088
+ key: "webhook",
1089
+ label,
1090
+ status: "failed",
1091
+ detail: `The last delivery to ${host} responded 503 — the route answers that while MAPLED_WEBHOOK_SECRET isn't set on the site. Set it there, redeploy, then send a test ping.`,
1092
+ };
1093
+ }
1073
1094
  const reason = last.responseStatus ? `responded ${last.responseStatus}` : (last.error ?? "failed");
1074
1095
  return {
1075
1096
  key: "webhook",
package/dist/errors.d.ts CHANGED
@@ -4,3 +4,9 @@ export declare class CliError extends Error {
4
4
  exitCode: number;
5
5
  constructor(message: string, exitCode?: number);
6
6
  }
7
+ /** The stored connection no longer works — revoked in Mapled, or its
8
+ refresh token spent: only a new sign-in helps. A network failure is
9
+ not this. */
10
+ export declare class SignInNeeded extends CliError {
11
+ constructor(message: string);
12
+ }
package/dist/errors.js CHANGED
@@ -8,3 +8,12 @@ export class CliError extends Error {
8
8
  this.exitCode = exitCode;
9
9
  }
10
10
  }
11
+ /** The stored connection no longer works — revoked in Mapled, or its
12
+ refresh token spent: only a new sign-in helps. A network failure is
13
+ not this. */
14
+ export class SignInNeeded extends CliError {
15
+ constructor(message) {
16
+ super(message);
17
+ this.name = "SignInNeeded";
18
+ }
19
+ }
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@ import { readFileSync } from "node:fs";
3
3
  import { createInterface } from "node:readline/promises";
4
4
  import { parseArgs } from "./args.js";
5
5
  import { openBrowser } from "./browser.js";
6
- import { bindingsPull, bindingsPush, doctor, formsCreate, formsList, generate, integrationReport, link, login, logout, manifestValidate, mdPull, preview, scan, schemaDiff, schemaPull, } from "./commands.js";
6
+ import { bindingsPull, bindingsPush, doctor, formsCreate, formsList, generate, integrationReport, link, login, logout, manifestValidate, mdPull, preview, scan, connect, tool, tools, schemaDiff, schemaPull, } from "./commands.js";
7
7
  import { credentialsPath } from "./credentials.js";
8
8
  import { CliError } from "./errors.js";
9
9
  /** `mapled` — sign in, link a repository to its project, generate types
@@ -13,6 +13,9 @@ import { CliError } from "./errors.js";
13
13
  const HELP = `mapled — the Mapled CLI
14
14
 
15
15
  Usage
16
+ mapled connect [--project <id>] Connect this repository: sign in, write mapled.json, print the guide for AI agents
17
+ mapled tools [--json] List the Mapled tools an agent can call
18
+ mapled tool <name> [<json> | --input <file>] Call one tool — the same ones the MCP server offers
16
19
  mapled auth login [--api <origin>] [--no-browser] Sign in to a project in your browser
17
20
  mapled auth logout [--all] Revoke this machine's access to the linked project
18
21
  mapled project link [--project <id>] Write mapled.json for this repository
@@ -26,7 +29,7 @@ Usage
26
29
  mapled md pull [--force] Write MAPLED.md, the guide for the next agent, from the project
27
30
  mapled forms create <name> [--key <key>] Create a form the site posts submissions to (--no-spam-protection to skip the honeypot)
28
31
  mapled forms list [--json] List the project's forms and where the site posts to
29
- mapled doctor [--json] Check the integration end to end
32
+ mapled doctor [--json] [--load-config] Check the integration end to end; --load-config runs a next.config doctor can't read, as next build does
30
33
  mapled integration report [--json] [--verify] Show the last setup run's verification checklist and report
31
34
  mapled preview [<collection> [<slug>]] [--locale <code>] [--open] Print a one-time Preview link that shows the drafts on the site
32
35
 
@@ -36,6 +39,7 @@ Options
36
39
 
37
40
  Credentials stay in your user config directory, never in the repository.
38
41
  Run it as \`npx @mapled/cli <command>\`, or install it globally with \`npm install -g @mapled/cli\` to get \`mapled\` on your PATH.`;
42
+ const ROOT_COMMANDS = new Set(["preview", "connect", "tools", "tool"]);
39
43
  function version() {
40
44
  return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
41
45
  }
@@ -80,8 +84,17 @@ async function main(argv) {
80
84
  version: version(),
81
85
  credentialsFile: credentialsPath(),
82
86
  };
83
- // `preview` takes the record it opens as words of its own
84
- switch (group === "preview" ? group : `${group} ${sub ?? ""}`.trim()) {
87
+ // commands of one word: what follows them is theirs (`preview` takes the
88
+ // record it opens, `tool` the tool it calls)
89
+ switch (ROOT_COMMANDS.has(group) ? group : `${group} ${sub ?? ""}`.trim()) {
90
+ case "connect":
91
+ await connect(ctx, flags);
92
+ return 0;
93
+ case "tools":
94
+ await tools(ctx, flags);
95
+ return 0;
96
+ case "tool":
97
+ return tool(ctx, flags, words.slice(1));
85
98
  case "auth login":
86
99
  await login(ctx, flags);
87
100
  return 0;
package/dist/live.d.ts CHANGED
@@ -74,7 +74,8 @@ export type PageFile = {
74
74
  finds the package from the site's folder up — absent when none is
75
75
  installed. `output`: next.config sets `output: "export"` (value
76
76
  "export") or may set it where the reading can't tell (null) — then
77
- there may be no server to run any route. */
77
+ there may be no server to run any route. `configLoad`: `mapled doctor
78
+ --load-config` had Next load that file and it didn't — why. */
78
79
  export type LiveFacts = {
79
80
  listeners: LiveListener[];
80
81
  routes: RouteFile[];
@@ -84,6 +85,7 @@ export type LiveFacts = {
84
85
  pagesDir?: string;
85
86
  basePath?: string;
86
87
  config?: string;
88
+ configLoad?: ConfigLoadFailure;
87
89
  sdk?: string;
88
90
  output?: {
89
91
  file: string;
@@ -148,6 +150,7 @@ export type LiveState = {
148
150
  kind: "config";
149
151
  listener: LiveListener;
150
152
  file: string;
153
+ load?: ConfigLoadFailure;
151
154
  } | {
152
155
  kind: "outside";
153
156
  listener: LiveListener;
@@ -298,7 +301,39 @@ export declare function sourceMayTake(source: string, at: string): boolean;
298
301
  /** Whether a version is at least `min`, by its major, minor and patch —
299
302
  false for one that doesn't start with three numbers. */
300
303
  export declare function atLeast(version: string, min: string): boolean;
301
- export declare function findLive(dir: string, maxFiles?: number): Promise<LiveFacts>;
304
+ /** Why Next's own loading of next.config gave no settings: the site has no
305
+ Next installed (`no_next`) or, for a next.config.ts, no TypeScript
306
+ (`no_typescript` — Next would install it), the loading threw (`threw`, with the error's
307
+ class — never its message, the site's text), it didn't end in time (`timeout`), or it ended without
308
+ settings the reading takes (`output`). */
309
+ export type ConfigLoadFailure = {
310
+ reason: "no_next" | "no_typescript" | "threw" | "timeout" | "output";
311
+ detail?: string;
312
+ };
313
+ /** `timeoutMs`: how long the loading may take (tests shorten it). `env`:
314
+ the environment the child starts from. */
315
+ export type ConfigLoadOptions = {
316
+ timeoutMs?: number;
317
+ env?: NodeJS.ProcessEnv;
318
+ };
319
+ /** How long `mapled doctor --load-config` lets Next load next.config. */
320
+ export declare const CONFIG_LOAD_TIMEOUT_MS = 20000;
321
+ /** The site's next.config `file` as the site's installed Next loads it (`loadConfig`, the
322
+ reference `live.test.ts` holds `nextConfigOf` to) — for a config the
323
+ declarative reading leaves unread. Unlike that reading this RUNS the
324
+ site's code — the config and every module it loads, as `next build`
325
+ does — so only `mapled doctor --load-config` asks for it, never a tool
326
+ of an agent. It runs in a process of its own, in the site's folder, for
327
+ `timeoutMs` at most, without the `MAPLED_*` variables of doctor's own
328
+ environment (the site's .env files still load, as Next loads them);
329
+ its stdout and stderr go nowhere, and of the config only the settings
330
+ come back. Any failure is a reason, never a guess at the settings. */
331
+ export declare function loadNextConfig(dir: string, file: string, options?: ConfigLoadOptions): Promise<NextConfig | ConfigLoadFailure>;
332
+ /** `loadConfig`: `mapled doctor --load-config` — a next.config the reading
333
+ leaves unread is loaded by the site's Next (loadNextConfig). */
334
+ export declare function findLive(dir: string, maxFiles?: number, options?: {
335
+ loadConfig?: ConfigLoadOptions;
336
+ }): Promise<LiveFacts>;
302
337
  /** Whether a literal endpoint names its own origin — `https://…`, `//host/…`
303
338
  — which the code doesn't show to be the site's: `watchRelease` fetches it
304
339
  as written, and no route file of this site is known to answer it. */
package/dist/live.js CHANGED
@@ -1,4 +1,6 @@
1
+ import { spawn, spawnSync } from "node:child_process";
1
2
  import { readdir, readFile, stat } from "node:fs/promises";
3
+ import { createRequire } from "node:module";
2
4
  import path from "node:path";
3
5
  import { appRoute, listFiles, tokenize } from "./scan.js";
4
6
  /** Live updates (§18.8) as the site's code shows them. A Next.js site
@@ -2246,7 +2248,158 @@ async function middlewareFiles(dir, extensions, next) {
2246
2248
  }
2247
2249
  return found;
2248
2250
  }
2249
- export async function findLive(dir, maxFiles = 4000) {
2251
+ /** How long `mapled doctor --load-config` lets Next load next.config. */
2252
+ export const CONFIG_LOAD_TIMEOUT_MS = 20_000;
2253
+ // what the child may answer: four settings fit in far less
2254
+ const CONFIG_LOAD_MAX = 64 * 1024;
2255
+ // the error classes doctor names: JavaScript's own — any other name is the site's text
2256
+ const ERROR_CLASSES = ["Error", "TypeError", "RangeError", "ReferenceError", "SyntaxError", "EvalError", "URIError", "AggregateError"];
2257
+ // The child: the site's installed Next loads the config as `next build` does — the site's .env files first, then
2258
+ // loadConfig — and answers on its own pipe (fd 3) with the settings doctor reads, nothing else of the config.
2259
+ const CONFIG_LOADER = `
2260
+ const { createRequire } = require("node:module");
2261
+ const { writeSync } = require("node:fs");
2262
+ const [next, dir] = process.argv.slice(1);
2263
+ const send = (answer) => {
2264
+ try {
2265
+ writeSync(3, JSON.stringify(answer) + "\\n");
2266
+ } catch {}
2267
+ process.exit(0);
2268
+ };
2269
+ // of an error only its class: the message is the site's text and may quote a value of its environment
2270
+ const failed = (e) => {
2271
+ let name = "Error";
2272
+ try {
2273
+ name = String((e && e.name) || "Error");
2274
+ } catch {}
2275
+ send({ error: name });
2276
+ };
2277
+ process.on("uncaughtException", failed);
2278
+ process.on("unhandledRejection", failed);
2279
+ (async () => {
2280
+ const require = createRequire(next);
2281
+ process.env.NODE_ENV = process.env.NODE_ENV || "production";
2282
+ // as the next CLI sets it before a build
2283
+ process.env.NEXT_RUNTIME = "nodejs";
2284
+ try {
2285
+ require("@next/env").loadEnvConfig(dir, false, { info() {}, error() {} });
2286
+ } catch {}
2287
+ const loaded = require("next/dist/server/config");
2288
+ const loadConfig = loaded.default ?? loaded;
2289
+ const { PHASE_PRODUCTION_BUILD } = require("next/constants");
2290
+ const c = await loadConfig(PHASE_PRODUCTION_BUILD, dir, { silent: true });
2291
+ send({ settings: { pageExtensions: c.pageExtensions, basePath: c.basePath, output: c.output ?? null, i18n: c.i18n != null } });
2292
+ })().catch(failed);
2293
+ `;
2294
+ /** The site's next.config `file` as the site's installed Next loads it (`loadConfig`, the
2295
+ reference `live.test.ts` holds `nextConfigOf` to) — for a config the
2296
+ declarative reading leaves unread. Unlike that reading this RUNS the
2297
+ site's code — the config and every module it loads, as `next build`
2298
+ does — so only `mapled doctor --load-config` asks for it, never a tool
2299
+ of an agent. It runs in a process of its own, in the site's folder, for
2300
+ `timeoutMs` at most, without the `MAPLED_*` variables of doctor's own
2301
+ environment (the site's .env files still load, as Next loads them);
2302
+ its stdout and stderr go nowhere, and of the config only the settings
2303
+ come back. Any failure is a reason, never a guess at the settings. */
2304
+ export async function loadNextConfig(dir, file, options = {}) {
2305
+ let next;
2306
+ try {
2307
+ // resolved, not loaded: no code of the site runs here
2308
+ next = createRequire(path.join(path.resolve(dir), "package.json")).resolve("next/package.json");
2309
+ }
2310
+ catch {
2311
+ return { reason: "no_next" };
2312
+ }
2313
+ // Next installs TypeScript itself when a next.config.ts finds none: doctor changes nothing of the site
2314
+ if (/\.m?ts$/.test(file)) {
2315
+ try {
2316
+ createRequire(path.join(path.resolve(dir), "package.json")).resolve("typescript");
2317
+ }
2318
+ catch {
2319
+ return { reason: "no_typescript" };
2320
+ }
2321
+ }
2322
+ const env = {};
2323
+ for (const [name, value] of Object.entries(options.env ?? process.env))
2324
+ if (!/^MAPLED_/i.test(name))
2325
+ env[name] = value;
2326
+ const answer = await new Promise((resolve) => {
2327
+ let settled = false;
2328
+ let text = "";
2329
+ // a group of its own where the system has them (a tree ended by taskkill where it doesn't): what the config started ends with it
2330
+ const grouped = process.platform !== "win32";
2331
+ const child = spawn(process.execPath, ["-e", CONFIG_LOADER, next, path.resolve(dir)], { cwd: dir, env, stdio: ["ignore", "ignore", "ignore", "pipe"], windowsHide: true, detached: grouped });
2332
+ const settle = (value) => {
2333
+ if (settled)
2334
+ return;
2335
+ settled = true;
2336
+ clearTimeout(timer);
2337
+ try {
2338
+ if (child.pid === undefined) {
2339
+ // never started
2340
+ }
2341
+ else if (grouped)
2342
+ process.kill(-child.pid, "SIGKILL");
2343
+ // Windows has no groups: taskkill ends the tree — to its end before the loader is killed below, which it finds the tree by
2344
+ else
2345
+ spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore", windowsHide: true, timeout: 5000 });
2346
+ }
2347
+ catch {
2348
+ // the group is gone already
2349
+ }
2350
+ child.kill("SIGKILL");
2351
+ child.stdio[3]?.destroy();
2352
+ resolve(value);
2353
+ };
2354
+ const timer = setTimeout(() => settle("timeout"), options.timeoutMs ?? CONFIG_LOAD_TIMEOUT_MS);
2355
+ child.on("error", () => settle(null));
2356
+ child.on("close", () => settle(null));
2357
+ const pipe = child.stdio[3];
2358
+ pipe?.on("error", () => settle(null));
2359
+ pipe?.on("data", (chunk) => {
2360
+ text += chunk.toString("utf8");
2361
+ // the first line is the answer; whatever else the site's code writes there isn't read
2362
+ const end = text.indexOf("\n");
2363
+ if (end >= 0)
2364
+ settle(text.slice(0, end));
2365
+ else if (text.length > CONFIG_LOAD_MAX)
2366
+ settle(null);
2367
+ });
2368
+ });
2369
+ if (answer === "timeout")
2370
+ return { reason: "timeout" };
2371
+ if (answer === null || answer.length > CONFIG_LOAD_MAX)
2372
+ return { reason: "output" };
2373
+ let parsed;
2374
+ try {
2375
+ parsed = JSON.parse(answer);
2376
+ }
2377
+ catch {
2378
+ return { reason: "output" };
2379
+ }
2380
+ if (typeof parsed !== "object" || parsed === null)
2381
+ return { reason: "output" };
2382
+ const { error, settings } = parsed;
2383
+ // the class alone, and only one the language has: a name is the site's text too, and one made up may be a key
2384
+ if (typeof error === "string")
2385
+ return { reason: "threw", ...(ERROR_CLASSES.includes(error) ? { detail: error } : {}) };
2386
+ if (typeof settings !== "object" || settings === null)
2387
+ return { reason: "output" };
2388
+ const { pageExtensions, basePath, output, i18n } = settings;
2389
+ if (!Array.isArray(pageExtensions) || pageExtensions.length > 100 || !pageExtensions.every((ext) => typeof ext === "string" && ext.length <= 100))
2390
+ return { reason: "output" };
2391
+ if (typeof basePath !== "string" || basePath.length > 2000 || (output !== null && typeof output !== "string"))
2392
+ return { reason: "output" };
2393
+ const read = { pageExtensions, basePath };
2394
+ if (output !== null)
2395
+ read.output = output;
2396
+ if (i18n === true)
2397
+ read.i18n = true;
2398
+ return read;
2399
+ }
2400
+ /** `loadConfig`: `mapled doctor --load-config` — a next.config the reading
2401
+ leaves unread is loaded by the site's Next (loadNextConfig). */
2402
+ export async function findLive(dir, maxFiles = 4000, options = {}) {
2250
2403
  const { files } = await listFiles(dir, maxFiles);
2251
2404
  const config = NEXT_CONFIGS.find((name) => files.includes(name));
2252
2405
  let configText = null;
@@ -2258,7 +2411,15 @@ export async function findLive(dir, maxFiles = 4000) {
2258
2411
  // unreadable: the defaults
2259
2412
  }
2260
2413
  }
2261
- const settings = nextConfigOf(configText);
2414
+ let settings = nextConfigOf(configText);
2415
+ let configLoad;
2416
+ if (settings === null && config && options.loadConfig) {
2417
+ const loaded = await loadNextConfig(dir, config, options.loadConfig);
2418
+ if ("reason" in loaded)
2419
+ configLoad = loaded;
2420
+ else
2421
+ settings = loaded;
2422
+ }
2262
2423
  const extensions = settings?.pageExtensions ?? null;
2263
2424
  const routeExtension = extensions === null || extensions.includes("ts") ? "ts" : (extensions[0] ?? "ts");
2264
2425
  // a folder under src/ that Next doesn't read: nothing in it runs
@@ -2305,6 +2466,8 @@ export async function findLive(dir, maxFiles = 4000) {
2305
2466
  facts.basePath = settings.basePath;
2306
2467
  if (settings === null && config)
2307
2468
  facts.config = config;
2469
+ if (configLoad)
2470
+ facts.configLoad = configLoad;
2308
2471
  if (config && settings && (settings.output === "export" || settings.output === null))
2309
2472
  facts.output = { file: config, value: settings.output };
2310
2473
  const sdk = await installedVersion(dir, "@mapled/next");
@@ -2488,7 +2651,7 @@ export function liveState(facts) {
2488
2651
  if (written === null)
2489
2652
  return { kind: "unresolved", listener };
2490
2653
  if (facts.config)
2491
- return { kind: "config", listener, file: facts.config };
2654
+ return { kind: "config", listener, file: facts.config, ...(facts.configLoad ? { load: facts.configLoad } : {}) };
2492
2655
  // a static export has no server: no route runs, whatever the files say
2493
2656
  if (facts.output)
2494
2657
  return { kind: "export", listener, file: facts.output.file, output: facts.output.value };
package/dist/oauth.d.ts CHANGED
@@ -30,6 +30,8 @@ export declare function registerClient(api: string, f: Fetch): Promise<string>;
30
30
  creates nothing on the server. */
31
31
  export declare function clientKnown(api: string, clientId: string, f: Fetch): Promise<boolean>;
32
32
  export type AuthorizeDeps = {
33
+ /** The project the consent screen opens on — a hint; the person still picks. */
34
+ project?: string;
33
35
  openBrowser: (url: string) => Promise<boolean>;
34
36
  log: (line: string) => void;
35
37
  timeoutMs?: number;
package/dist/oauth.js CHANGED
@@ -149,6 +149,7 @@ export function authorize(api, clientId, deps) {
149
149
  state,
150
150
  scope: SCOPE,
151
151
  resource: `${api}/mcp`,
152
+ ...(deps.project ? { project: deps.project } : {}),
152
153
  }).toString();
153
154
  deps.log(`Opening your browser to sign in to Mapled. If it doesn't open, visit:\n ${url.toString()}`);
154
155
  deps
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mapled/cli",
3
- "version": "0.14.5",
3
+ "version": "0.15.0",
4
4
  "description": "Mapled CLI — sign in, link a project, generate TypeScript types for its content, create forms, preview drafts and check the site's integration.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://mapled.io",
@@ -9,6 +9,8 @@
9
9
  "cms",
10
10
  "headless-cms",
11
11
  "cli",
12
+ "ai-agents",
13
+ "mcp",
12
14
  "typescript",
13
15
  "codegen"
14
16
  ],