@mapled/cli 0.14.5 → 0.14.6

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
@@ -206,12 +206,18 @@ Mapled doctor — Dolphin Landing
206
206
  1 warning.
207
207
  ```
208
208
 
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.
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 — `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
210
 
211
211
  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
212
 
213
213
  Exit code 1 when something is marked ✗; `--json` prints the checks for scripts and agents.
214
214
 
215
+ ```bash
216
+ npx @mapled/cli doctor --load-config
217
+ ```
218
+
219
+ `--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.
220
+
215
221
  ## Read the setup report
216
222
 
217
223
  ```bash
@@ -266,6 +272,7 @@ Prints a Preview link — the one the **Preview** button in Mapled opens: the si
266
272
  - `--api <origin>` — a Mapled API other than `https://api.mapled.io` (or set `MAPLED_API_URL`); `project link` remembers it in `mapled.json`
267
273
  - `--no-browser` — print the sign-in link instead of opening a browser
268
274
  - `--json` — machine-readable output (`doctor`, `integration report`, `schema diff`, `scan`, `manifest validate`, `forms list`, `preview`)
275
+ - `--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
276
  - `MAPLED_CONFIG_DIR` — where credentials live (default: `$XDG_CONFIG_HOME/mapled` or `~/.config/mapled`)
270
277
 
271
278
  ## Files in the repository
package/dist/commands.js CHANGED
@@ -731,7 +731,8 @@ export async function doctor(ctx, flags) {
731
731
  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
732
  checks.push(dr.checkCli(ctx.version, status?.sdk["@mapled/cli"]));
733
733
  // 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;
734
+ // --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
735
+ const live = !framework || framework === "nextjs" ? await findLive(dir, undefined, flags["load-config"] ? { loadConfig: { env: ctx.env } } : {}) : null;
735
736
  checks.push(dr.checkRoute("revalidate", await dr.findRoute(dir, "revalidate", framework), framework, (live?.listeners.length ?? 0) > 0));
736
737
  checks.push(dr.checkRoute("preview", await dr.findRoute(dir, "preview", framework), framework));
737
738
  // 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,
package/dist/index.js CHANGED
@@ -26,7 +26,7 @@ Usage
26
26
  mapled md pull [--force] Write MAPLED.md, the guide for the next agent, from the project
27
27
  mapled forms create <name> [--key <key>] Create a form the site posts submissions to (--no-spam-protection to skip the honeypot)
28
28
  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
29
+ 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
30
  mapled integration report [--json] [--verify] Show the last setup run's verification checklist and report
31
31
  mapled preview [<collection> [<slug>]] [--locale <code>] [--open] Print a one-time Preview link that shows the drafts on the site
32
32
 
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mapled/cli",
3
- "version": "0.14.5",
3
+ "version": "0.14.6",
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",