@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 +31 -2
- package/dist/api.d.ts +5 -1
- package/dist/api.js +18 -8
- package/dist/args.js +1 -1
- package/dist/commands.d.ts +13 -0
- package/dist/commands.js +130 -4
- package/dist/doctor.js +24 -3
- package/dist/errors.d.ts +6 -0
- package/dist/errors.js +9 -0
- package/dist/index.js +17 -4
- package/dist/live.d.ts +37 -2
- package/dist/live.js +166 -3
- package/dist/oauth.d.ts +2 -0
- package/dist/oauth.js +1 -0
- package/package.json +3 -1
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
|
-
- `--
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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 = {};
|
package/dist/commands.d.ts
CHANGED
|
@@ -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
|
-
|
|
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]
|
|
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
|
-
//
|
|
84
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
],
|