@mapled/cli 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -128,6 +128,8 @@ Scanned 2 HTML files — 2 pages, 11 bindings.
128
128
  ⚠ This path names nothing in the schema: home.tagline (index.html). A misspelled field empties its place on the page, a misspelled single leaves the page's own HTML.
129
129
  ```
130
130
 
131
+ For a Next.js site the scan also records its live updates: `capabilities.realtime` is `true`, with the route in `releaseRoute`, when `<MapledLive />` or `watchRelease` asks — at the default path or one written in the code — a route whose exported `GET` answers with `createReleaseHandler`, and `false` otherwise — `MAPLED.md` then tells the next agent whether open tabs follow Publish. Capabilities aren't part of the integration hash; `doctor`'s Site manifest line says when the pushed ones differ.
132
+
131
133
  `--write` creates or updates `mapled/manifest.json`. Bindings the scan can't see (written by your AI agent, or by hand) are kept and listed; `--prune` drops them. Commit the file.
132
134
 
133
135
  ## Validate and push the manifest
@@ -193,6 +195,7 @@ Mapled doctor — Dolphin Landing
193
195
  ✓ mapled CLI 0.9.0 (current)
194
196
  ✓ Revalidation route app/api/mapled/revalidate/route.ts
195
197
  ⚠ Preview route app/api/mapled/preview/route.ts is missing — mount createPreviewHandler from "@mapled/next/server" so Preview from Mapled works.
198
+ ✓ Live updates app/api/mapled/release/route.ts — open tabs follow Publish (<MapledLive /> in app/layout.tsx)
196
199
  ✓ Site reads content Last read 3m ago
197
200
  ✓ Publish webhook Delivered 2h ago to dolphin-landing.example
198
201
  ✓ Preview on the site Responds at https://dolphin-landing.example/api/mapled/preview
@@ -201,9 +204,9 @@ Mapled doctor — Dolphin Landing
201
204
  1 warning.
202
205
  ```
203
206
 
204
- 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. 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, the publish webhook and its last delivery, the preview route on the deployed site, the bindings health.
207
+ 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. 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, the publish webhook and its last delivery, the preview route on the deployed site, the bindings health.
205
208
 
206
- 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. 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 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.
209
+ 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 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.
207
210
 
208
211
  Exit code 1 when something is marked ✗; `--json` prints the checks for scripts and agents.
209
212
 
package/dist/commands.js CHANGED
@@ -12,6 +12,7 @@ import { formatChecks, GLYPH, paint, summarize, useColor } from "./output.js";
12
12
  import { diffSchema, formatChanges, pinSchema, readPin, SCHEMA_FILE, summarizeChanges, writePin } from "./pin.js";
13
13
  import { checkMapledMd, MD_FILE, mergeMd } from "./md.js";
14
14
  import { buildReport, clean, formatReport, isChecked, linkLifetime, pickRun, } from "./report.js";
15
+ import { capabilitiesOf, findLive, liveState } from "./live.js";
15
16
  import { loadTypeScript, mergeManifest, scanRepository } from "./scan.js";
16
17
  import { scanHtml } from "./scanHtml.js";
17
18
  import { generateTypes } from "./types.js";
@@ -358,7 +359,8 @@ export async function bindingsPull(ctx, flags) {
358
359
  const local = await loadManifest(found.dir);
359
360
  if (local && !flags.force) {
360
361
  const diff = compareManifests(local.manifest, stored.manifest);
361
- if (!diff.same) {
362
+ // capabilities a scan just recorded are as much the repository's as its bindings
363
+ if (!diff.same || diff.capabilitiesDiffer) {
362
364
  const parts = [];
363
365
  if (diff.onlyLocal.length > 0)
364
366
  parts.push(`${diff.onlyLocal.length} not pushed`);
@@ -366,6 +368,8 @@ export async function bindingsPull(ctx, flags) {
366
368
  parts.push(`${diff.changed.length} changed`);
367
369
  if (diff.onlyRemote.length > 0)
368
370
  parts.push(`${diff.onlyRemote.length} only in Mapled`);
371
+ if (diff.capabilitiesDiffer)
372
+ parts.push("capabilities changed");
369
373
  throw new CliError(`${MANIFEST_FILE} differs from the pushed manifest (v${stored.version}): ${parts.join(", ")}. Pass --force to overwrite it, or push yours with \`mapled bindings push\`.`);
370
374
  }
371
375
  }
@@ -573,6 +577,13 @@ export async function scan(ctx, flags) {
573
577
  const framework = found.config.framework ?? (await dr.detectFramework(found.dir)) ?? null;
574
578
  // a plain HTML site declares its reads in attributes, not in code
575
579
  const result = framework === "plain-html" ? await scanHtml(found.dir, { schema }) : await scanRepository(found.dir, { framework, schema, ts: loadTypeScript(found.dir) });
580
+ // live updates (§18.8) of a Next.js site: the same reading of the code as doctor's line
581
+ const live = framework === "nextjs" ? liveState(await findLive(found.dir)) : null;
582
+ if (live)
583
+ result.capabilities = capabilitiesOf(live);
584
+ if (live && live.kind !== "served" && live.kind !== "unused") {
585
+ result.notes.push(`${live.listener.via} in ${live.listener.file} has no route that doctor can confirm answers it, so the manifest records that the site doesn't follow Publish — \`mapled doctor\` says what to fix.`);
586
+ }
576
587
  const existing = await loadManifest(found.dir);
577
588
  const merged = mergeManifest(existing?.manifest ?? null, result, { prune: Boolean(flags.prune) });
578
589
  if (flags.json) {
@@ -587,6 +598,10 @@ export async function scan(ctx, flags) {
587
598
  for (const line of describeScan(result, schema))
588
599
  ctx.out(line);
589
600
  }
601
+ if (live?.kind === "served") {
602
+ ctx.out("");
603
+ ctx.out(`Live updates: ${live.listener.via} in ${live.listener.file} → ${live.path} (${live.route.file})`);
604
+ }
590
605
  if (result.notes.length > 0) {
591
606
  ctx.out("");
592
607
  for (const n of result.notes)
@@ -604,6 +619,8 @@ export async function scan(ctx, flags) {
604
619
  parts.push(`${merged.changed.length} changed`);
605
620
  if (merged.unchanged.length > 0)
606
621
  parts.push(`${merged.unchanged.length} unchanged`);
622
+ if (merged.capabilitiesChanged)
623
+ parts.push("capabilities updated");
607
624
  ctx.out(`Wrote ${MANIFEST_FILE} — ${manifestSummary(merged.manifest)}${parts.length > 0 && existing ? ` (${parts.join(", ")})` : ""}.${keptNote}${droppedNote}`);
608
625
  ctx.out("Next: `mapled bindings push`.");
609
626
  }
@@ -617,10 +634,12 @@ export async function scan(ctx, flags) {
617
634
  if (merged.changed.length > 0)
618
635
  parts.push(`${merged.changed.length} changed`);
619
636
  parts.push(`${merged.unchanged.length} unchanged`);
637
+ if (merged.capabilitiesChanged)
638
+ parts.push("capabilities changed");
620
639
  if (merged.kept.length > 0)
621
640
  parts.push(`${merged.kept.length} not found in the code (${merged.kept.slice(0, 5).join(", ")}${merged.kept.length > 5 ? ", …" : ""})`);
622
641
  ctx.out(`Compared with ${MANIFEST_FILE}: ${parts.join(", ")}.`);
623
- if (merged.added.length > 0 || merged.changed.length > 0) {
642
+ if (merged.added.length > 0 || merged.changed.length > 0 || merged.capabilitiesChanged) {
624
643
  ctx.out(`Run \`mapled scan --write\` to update ${MANIFEST_FILE}, then \`mapled bindings push\`.`);
625
644
  }
626
645
  else {
@@ -693,6 +712,8 @@ export async function doctor(ctx, flags) {
693
712
  checks.push(dr.checkCli(ctx.version, status?.sdk["@mapled/cli"]));
694
713
  checks.push(dr.checkRoute("revalidate", await dr.findRoute(dir, "revalidate"), framework));
695
714
  checks.push(dr.checkRoute("preview", await dr.findRoute(dir, "preview"), framework));
715
+ // the code is read only where the check applies: a Next.js site (or one doctor can't place)
716
+ checks.push(dr.checkLive(!framework || framework === "nextjs" ? await findLive(dir) : null, framework));
696
717
  if (status) {
697
718
  checks.push(dr.checkReads(status.delivery.lastReadAt));
698
719
  checks.push(dr.checkWebhook(status.webhook, { local: browser, remote: status.site }));
package/dist/doctor.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { Connection } from "./credentials.js";
2
+ import { type LiveFacts } from "./live.js";
2
3
  import { type Manifest, type Problem } from "./manifest.js";
3
4
  import type { Fetch } from "./oauth.js";
4
5
  import { type SchemaChange, type SchemaPin } from "./pin.js";
@@ -145,6 +146,10 @@ export declare function checkSecrets(facts: GitFacts): Check;
145
146
  export declare function checkSdk(pkg: PackageVersion, latest: string | undefined, name?: string): Check;
146
147
  export declare function checkCli(current: string, latest: string | undefined): Check;
147
148
  export declare function checkRoute(kind: RouteKind, found: RouteFile | null, framework: string | undefined): Check;
149
+ /** Live updates (§18.8): whether open tabs follow Publish, and when the
150
+ code listens, whether a route answers it. `facts` is null when the
151
+ code wasn't read (a stack this check doesn't cover). */
152
+ export declare function checkLive(facts: LiveFacts | null, framework: string | undefined): Check;
148
153
  export declare function checkReads(lastReadAt: string | null): Check;
149
154
  export declare function checkWebhook(webhook: IntegrationStatus["webhook"], site?: {
150
155
  local: boolean;
package/dist/doctor.js CHANGED
@@ -3,6 +3,7 @@ import { access, readdir, readFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { promisify } from "node:util";
5
5
  import { integrationHash } from "./integrationHash.js";
6
+ import { BASE_PATH_SINCE, fullAddress, liveState, RELEASE_ROUTE, routeFileFor } from "./live.js";
6
7
  import { compareManifests, manifestSummary, MANIFEST_FILE } from "./manifest.js";
7
8
  import { SCHEMA_FILE } from "./pin.js";
8
9
  import { splitGenerated } from "./types.js";
@@ -448,6 +449,146 @@ export function checkRoute(kind, found, framework) {
448
449
  }
449
450
  return { key, label, status: "passed", detail: found.file };
450
451
  }
452
+ /** Live updates (§18.8): whether open tabs follow Publish, and when the
453
+ code listens, whether a route answers it. `facts` is null when the
454
+ code wasn't read (a stack this check doesn't cover). */
455
+ export function checkLive(facts, framework) {
456
+ const key = "live";
457
+ const label = "Live updates";
458
+ if (framework === "react-spa") {
459
+ return { key, label, status: "skipped", detail: "No route needed: `live` on MapledProvider (watchRelease by key) asks Mapled directly." };
460
+ }
461
+ if (framework === "plain-html") {
462
+ return { key, label, status: "skipped", detail: "No route needed: data-mapled-live on the script tag (watchRelease by key) asks Mapled directly." };
463
+ }
464
+ if ((framework && framework !== "nextjs") || !facts) {
465
+ return { key, label, status: "skipped", detail: `No live updates check for ${framework ?? "this"} site${framework ? "s" : ""} yet.` };
466
+ }
467
+ const state = liveState(facts);
468
+ const who = (l) => `${l.via} in ${l.file}`;
469
+ const handler = 'createReleaseHandler from "@mapled/next/server"';
470
+ switch (state.kind) {
471
+ case "unused":
472
+ return {
473
+ key,
474
+ label,
475
+ status: "skipped",
476
+ detail: state.handlerRoute
477
+ ? `Not used: ${state.handlerRoute.file} is mounted, but no page listens — add <MapledLive /> to the root layout.`
478
+ : "Not used: an open tab shows a publish once it is reloaded.",
479
+ };
480
+ case "served":
481
+ return { key, label, status: "passed", detail: `${state.route.file} — open tabs follow Publish (${who(state.listener)})` };
482
+ case "no_handler":
483
+ return { key, label, status: "warning", detail: `${state.route.file} exists but doesn't use ${handler} — ${who(state.listener)} can't read the release there.` };
484
+ case "not_get":
485
+ return {
486
+ key,
487
+ label,
488
+ status: "warning",
489
+ detail: `${state.route.file} calls ${handler}, but doctor can't see its GET always answer with the handler's response — ${who(state.listener)} asks with GET. Export the handler as GET, or end GET by returning its call, with no other return or throw.`,
490
+ };
491
+ case "unresolved":
492
+ return {
493
+ key,
494
+ label,
495
+ status: "warning",
496
+ detail: state.listener.endpoint === null
497
+ ? `${who(state.listener)} takes its endpoint from the code (a computed value or spread props), so no route can be checked. Give the route's path, like ${RELEASE_ROUTE}.`
498
+ : fullAddress(state.listener.endpoint)
499
+ ? `${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}.`
500
+ : `${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}.`,
501
+ };
502
+ case "config":
503
+ return {
504
+ key,
505
+ label,
506
+ status: "warning",
507
+ 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.`,
508
+ };
509
+ case "export":
510
+ return {
511
+ key,
512
+ label,
513
+ status: "warning",
514
+ detail: state.output === "export"
515
+ ? `${state.file} sets output: "export" — a static export has no server, so no route answers ${who(state.listener)}, and open tabs can't follow Publish. Live updates need the site served by Next: next start, or a host that runs it.`
516
+ : `${state.file} may set output where doctor can't read it — with output: "export" the site has no server, and no route answers ${who(state.listener)}. Write output as a literal in the object it exports, or leave it out.`,
517
+ };
518
+ case "outside":
519
+ return {
520
+ key,
521
+ label,
522
+ status: "warning",
523
+ detail: `${who(state.listener)} asks ${state.endpoint}, outside the site's basePath ${state.basePath} — Next answers nothing there. Give it the path under the basePath: ${state.basePath}${state.endpoint === "/" ? "" : state.endpoint}.`,
524
+ };
525
+ case "default_base":
526
+ return {
527
+ key,
528
+ label,
529
+ status: "warning",
530
+ detail: state.sdk === null
531
+ ? `${who(state.listener)} gives no endpoint, and doctor finds no @mapled/next in node_modules to tell where it asks: from ${BASE_PATH_SINCE} under the site's basePath (${state.basePath}${RELEASE_ROUTE}), before that at ${RELEASE_ROUTE}, where Next answers nothing. Run npm install, or give it the path: ${state.basePath}${RELEASE_ROUTE}.`
532
+ : `${who(state.listener)} gives no endpoint, so @mapled/next ${state.sdk} asks ${RELEASE_ROUTE}, outside the site's basePath ${state.basePath} — Next answers nothing there. Update to @mapled/next ${BASE_PATH_SINCE} or later, which asks under the basePath, or give it the path: ${state.basePath}${RELEASE_ROUTE}.`,
533
+ };
534
+ case "taken":
535
+ return {
536
+ key,
537
+ label,
538
+ status: "warning",
539
+ detail: `${who(state.listener)} asks ${state.endpoint}, which ${state.page.file} answers, not a release route. Point the listener at a path of its own, and mount ${handler} there.`,
540
+ };
541
+ case "params":
542
+ return {
543
+ key,
544
+ label,
545
+ status: "warning",
546
+ detail: `${state.route.file} sets dynamicParams, so it answers only the params its code lists — doctor can't tell ${state.endpoint} is one, and ${who(state.listener)} may get a 404 there. Mount ${handler} at ${routeFileFor(state.endpoint, facts)}.`,
547
+ };
548
+ case "segment":
549
+ return {
550
+ key,
551
+ label,
552
+ status: "warning",
553
+ detail: state.setting === "dynamic"
554
+ ? `${state.route.file} may set dynamic to something other than "auto" or "force-dynamic" — with "force-static" Next serves a copy of the route built without the tab's request, which moves only when Next rebuilds it and answers every check in full, so doctor can't tell ${who(state.listener)} follows Publish. Leave dynamic out of the route.`
555
+ : state.setting === "force-dynamic"
556
+ ? `${state.route.file} sets dynamic to "force-dynamic", and doctor can't see createReleaseHandler there keep a revalidate above 0 (false, 0, or options it can't read) — Next then reads the live release past the cache tag, so the route moves before the webhook refreshes the pages, and ${who(state.listener)} refreshes into a release they don't show yet. Leave dynamic out of the route, or revalidate at its default.`
557
+ : `${state.route.file} may set fetchCache to something other than "auto" — with "force-no-store" the handler reads the live release past the cache tag, so the route moves before the webhook refreshes the pages, and ${who(state.listener)} refreshes into a release they don't show yet. Leave fetchCache out of the route.`,
558
+ };
559
+ case "middleware": {
560
+ const base = facts.basePath ?? "";
561
+ const local = base !== "" && state.path.startsWith(base) ? state.path.slice(base.length) || "/" : state.path;
562
+ const why = state.reason === "none"
563
+ ? `${state.file} has no matcher, so it runs on every path, ${state.path} included`
564
+ : state.reason === "unread"
565
+ ? `doctor can't read the matcher of ${state.file} — Next reads it only from export const config = { matcher: … } written with literals — so it may run on ${state.path}`
566
+ : state.reason === "i18n"
567
+ ? `${state.file} may run on ${state.path}: with i18n in next.config Next puts a locale in front of its matcher, and doctor can't tell which paths it takes`
568
+ : `${state.file} may run on ${state.path}: its matcher ${JSON.stringify(state.source)} can take it`;
569
+ return {
570
+ key,
571
+ label,
572
+ status: "warning",
573
+ detail: `${why}. doctor can't see what it does there — a redirect, a rewrite or a sign-in wall keeps ${who(state.listener)} from the release. Leave the path out of its matcher, like "/((?!${local.slice(1)}).*)".`,
574
+ };
575
+ }
576
+ case "missing":
577
+ return state.endpoint === null
578
+ ? {
579
+ key,
580
+ label,
581
+ status: "warning",
582
+ detail: `${who(state.listener)} asks a release route the code doesn't have — mount ${handler} (at ${routeFileFor(RELEASE_ROUTE, facts)} by default).`,
583
+ }
584
+ : {
585
+ key,
586
+ label,
587
+ status: "warning",
588
+ detail: `${who(state.listener)} asks ${state.endpoint}, which has no route — mount ${handler} at ${routeFileFor(state.endpoint, facts)}.`,
589
+ };
590
+ }
591
+ }
451
592
  export function checkReads(lastReadAt) {
452
593
  const label = "Site reads content";
453
594
  if (!lastReadAt) {
@@ -606,7 +747,7 @@ export function checkManifestFile(local, remote) {
606
747
  if (remote === null)
607
748
  return { key: "manifest", label, status: "warning", detail: `${MANIFEST_FILE} isn't pushed yet — run \`mapled bindings push\`.` };
608
749
  const diff = compareManifests(local.manifest, remote.manifest);
609
- if (diff.same) {
750
+ if (diff.same && !diff.capabilitiesDiffer) {
610
751
  return { key: "manifest", label, status: "passed", detail: `${MANIFEST_FILE} is pushed as manifest v${remote.version} (${manifestSummary(local.manifest)})` };
611
752
  }
612
753
  const parts = [];
@@ -616,6 +757,9 @@ export function checkManifestFile(local, remote) {
616
757
  parts.push(`${diff.changed.length} changed`);
617
758
  if (diff.onlyRemote.length > 0)
618
759
  parts.push(`${diff.onlyRemote.length} only in Mapled`);
760
+ // capabilities aren't in the integration hash, so this is the line that notices them
761
+ if (diff.capabilitiesDiffer)
762
+ parts.push("capabilities changed");
619
763
  return {
620
764
  key: "manifest",
621
765
  label,
package/dist/live.d.ts ADDED
@@ -0,0 +1,264 @@
1
+ /** Live updates (§18.8) as the site's code shows them. A Next.js site
2
+ follows Publish in open tabs with two pieces: `<MapledLive />` from
3
+ "@mapled/next/live" (or `watchRelease` from "@mapled/next") asks a
4
+ route of the site every few seconds, and that route answers with
5
+ `createReleaseHandler` from "@mapled/next/server". A listener without
6
+ the route polls a 404 forever and nothing says so — `mapled doctor`
7
+ reads both and says which is missing, and `mapled scan` records the
8
+ result in the manifest (`capabilities.realtime`, `releaseRoute`) so
9
+ MAPLED.md can tell the next agent. The code is read with the scanner's
10
+ tokenizer: comments and strings never count as a use, and neither
11
+ does an imported name the file binds again. */
12
+ /** Where `<MapledLive />` and `watchRelease` ask when no endpoint is given. */
13
+ export declare const RELEASE_ROUTE = "/api/mapled/release";
14
+ /** The `@mapled/next` from which that default follows the site's basePath —
15
+ before it, a listener with no endpoint asks RELEASE_ROUTE as written. */
16
+ export declare const BASE_PATH_SINCE = "0.10.0";
17
+ export type LiveListener = {
18
+ file: string;
19
+ via: "<MapledLive />" | "watchRelease";
20
+ /** the endpoint as the code writes it; null when the code decides it —
21
+ a computed value, or spread props or options that may carry one */
22
+ endpoint: string | null;
23
+ /** the code gives no endpoint: the SDK asks its default, `endpoint` —
24
+ under the site's basePath from `@mapled/next` 0.10.0 */
25
+ byDefault?: true;
26
+ };
27
+ /** An App Router route handler file and the path it answers. `handler`:
28
+ "GET" when the route's exported GET is seen to answer with
29
+ `createReleaseHandler`, "other" when the file calls it but GET isn't
30
+ (exported as POST or not at all — the tab's GET gets a 405 — or a GET
31
+ whose answer isn't the handler's), null when it doesn't call it.
32
+ `params`: the file sets `dynamicParams` — a dynamic route then answers
33
+ only the params its code lists. `segment`: the first route segment
34
+ setting the file may export as a value that changes how Next serves or
35
+ caches the route (SEGMENT_SETTINGS). */
36
+ export type RouteFile = {
37
+ file: string;
38
+ path: string;
39
+ handler: RouteHandler;
40
+ params?: true;
41
+ segment?: SegmentSetting;
42
+ };
43
+ export type RouteHandler = "GET" | "other" | null;
44
+ /** A page file and the path it answers: an App Router `page.<ext>`, or any
45
+ Pages Router file (`pages/api/…` included). It takes part in the match
46
+ as Next makes it — a page more specific than a route takes the path. */
47
+ export type PageFile = {
48
+ file: string;
49
+ path: string;
50
+ };
51
+ /** `routeExtension`: what a route file the site lacks would end in — the
52
+ site's page extension, `ts` by default. `appDir`: the App Router folder
53
+ Next reads, when the site has one. `basePath`: what every path of the
54
+ site starts with (next.config). `config`: the next.config file, when it
55
+ may set `pageExtensions` or `basePath` where the reading can't follow —
56
+ then no route can be checked. `sdk`: the `@mapled/next` version the
57
+ site resolves, as Node finds the package from the site's folder up —
58
+ absent when none is installed. `output`: next.config sets `output:
59
+ "export"` (value "export") or may set it where the reading can't tell
60
+ (null) — then there may be no server to run any route. */
61
+ export type LiveFacts = {
62
+ listeners: LiveListener[];
63
+ routes: RouteFile[];
64
+ pages?: PageFile[];
65
+ routeExtension?: string;
66
+ appDir?: string;
67
+ basePath?: string;
68
+ config?: string;
69
+ sdk?: string;
70
+ output?: {
71
+ file: string;
72
+ value: "export" | null;
73
+ };
74
+ middleware?: Middleware[];
75
+ i18n?: true;
76
+ };
77
+ /** A middleware file of the site (`proxy` from Next 16) and the paths it
78
+ runs on as Next reads its `config`: the matcher's sources, "none" when
79
+ it has no matcher — every path — or "unread" when Next may not read the
80
+ config doctor sees, or doctor can't read it. */
81
+ export type Middleware = {
82
+ file: string;
83
+ matcher: string[] | "none" | "unread";
84
+ };
85
+ export type LiveState = {
86
+ kind: "unused";
87
+ handlerRoute: RouteFile | null;
88
+ } | {
89
+ kind: "served";
90
+ listener: LiveListener;
91
+ route: RouteFile;
92
+ path: string;
93
+ } | {
94
+ kind: "missing";
95
+ listener: LiveListener;
96
+ endpoint: string | null;
97
+ } | {
98
+ kind: "unresolved";
99
+ listener: LiveListener;
100
+ } | {
101
+ kind: "no_handler";
102
+ listener: LiveListener;
103
+ route: RouteFile;
104
+ } | {
105
+ kind: "not_get";
106
+ listener: LiveListener;
107
+ route: RouteFile;
108
+ } | {
109
+ kind: "config";
110
+ listener: LiveListener;
111
+ file: string;
112
+ } | {
113
+ kind: "outside";
114
+ listener: LiveListener;
115
+ endpoint: string;
116
+ basePath: string;
117
+ } | {
118
+ kind: "default_base";
119
+ listener: LiveListener;
120
+ basePath: string;
121
+ sdk: string | null;
122
+ } | {
123
+ kind: "export";
124
+ listener: LiveListener;
125
+ file: string;
126
+ output: "export" | null;
127
+ } | {
128
+ kind: "middleware";
129
+ listener: LiveListener;
130
+ route: RouteFile;
131
+ path: string;
132
+ file: string;
133
+ reason: "none" | "unread" | "i18n" | "matcher";
134
+ source?: string;
135
+ } | {
136
+ kind: "taken";
137
+ listener: LiveListener;
138
+ page: PageFile;
139
+ endpoint: string;
140
+ } | {
141
+ kind: "params";
142
+ listener: LiveListener;
143
+ route: RouteFile;
144
+ endpoint: string;
145
+ } | {
146
+ kind: "segment";
147
+ listener: LiveListener;
148
+ route: RouteFile;
149
+ setting: SegmentSetting;
150
+ };
151
+ /** What the manifest records (§21.2). */
152
+ export type Capabilities = {
153
+ realtime?: boolean;
154
+ releaseRoute?: string;
155
+ };
156
+ /** The extensions a route file may have when next.config doesn't set `pageExtensions`. */
157
+ export declare const PAGE_EXTENSIONS: string[];
158
+ /** The listeners one file mounts and, for a route file, whether its GET
159
+ serves the release. An imported name the file binds again — a
160
+ parameter, a declaration, destructuring — may not be the import where
161
+ it's used, and the reading can't tell where: none of its uses count.
162
+ A namespace's name the same: its members count only where `ns` itself
163
+ is the import. */
164
+ export declare function liveInFile(file: string, text: string): {
165
+ listeners: LiveListener[];
166
+ handler: RouteHandler;
167
+ params: boolean;
168
+ segment?: SegmentSetting;
169
+ };
170
+ /** The route segment settings that change how Next serves the release
171
+ route or reads what the handler reads, in the order doctor names them:
172
+ `dynamic` can make it a static copy built without the tab's request,
173
+ and `fetchCache` can make the handler's read skip the `mapled` cache
174
+ tag, so the release moves before the webhook refreshes the pages.
175
+ `revalidate` isn't one: it only fills in fetches that don't name their
176
+ own window, and the handler's names one (Next 14's fetch patch; the
177
+ live smoke with `revalidate = 1` on Next 15). "force-dynamic" is the
178
+ third: harmless while the handler keeps its read's window, and past
179
+ the tag when it doesn't (keepsWindow). */
180
+ export declare const SEGMENT_SETTINGS: readonly ["dynamic", "fetchCache"];
181
+ export type SegmentSetting = (typeof SEGMENT_SETTINGS)[number] | "force-dynamic";
182
+ /** The route files of the App Router with the path each answers. */
183
+ export declare function routeFileOf(file: string, extensions?: string[]): string | null;
184
+ /** The page files of both routers with the path each answers: an App
185
+ Router `page.<ext>`, and any Pages Router file but `_app`, `_document`
186
+ and `_error`, with the longest page extension its name ends in taken
187
+ off, as Next does, and `index` naming its folder. */
188
+ export declare function pageFileOf(file: string, extensions?: string[]): string | null;
189
+ /** What next.config decides about where a file answers: the page
190
+ extensions of route and page files, and the basePath every path of the
191
+ site starts with. `output`, when the file may set it: the literal, or
192
+ null where the reading can't tell — with "export" there is no server.
193
+ `i18n`: the file mentions it, so locales may be configured. */
194
+ export type NextConfig = {
195
+ pageExtensions: string[];
196
+ basePath: string;
197
+ output?: string | null;
198
+ i18n?: true;
199
+ };
200
+ /** next.config's `pageExtensions`, `basePath` and `output`, read only when
201
+ the file is written in a declarative subset of JavaScript, where no code
202
+ runs while Next loads it — neither the file's nor a module's:
203
+
204
+ - statements: type imports (`import type { NextConfig } from "next"`),
205
+ declarations, and one export of the config object itself —
206
+ `module.exports = {…}`, `export default nextConfig` or `export {
207
+ config as default }`;
208
+ - values: literals, and arrays and objects of them, `process.env.NAME`,
209
+ `process.cwd()`, `__dirname`, `import.meta.dirname`, `new URL(…)`,
210
+ `require.resolve("…")`, names declared so; `!`, `+`, comparisons,
211
+ `&&`, `||`, `??` and `? :` over them; a type after `as` or
212
+ `satisfies`;
213
+ - functions only as Next's hooks (`webpack`, `headers`, `redirects`,
214
+ `rewrites`, `generateBuildId`, `exportPathMap`) at the top of the
215
+ exported object: Next calls them once it has loaded the config.
216
+
217
+ Anything else makes it null: a module loaded in any way (`import "…"`,
218
+ `import x from "…"`, `require(…)`) — its code runs, and which module a
219
+ name loads its spelling doesn't show (Node's self-reference, a
220
+ next.config.ts's tsconfig `paths`); so a plugin's call around the
221
+ config (`withMDX(nextConfig)`) and a `path` helper too (block 348). And
222
+ any other statement, a function or class of the file, a getter, a
223
+ spread, a member of a value, a call, a name bound twice or over one the
224
+ subset reads (`require`, `process`), the config's name used twice. Each
225
+ setting: a literal, once, in that object — `pageExtensions` or
226
+ `basePath` named anywhere else in the file makes it null, and an
227
+ `output` the reading can't take (an expression, a second one, one a
228
+ hook's code writes) makes only `output` null. `live.test.ts` holds the
229
+ reading to Next's own `loadConfig` over generated configs.
230
+
231
+ What the reading trusts: Next calls the hooks only after loading, and a
232
+ hook leaves the settings it loaded alone. */
233
+ export declare function nextConfigOf(config: string | null): NextConfig | null;
234
+ /** The matcher of a middleware file, read as Next's build reads it: only
235
+ `export const config = { … }` (a type or `satisfies` allowed), every
236
+ value in it a literal (extractable), `matcher` a string or an array of
237
+ strings and `{ source }` objects. Anything else Next can't read either
238
+ and runs the middleware on every path — or reads what doctor can't. */
239
+ export declare function middlewareMatcher(text: string): string[] | "none" | "unread";
240
+ /** Whether a matcher source may take the path `at` (without the basePath,
241
+ which Next puts in front of both). False only when it surely can't: the
242
+ literal the source starts with isn't how the path starts, or right
243
+ after it comes a group — path-to-regexp's `(…)`, kept verbatim in the
244
+ regex Next builds — that matches no start of the rest, in either case.
245
+ Everything else may. */
246
+ export declare function sourceMayTake(source: string, at: string): boolean;
247
+ /** Whether a version is at least `min`, by its major, minor and patch —
248
+ false for one that doesn't start with three numbers. */
249
+ export declare function atLeast(version: string, min: string): boolean;
250
+ export declare function findLive(dir: string, maxFiles?: number): Promise<LiveFacts>;
251
+ /** Whether a literal endpoint names its own origin — `https://…`, `//host/…`
252
+ — which the code doesn't show to be the site's: `watchRelease` fetches it
253
+ as written, and no route file of this site is known to answer it. */
254
+ export declare const fullAddress: (endpoint: string) => boolean;
255
+ /** Whether every listener has a route that answers it. The first one
256
+ that doesn't is the one to name. */
257
+ export declare function liveState(facts: LiveFacts): LiveState;
258
+ /** What `mapled scan` writes into the manifest: a listener nothing answers
259
+ isn't listening; the route is the path the tab asks, not a dynamic route's pattern. */
260
+ export declare function capabilitiesOf(state: LiveState): Capabilities;
261
+ /** Where a route answering `endpoint` goes: in the App Router folder Next
262
+ reads — without one, under `src/app` when the site keeps its code there
263
+ — and without the basePath, which Next adds itself. */
264
+ export declare function routeFileFor(endpoint: string, facts: LiveFacts): string;