@mapled/cli 0.2.0 → 0.4.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
@@ -8,6 +8,7 @@ npx @mapled/cli project link
8
8
  npx @mapled/cli types generate
9
9
  npx @mapled/cli scan --write
10
10
  npx @mapled/cli bindings push
11
+ npx @mapled/cli md pull
11
12
  npx @mapled/cli doctor
12
13
  ```
13
14
 
@@ -120,10 +121,20 @@ npx @mapled/cli bindings pull
120
121
 
121
122
  `manifest validate` checks `mapled/manifest.json` the way Mapled will: the shape and limits, duplicate keys, and — against the schema pin or the live schema — collections and fields that don't exist and targets that don't fit the field type, plus files the repository doesn't have. Problems exit 1; warnings only tell you what Mapled would grade down.
122
123
 
123
- `bindings push` sends the manifest to Mapled (the same call your AI agent's `push_site_manifest` makes). Mapled grades every binding — healthy, type mismatch, outdated, missing on site — and the result shows up in **Structure → Bindings**. `--dry-run` validates and stops. Pushing bindings changes structure, so it needs the builder plan.
124
+ `bindings push` sends the manifest to Mapled (the same call your AI agent's `push_site_manifest` makes). Mapled grades every binding — healthy, type mismatch, outdated, missing on site — and the result shows up in **Structure → Bindings**. The push also records the integration hash: one marker over the schema and these bindings, what the site is synced with from now on. `--dry-run` validates and stops. Pushing bindings changes structure, so it needs the builder plan.
124
125
 
125
126
  `bindings pull` writes the pushed manifest into `mapled/manifest.json` — say, after your agent pushed one from a chat and you want the repository's copy. It refuses to overwrite a local file that differs unless you pass `--force`.
126
127
 
128
+ ## Leave a guide for the next agent
129
+
130
+ ```bash
131
+ npx @mapled/cli md pull
132
+ ```
133
+
134
+ Writes `MAPLED.md` at the repository root — the guide Mapled renders from the project for whoever works on the site next, person or AI agent: the project and how the site reads it, the content model with every field's type, rules and help text, where each page renders which field (from the pushed manifest), the last setup run with its verification, the working rules, and the commands that verify the integration. It never contains a secret.
135
+
136
+ The first line is a stamp with the schema hash, the manifest version and the integration hash. Everything below the `<!-- mapled:notes -->` line is yours — conventions, what stays hardcoded on purpose, things to avoid — and survives every `md pull`. A `MAPLED.md` Mapled didn't write is left alone unless you pass `--force`, which moves its content below the notes line. `mapled doctor` tells you when the file no longer matches the project; your AI agent gets the same document from the `get_mapled_md` tool.
137
+
127
138
  ## Check the integration
128
139
 
129
140
  ```bash
@@ -138,6 +149,8 @@ Mapled doctor — Dolphin Landing
138
149
  ✓ Generated types mapled-types.ts matches the schema (3f9a1c2b4d5e)
139
150
  ✓ Schema pin mapled/schema.json matches the schema (3f9a1c2b4d5e)
140
151
  ✓ Site manifest mapled/manifest.json is pushed as manifest v3 (14 bindings on 3 pages)
152
+ ✓ Integration In sync with Mapled (7c1d9e0f2a3b)
153
+ ✓ MAPLED.md MAPLED.md matches the schema (3f9a1c2b4d5e) and manifest v3
141
154
  ✓ Environment MAPLED_KEY and MAPLED_WEBHOOK_SECRET in .env.local
142
155
  ✓ Secrets in git No env files or Mapled secrets are tracked.
143
156
  ✓ @mapled/next 0.6.0 (current)
@@ -152,7 +165,7 @@ Mapled doctor — Dolphin Landing
152
165
  1 warning.
153
166
  ```
154
167
 
155
- The repository side: the link, the sign-in, the generated types, the schema pin, the site manifest (and whether it matches what was pushed), `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.
168
+ 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.
156
169
 
157
170
  Exit code 1 when something is marked ✗; `--json` prints the checks for scripts and agents.
158
171
 
@@ -171,5 +184,6 @@ Exit code 1 when something is marked ✗; `--json` prints the checks for scripts
171
184
  | `mapled-types.ts` | `types generate` | TypeScript types of the published content, with `MapledSchema` |
172
185
  | `mapled/schema.json` | `schema pull`, `types generate` | The schema this site is built against — `schema diff` starts from it |
173
186
  | `mapled/manifest.json` | `scan --write`, `bindings pull`, your agent | Pages and bindings — where the code reads each collection and field |
187
+ | `MAPLED.md` | `md pull`, your agent | The guide for the next agent — project, content model, bindings, working rules; your notes below the marker |
174
188
 
175
- Commit all four. Credentials never live in the repository.
189
+ Commit all five. Credentials never live in the repository.
@@ -2,7 +2,7 @@ import { type ParsedArgs } from "./args.js";
2
2
  import { type FoundConfig } from "./config.js";
3
3
  import { type Connection } from "./credentials.js";
4
4
  import { type Fetch } from "./oauth.js";
5
- /** The commands of waves 1 and 2 (§31). Everything that talks to the
5
+ /** The commands of waves 1 and 2 (§31) and MAPLED.md (§21.3). Everything that talks to the
6
6
  world comes in through `Ctx`, so the commands run in tests against a
7
7
  fake API and a temp directory. */
8
8
  export type Ctx = {
@@ -28,6 +28,7 @@ export declare function schemaDiff(ctx: Ctx, flags: Flags): Promise<number>;
28
28
  export declare function manifestValidate(ctx: Ctx, flags: Flags): Promise<number>;
29
29
  export declare function bindingsPush(ctx: Ctx, flags: Flags): Promise<void>;
30
30
  export declare function bindingsPull(ctx: Ctx, flags: Flags): Promise<void>;
31
+ export declare function mdPull(ctx: Ctx, flags: Flags): Promise<void>;
31
32
  export declare function scan(ctx: Ctx, flags: Flags): Promise<void>;
32
33
  export declare function doctor(ctx: Ctx, flags: Flags): Promise<number>;
33
34
  export {};
package/dist/commands.js CHANGED
@@ -10,6 +10,7 @@ import { checkManifest, compareManifests, fileExistsIn, MANIFEST_FILE, manifestS
10
10
  import { authorize, clientKnown, exchangeCode, registerClient, revokeToken } from "./oauth.js";
11
11
  import { formatChecks, GLYPH, paint, summarize, useColor } from "./output.js";
12
12
  import { diffSchema, formatChanges, pinSchema, readPin, SCHEMA_FILE, summarizeChanges, writePin } from "./pin.js";
13
+ import { checkMapledMd, MD_FILE, mergeMd } from "./md.js";
13
14
  import { loadTypeScript, mergeManifest, scanRepository } from "./scan.js";
14
15
  import { generateTypes } from "./types.js";
15
16
  /** The OAuth client id for this API — registered once per machine, and
@@ -331,7 +332,8 @@ export async function bindingsPush(ctx, flags) {
331
332
  graded.push(`${s.missing_on_site} missing on site`);
332
333
  if (s.disabled)
333
334
  graded.push(`${s.disabled} disabled`);
334
- ctx.out(`Pushed ${MANIFEST_FILE} to ${session.conn.projectName} — manifest v${result.manifest.version}, ${plural(body.bindings.length, "binding")}: ${graded.join(", ")}.`);
335
+ const recorded = result.manifest.integrationHash ? ` (integration ${result.manifest.integrationHash})` : "";
336
+ ctx.out(`Pushed ${MANIFEST_FILE} to ${session.conn.projectName} — manifest v${result.manifest.version}${recorded}, ${plural(body.bindings.length, "binding")}: ${graded.join(", ")}.`);
335
337
  for (const w of result.warnings)
336
338
  ctx.out(`${paint("warning", GLYPH.warning, color)} ${w}`);
337
339
  if ((s.type_mismatch ?? 0) + (s.outdated ?? 0) + (s.missing_on_site ?? 0) > 0 || result.warnings.length > 0) {
@@ -367,6 +369,27 @@ export async function bindingsPull(ctx, flags) {
367
369
  await writeManifestFile(found.dir, stored.manifest);
368
370
  ctx.out(`Wrote ${MANIFEST_FILE} from manifest v${stored.version} (pushed ${dr.ago(stored.createdAt)} by ${stored.clientName}) — ${manifestSummary(stored.manifest)}.`);
369
371
  }
372
+ /* ---- MAPLED.md ---- */
373
+ export async function mdPull(ctx, flags) {
374
+ const { found, session } = await openSession(ctx, flags);
375
+ const fresh = await session.get("/v1/agent/mapled-md");
376
+ const file = path.join(found.dir, MD_FILE);
377
+ const existing = await readFile(file, "utf8").catch(() => null);
378
+ const merged = mergeMd(fresh.markdown, existing, { force: Boolean(flags.force) });
379
+ if (merged.kind === "unchanged") {
380
+ ctx.out(`${MD_FILE} is up to date (schema ${fresh.schemaHash}).`);
381
+ return;
382
+ }
383
+ await writeFile(file, merged.text);
384
+ const c = fresh.counts;
385
+ const summary = `${plural(c.collections, "collection")}, ${plural(c.singles, "single")}, ${plural(c.bindings, "binding")} (schema ${fresh.schemaHash})`;
386
+ if (merged.kind === "created")
387
+ ctx.out(`Wrote ${MD_FILE} — ${summary}. Commit it: the next agent reads it first.`);
388
+ else if (merged.kind === "replaced")
389
+ ctx.out(`Replaced ${MD_FILE} — ${summary}; the previous content was kept below the notes line.`);
390
+ else
391
+ ctx.out(`Updated ${MD_FILE} — ${summary}; the notes below the notes line were kept.`);
392
+ }
370
393
  /* ---- wave 2: the scanner ---- */
371
394
  function describeScan(result, schema) {
372
395
  const singles = new Set(schema.collections.filter((c) => c.kind === "single").map((c) => c.key));
@@ -478,6 +501,7 @@ export async function doctor(ctx, flags) {
478
501
  const conn = found ? findConnection(store, api, found.config.project) : undefined;
479
502
  let status = null;
480
503
  let schema = null;
504
+ let guide = null;
481
505
  let authError = null;
482
506
  if (conn) {
483
507
  const session = new Session(store, ctx.credentialsFile, conn, ctx.fetch);
@@ -488,6 +512,15 @@ export async function doctor(ctx, flags) {
488
512
  catch (err) {
489
513
  authError = err instanceof Error ? err.message : String(err);
490
514
  }
515
+ if (status) {
516
+ // the guide is one more read; an API without it must not fail the sign-in line
517
+ try {
518
+ guide = await session.get("/v1/agent/mapled-md");
519
+ }
520
+ catch {
521
+ guide = "unavailable";
522
+ }
523
+ }
491
524
  }
492
525
  const checks = [];
493
526
  checks.push(dr.checkLink(found ? { path: path.relative(ctx.cwd, found.path) || CONFIG_FILE } : null, status?.project.name ?? conn?.projectName ?? null));
@@ -510,6 +543,8 @@ export async function doctor(ctx, flags) {
510
543
  }
511
544
  }
512
545
  checks.push(dr.checkManifestFile(localManifest ? { manifest: localManifest.manifest, problems: localManifest.problems } : null, remoteManifest));
546
+ checks.push(dr.checkIntegration({ pin: pin?.pin ?? null, manifest: localManifest?.manifest ?? null }, conn && status ? { integration: status.integration, manifest: remoteManifest } : null));
547
+ checks.push(checkMapledMd(await readFile(path.join(dir, MD_FILE), "utf8").catch(() => null), guide));
513
548
  checks.push(dr.checkEnv(await dr.envNames(dir), ctx.env));
514
549
  checks.push(dr.checkSecrets(await dr.gitFacts(dir)));
515
550
  checks.push(dr.checkSdk(await dr.installedVersion(dir, "@mapled/next"), status?.sdk["@mapled/next"]));
package/dist/doctor.d.ts CHANGED
@@ -49,9 +49,25 @@ export type IntegrationStatus = {
49
49
  } | null;
50
50
  summary: Record<string, number>;
51
51
  };
52
+ /** the integration hash (§21.4); missing from an API older than the hash */
53
+ integration?: IntegrationState;
52
54
  previewPath: string;
53
55
  sdk: Record<string, string>;
54
56
  };
57
+ /** Whether the site's integration still matches what Mapled holds:
58
+ `hash` is the live schema plus the bindings as Mapled holds them now,
59
+ `synced` what the last manifest push recorded. */
60
+ export type IntegrationState = {
61
+ schemaHash: string;
62
+ hash: string | null;
63
+ synced: {
64
+ hash: string;
65
+ schemaHash: string;
66
+ } | null;
67
+ inSync: boolean | null;
68
+ schemaChanged: boolean;
69
+ bindingsChanged: boolean;
70
+ };
55
71
  export declare function ago(iso: string): string;
56
72
  export declare const ENV_FILES: string[];
57
73
  /** Variable NAMES defined in the repository's env files. Values stay in
@@ -111,10 +127,26 @@ export type StoredManifest = {
111
127
  version: number;
112
128
  clientName: string;
113
129
  createdAt: string;
130
+ /** what the push recorded; null when it predates the integration hash, missing from an older API */
131
+ schemaHash?: string | null;
132
+ integrationHash?: string | null;
114
133
  manifest: Manifest;
115
134
  };
116
135
  export declare function checkManifestFile(local: {
117
136
  manifest: Manifest;
118
137
  problems: Problem[];
119
138
  } | null, remote: StoredManifest | null | "unknown"): Check;
139
+ /** The one line that says whether the repository and Mapled describe the
140
+ same integration (§21.4): the hash over mapled/schema.json and
141
+ mapled/manifest.json here against the hash Mapled computes over the
142
+ live schema and the bindings it holds. The two lines above it say
143
+ what differs; this one says whether anything does, and whether the
144
+ last push recorded it. */
145
+ export declare function checkIntegration(local: {
146
+ pin: SchemaPin | null;
147
+ manifest: Manifest | null;
148
+ }, remote: {
149
+ integration: IntegrationState | undefined;
150
+ manifest: StoredManifest | null | "unknown";
151
+ } | null): Check;
120
152
  export declare const REMOTE_CHECKS: [string, string][];
package/dist/doctor.js CHANGED
@@ -2,6 +2,7 @@ import { execFile } from "node:child_process";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { promisify } from "node:util";
5
+ import { integrationHash } from "./integrationHash.js";
5
6
  import { compareManifests, manifestSummary, MANIFEST_FILE } from "./manifest.js";
6
7
  import { SCHEMA_FILE } from "./pin.js";
7
8
  import { splitGenerated } from "./types.js";
@@ -462,6 +463,73 @@ export function checkManifestFile(local, remote) {
462
463
  detail: `${MANIFEST_FILE} differs from the pushed manifest (v${remote.version}): ${parts.join(", ")} — run \`mapled bindings push\`.`,
463
464
  };
464
465
  }
466
+ /** The one line that says whether the repository and Mapled describe the
467
+ same integration (§21.4): the hash over mapled/schema.json and
468
+ mapled/manifest.json here against the hash Mapled computes over the
469
+ live schema and the bindings it holds. The two lines above it say
470
+ what differs; this one says whether anything does, and whether the
471
+ last push recorded it. */
472
+ export function checkIntegration(local, remote) {
473
+ const key = "integration";
474
+ const label = "Integration";
475
+ if (!local.pin || !local.manifest) {
476
+ const missing = [
477
+ local.pin ? null : `${SCHEMA_FILE} (\`mapled schema pull\`)`,
478
+ local.manifest ? null : `${MANIFEST_FILE} (\`mapled scan --write\`)`,
479
+ ].filter((s) => s !== null);
480
+ return { key, label, status: "skipped", detail: `Needs ${missing.join(" and ")} to compute the integration hash.` };
481
+ }
482
+ if (!remote)
483
+ return { key, label, status: "skipped", detail: "Sign in to compare with Mapled." };
484
+ const theirs = remote.integration;
485
+ if (!theirs) {
486
+ return { key, label, status: "skipped", detail: "Mapled didn't answer with an integration hash — update @mapled/cli, or try again later." };
487
+ }
488
+ const mine = integrationHash(local.pin.hash, local.manifest);
489
+ if (theirs.hash === null) {
490
+ return { key, label, status: "skipped", detail: `Nothing is pushed yet — \`mapled bindings push\` records the first integration hash (${mine} here).` };
491
+ }
492
+ if (mine === theirs.hash) {
493
+ if (theirs.synced === null) {
494
+ return { key, label, status: "warning", detail: `Matches Mapled (${mine}), but the last push predates the integration hash — run \`mapled bindings push\` to record it.` };
495
+ }
496
+ if (theirs.inSync === false) {
497
+ return {
498
+ key,
499
+ label,
500
+ status: "warning",
501
+ detail: `Matches Mapled (${mine}), but the last push was against schema ${theirs.synced.schemaHash} — run \`mapled bindings push\` to record the sync.`,
502
+ };
503
+ }
504
+ return { key, label, status: "passed", detail: `In sync with Mapled (${mine})` };
505
+ }
506
+ const reasons = [];
507
+ const fixes = [];
508
+ if (local.pin.hash !== theirs.schemaHash) {
509
+ reasons.push(`schema ${local.pin.hash} → ${theirs.schemaHash}`);
510
+ fixes.push("`mapled schema diff`");
511
+ }
512
+ if (remote.manifest && remote.manifest !== "unknown") {
513
+ const diff = compareManifests(local.manifest, remote.manifest.manifest);
514
+ if (!diff.same) {
515
+ const parts = [];
516
+ if (diff.onlyLocal.length > 0)
517
+ parts.push(`${diff.onlyLocal.length} not pushed`);
518
+ if (diff.changed.length > 0)
519
+ parts.push(`${diff.changed.length} changed`);
520
+ if (diff.onlyRemote.length > 0)
521
+ parts.push(`${diff.onlyRemote.length} only in Mapled`);
522
+ reasons.push(`the manifest differs (${parts.join(", ")}${theirs.bindingsChanged ? " — edited in Mapled" : ""})`);
523
+ // Mapled's edits win when the repository adds nothing of its own; `pull` refuses a differing file without --force
524
+ fixes.push(theirs.bindingsChanged && diff.onlyLocal.length === 0 ? "`mapled bindings pull --force`" : "`mapled bindings push`");
525
+ }
526
+ }
527
+ if (reasons.length === 0) {
528
+ reasons.push(`the manifest's pages or notes differ (${mine} here, ${theirs.hash} in Mapled)`);
529
+ fixes.push("`mapled bindings push`");
530
+ }
531
+ return { key, label, status: "warning", detail: `Out of sync — ${reasons.join(", ")}. Run ${fixes.join(" and ")}.` };
532
+ }
465
533
  export const REMOTE_CHECKS = [
466
534
  ["reads", "Site reads content"],
467
535
  ["webhook", "Publish webhook"],
package/dist/index.js CHANGED
@@ -3,12 +3,13 @@ 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, generate, link, login, logout, manifestValidate, scan, schemaDiff, schemaPull, } from "./commands.js";
6
+ import { bindingsPull, bindingsPush, doctor, generate, link, login, logout, manifestValidate, mdPull, scan, 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
10
- for the content, keep the schema pin and the site manifest in step
11
- with the code, check the integration (§31, waves 1 and 2). */
10
+ for the content, keep the schema pin, the site manifest and MAPLED.md
11
+ in step with the code, check the integration (§31, waves 1 and 2;
12
+ §21.3). */
12
13
  const HELP = `mapled — the Mapled CLI
13
14
 
14
15
  Usage
@@ -22,6 +23,7 @@ Usage
22
23
  mapled manifest validate [--json] Check mapled/manifest.json before pushing it
23
24
  mapled bindings push [--dry-run] Push mapled/manifest.json so Mapled grades every binding
24
25
  mapled bindings pull [--force] Write the pushed manifest into mapled/manifest.json
26
+ mapled md pull [--force] Write MAPLED.md, the guide for the next agent, from the project
25
27
  mapled doctor [--json] Check the integration end to end
26
28
 
27
29
  Options
@@ -103,6 +105,9 @@ async function main(argv) {
103
105
  case "bindings pull":
104
106
  await bindingsPull(ctx, flags);
105
107
  return 0;
108
+ case "md pull":
109
+ await mdPull(ctx, flags);
110
+ return 0;
106
111
  case "doctor":
107
112
  return doctor(ctx, flags);
108
113
  case "auth":
@@ -111,6 +116,7 @@ async function main(argv) {
111
116
  case "schema":
112
117
  case "manifest":
113
118
  case "bindings":
119
+ case "md":
114
120
  console.log(HELP);
115
121
  return 2;
116
122
  default:
@@ -0,0 +1,35 @@
1
+ /** The integration hash (§21.4): one marker over everything the site's
2
+ integration was written against — the schema (its hash, the twelve hex
3
+ characters in mapled-types.ts and mapled/schema.json) and the site
4
+ manifest (bindings, pages, framework, notes). Mapled records it on
5
+ every manifest push and computes it again on read; the repository
6
+ computes the same value from mapled/schema.json and
7
+ mapled/manifest.json, and `mapled doctor` compares the two in one
8
+ line. This is a literal port of apps/api/src/lib/integrationHash.ts —
9
+ the API's tests run both over one manifest and expect one hash.
10
+ Change the two together.
11
+
12
+ Bindings and pages are sorted, so the order in a file never matters;
13
+ optional fields take their defaults (`required: true` written or
14
+ omitted is the same integration, an empty component is none). */
15
+ export type HashedBinding = {
16
+ key: string;
17
+ page: string;
18
+ component?: string | null;
19
+ file?: string | null;
20
+ collection: string;
21
+ field?: string | null;
22
+ target: string;
23
+ required?: boolean | null;
24
+ };
25
+ export type HashedManifest = {
26
+ framework?: string | null;
27
+ integrationMode?: string | null;
28
+ pages?: {
29
+ route: string;
30
+ file?: string | null;
31
+ }[] | null;
32
+ bindings: HashedBinding[];
33
+ notes?: string[] | null;
34
+ };
35
+ export declare function integrationHash(schemaHash: string, manifest: HashedManifest): string;
@@ -0,0 +1,26 @@
1
+ import { createHash } from "node:crypto";
2
+ const byText = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
3
+ export function integrationHash(schemaHash, manifest) {
4
+ const projection = {
5
+ schema: schemaHash,
6
+ framework: manifest.framework || null,
7
+ integrationMode: manifest.integrationMode || null,
8
+ pages: (manifest.pages ?? [])
9
+ .map((p) => ({ route: p.route, file: p.file || null }))
10
+ .sort((a, b) => byText(a.route, b.route) || byText(a.file ?? "", b.file ?? "")),
11
+ bindings: manifest.bindings
12
+ .map((b) => ({
13
+ key: b.key,
14
+ page: b.page,
15
+ collection: b.collection,
16
+ field: b.field || null,
17
+ target: b.target,
18
+ required: b.required ?? true,
19
+ component: b.component || null,
20
+ file: b.file || null,
21
+ }))
22
+ .sort((a, b) => byText(a.key, b.key)),
23
+ notes: manifest.notes ?? [],
24
+ };
25
+ return createHash("sha256").update(JSON.stringify(projection)).digest("hex").slice(0, 12);
26
+ }
package/dist/md.d.ts ADDED
@@ -0,0 +1,62 @@
1
+ import type { Check } from "./doctor.js";
2
+ /** MAPLED.md (§21.3): Mapled renders the guide for the next agent,
3
+ the repository keeps it. `mapled md pull` writes what
4
+ GET /v1/agent/mapled-md answers and keeps everything below the notes
5
+ marker — that part is the repository's own; `doctor` compares the
6
+ rendered part of the local file with a fresh render. The first line
7
+ is a stamp (`<!-- mapled: project=… schema=… manifest=… integration=…
8
+ generated=… -->`) and the only line allowed to differ between a fresh
9
+ file and an up-to-date one — by its time. */
10
+ export declare const MD_FILE = "MAPLED.md";
11
+ export declare const NOTES_MARKER = "<!-- mapled:notes -->";
12
+ /** GET /v1/agent/mapled-md */
13
+ export type RenderedMd = {
14
+ file: string;
15
+ notesMarker: string;
16
+ markdown: string;
17
+ schemaHash: string;
18
+ /** the integration hash (§21.4); missing from an API older than the hash */
19
+ integrationHash?: string | null;
20
+ manifestVersion: number | null;
21
+ counts: {
22
+ collections: number;
23
+ singles: number;
24
+ forms: number;
25
+ bindings: number;
26
+ pages: number;
27
+ };
28
+ generatedAt: string;
29
+ };
30
+ export type MdStamp = {
31
+ project: string | null;
32
+ schema: string | null;
33
+ manifest: string | null;
34
+ integration: string | null;
35
+ };
36
+ export type MdParts = {
37
+ stamp: MdStamp;
38
+ /** the stamp line as written */
39
+ stampLine: string;
40
+ /** everything between the stamp and the notes marker */
41
+ body: string;
42
+ /** everything after the marker line; null when the file has no marker */
43
+ notes: string | null;
44
+ };
45
+ /** Splits a MAPLED.md written by Mapled; null for any other file. */
46
+ export declare function splitMd(text: string): MdParts | null;
47
+ export type MergedMd = {
48
+ kind: "unchanged";
49
+ } | {
50
+ kind: "created" | "updated" | "replaced";
51
+ text: string;
52
+ };
53
+ /** The file to write: the fresh render, with the repository's notes kept.
54
+ A file Mapled did not write is left alone unless forced — then its
55
+ whole content moves below the notes line, so nothing is lost. */
56
+ export declare function mergeMd(fresh: string, existing: string | null, opts?: {
57
+ force?: boolean;
58
+ }): MergedMd;
59
+ /** `doctor`'s line: is the repository's guide what Mapled would render now?
60
+ `fresh` is null without a sign-in and "unavailable" when Mapled
61
+ answered the other calls but not this one (an API without the route). */
62
+ export declare function checkMapledMd(local: string | null, fresh: RenderedMd | null | "unavailable"): Check;
package/dist/md.js ADDED
@@ -0,0 +1,113 @@
1
+ import { CliError } from "./errors.js";
2
+ /** MAPLED.md (§21.3): Mapled renders the guide for the next agent,
3
+ the repository keeps it. `mapled md pull` writes what
4
+ GET /v1/agent/mapled-md answers and keeps everything below the notes
5
+ marker — that part is the repository's own; `doctor` compares the
6
+ rendered part of the local file with a fresh render. The first line
7
+ is a stamp (`<!-- mapled: project=… schema=… manifest=… integration=…
8
+ generated=… -->`) and the only line allowed to differ between a fresh
9
+ file and an up-to-date one — by its time. */
10
+ export const MD_FILE = "MAPLED.md";
11
+ export const NOTES_MARKER = "<!-- mapled:notes -->";
12
+ /** Splits a MAPLED.md written by Mapled; null for any other file. */
13
+ export function splitMd(text) {
14
+ const lines = text.split("\n");
15
+ const first = lines[0] ?? "";
16
+ const m = /^<!-- mapled: (.*) -->\s*$/.exec(first);
17
+ if (!m)
18
+ return null;
19
+ const stamp = { project: null, schema: null, manifest: null, integration: null };
20
+ for (const pair of m[1].split(/\s+/)) {
21
+ const eq = pair.indexOf("=");
22
+ if (eq === -1)
23
+ continue;
24
+ const key = pair.slice(0, eq);
25
+ const value = pair.slice(eq + 1);
26
+ if (key === "project" || key === "schema" || key === "manifest" || key === "integration")
27
+ stamp[key] = value;
28
+ }
29
+ const marker = lines.indexOf(NOTES_MARKER, 1);
30
+ const body = lines.slice(1, marker === -1 ? lines.length : marker).join("\n");
31
+ const notes = marker === -1 ? null : lines.slice(marker + 1).join("\n");
32
+ return { stamp, stampLine: first, body, notes };
33
+ }
34
+ /** The same render: the body and what the stamp says about it, never
35
+ the time it was rendered at. */
36
+ function sameRender(a, b) {
37
+ return (a.body === b.body &&
38
+ a.stamp.schema === b.stamp.schema &&
39
+ a.stamp.manifest === b.stamp.manifest &&
40
+ a.stamp.integration === b.stamp.integration);
41
+ }
42
+ /** The file to write: the fresh render, with the repository's notes kept.
43
+ A file Mapled did not write is left alone unless forced — then its
44
+ whole content moves below the notes line, so nothing is lost. */
45
+ export function mergeMd(fresh, existing, opts = {}) {
46
+ const next = splitMd(fresh);
47
+ if (!next || next.notes === null)
48
+ throw new CliError("Mapled answered a guide without its stamp or notes marker — update @mapled/cli and try again.");
49
+ if (existing === null)
50
+ return { kind: "created", text: fresh };
51
+ const current = splitMd(existing);
52
+ if (!current) {
53
+ if (!opts.force) {
54
+ throw new CliError(`${MD_FILE} here wasn't written by Mapled. Pass --force to replace it — the current content moves below the notes line, nothing is lost.`);
55
+ }
56
+ const kept = existing.replace(/\s+$/, "");
57
+ return { kind: "replaced", text: `${next.stampLine}\n${next.body}\n${NOTES_MARKER}\n## Notes\n\n${kept}\n` };
58
+ }
59
+ if (sameRender(current, next) && current.notes !== null)
60
+ return { kind: "unchanged" };
61
+ const notes = current.notes === null || current.notes.trim() === "" ? next.notes : current.notes;
62
+ return { kind: "updated", text: `${next.stampLine}\n${next.body}\n${NOTES_MARKER}\n${notes}` };
63
+ }
64
+ /** `doctor`'s line: is the repository's guide what Mapled would render now?
65
+ `fresh` is null without a sign-in and "unavailable" when Mapled
66
+ answered the other calls but not this one (an API without the route). */
67
+ export function checkMapledMd(local, fresh) {
68
+ const label = "MAPLED.md";
69
+ if (local === null) {
70
+ return { key: "mapled_md", label, status: "skipped", detail: `No ${MD_FILE} — run \`mapled md pull\` so the next agent starts from the current integration.` };
71
+ }
72
+ if (fresh === null)
73
+ return { key: "mapled_md", label, status: "skipped", detail: "Sign in to compare with Mapled." };
74
+ if (fresh === "unavailable") {
75
+ return { key: "mapled_md", label, status: "skipped", detail: "Mapled didn't answer with a guide — update @mapled/cli, or try again later." };
76
+ }
77
+ const current = splitMd(local);
78
+ if (!current) {
79
+ return {
80
+ key: "mapled_md",
81
+ label,
82
+ status: "warning",
83
+ detail: `${MD_FILE} wasn't written by Mapled — run \`mapled md pull --force\` to replace it (the current content moves below the notes line).`,
84
+ };
85
+ }
86
+ const next = splitMd(fresh.markdown);
87
+ const manifest = fresh.manifestVersion === null ? "none" : `v${fresh.manifestVersion}`;
88
+ if (next && sameRender(current, next) && current.notes !== null) {
89
+ return {
90
+ key: "mapled_md",
91
+ label,
92
+ status: "passed",
93
+ detail: `${MD_FILE} matches the schema (${fresh.schemaHash})${fresh.manifestVersion === null ? "" : ` and manifest ${manifest}`}`,
94
+ };
95
+ }
96
+ if (current.stamp.schema && current.stamp.schema !== fresh.schemaHash) {
97
+ return {
98
+ key: "mapled_md",
99
+ label,
100
+ status: "warning",
101
+ detail: `${MD_FILE} describes schema ${current.stamp.schema}; the schema is ${fresh.schemaHash} — run \`mapled md pull\`.`,
102
+ };
103
+ }
104
+ if (current.stamp.manifest && current.stamp.manifest !== manifest) {
105
+ return {
106
+ key: "mapled_md",
107
+ label,
108
+ status: "warning",
109
+ detail: `${MD_FILE} describes manifest ${current.stamp.manifest}; ${manifest === "none" ? "none is pushed now" : `${manifest} is pushed`} — run \`mapled md pull\`.`,
110
+ };
111
+ }
112
+ return { key: "mapled_md", label, status: "warning", detail: `${MD_FILE} is out of date — run \`mapled md pull\`.` };
113
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mapled/cli",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Mapled CLI — sign in, link a project, generate TypeScript types for its content and check the site's integration.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://mapled.io",