@mapled/cli 0.1.0 → 0.3.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/dist/commands.js CHANGED
@@ -1,17 +1,18 @@
1
1
  import { readFile, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { apiGet, Session } from "./api.js";
3
+ import { ApiError, apiGet, Session } from "./api.js";
4
4
  import { stringFlag } from "./args.js";
5
5
  import { CONFIG_FILE, DEFAULT_TYPES_PATH, findConfig, resolveApi, writeConfig } from "./config.js";
6
6
  import { connectionsFor, findConnection, readCredentials, removeConnection, upsertConnection, writeCredentials, } from "./credentials.js";
7
7
  import * as dr from "./doctor.js";
8
8
  import { CliError } from "./errors.js";
9
+ import { checkManifest, compareManifests, fileExistsIn, MANIFEST_FILE, manifestSummary, orderManifest, parseManifest, plural, readManifestFile, writeManifestFile, } from "./manifest.js";
9
10
  import { authorize, clientKnown, exchangeCode, registerClient, revokeToken } from "./oauth.js";
10
- import { formatChecks, summarize, useColor } from "./output.js";
11
+ import { formatChecks, GLYPH, paint, summarize, useColor } from "./output.js";
12
+ import { diffSchema, formatChanges, pinSchema, readPin, SCHEMA_FILE, summarizeChanges, writePin } from "./pin.js";
13
+ import { checkMapledMd, MD_FILE, mergeMd } from "./md.js";
14
+ import { loadTypeScript, mergeManifest, scanRepository } from "./scan.js";
11
15
  import { generateTypes } from "./types.js";
12
- function plural(n, word) {
13
- return `${n} ${word}${n === 1 ? "" : "s"}`;
14
- }
15
16
  /** The OAuth client id for this API — registered once per machine, and
16
17
  again when the server no longer knows it. */
17
18
  async function ensureClient(store, api, f) {
@@ -147,6 +148,349 @@ export async function generate(ctx, flags) {
147
148
  await writeFile(file, generated.text);
148
149
  ctx.out(`Wrote ${path.relative(ctx.cwd, file) || out} — ${plural(generated.collections, "collection")}, ` +
149
150
  `${plural(generated.singles, "single")} (schema ${generated.hash}).`);
151
+ // the pin moves with the types, so `schema diff` starts from what the site was built against
152
+ const existing = await readPin(found.dir);
153
+ if (existing && existing.pin.hash !== generated.hash) {
154
+ await writePin(found.dir, pinSchema(schema, found.config.project));
155
+ ctx.out(`Refreshed ${SCHEMA_FILE} (schema ${existing.pin.hash} → ${generated.hash}).`);
156
+ }
157
+ }
158
+ /* ---- wave 2: the schema pin ---- */
159
+ function paintSeverity(color) {
160
+ return (severity, text) => paint(severity === "breaking" ? "failed" : "skipped", text, color);
161
+ }
162
+ export async function schemaPull(ctx, flags) {
163
+ const { found, session } = await openSession(ctx, flags);
164
+ const live = await session.get("/v1/agent/schema");
165
+ const pin = pinSchema(live, found.config.project);
166
+ const existing = await readPin(found.dir);
167
+ const collections = pin.collections.filter((c) => c.kind === "collection").length;
168
+ const singles = pin.collections.length - collections;
169
+ if (!existing) {
170
+ await writePin(found.dir, pin);
171
+ ctx.out(`Wrote ${SCHEMA_FILE} — ${plural(collections, "collection")}, ${plural(singles, "single")} (schema ${pin.hash}).`);
172
+ return;
173
+ }
174
+ if (existing.pin.hash === pin.hash) {
175
+ ctx.out(`${SCHEMA_FILE} is up to date (schema ${pin.hash}).`);
176
+ return;
177
+ }
178
+ const changes = diffSchema(existing.pin.collections, pin.collections);
179
+ await writePin(found.dir, pin);
180
+ ctx.out(`Updated ${SCHEMA_FILE} (schema ${existing.pin.hash} → ${pin.hash}): ${summarizeChanges(changes).toLowerCase()}.`);
181
+ ctx.out("");
182
+ for (const line of formatChanges(changes, paintSeverity(useColor(ctx.env))))
183
+ ctx.out(line);
184
+ const typesPath = found.config.types ?? DEFAULT_TYPES_PATH;
185
+ const types = await readFile(path.join(found.dir, typesPath), "utf8").catch(() => null);
186
+ if (types !== null) {
187
+ ctx.out("");
188
+ ctx.out(`Next: \`mapled types generate\` — ${typesPath} still describes the previous schema.`);
189
+ }
190
+ }
191
+ export async function schemaDiff(ctx, flags) {
192
+ const { found, session } = await openSession(ctx, flags);
193
+ const existing = await readPin(found.dir);
194
+ if (!existing) {
195
+ throw new CliError(`No ${SCHEMA_FILE} yet. Run \`mapled schema pull\` to record the schema this site is built against.`);
196
+ }
197
+ const live = pinSchema(await session.get("/v1/agent/schema"), found.config.project);
198
+ const changes = diffSchema(existing.pin.collections, live.collections);
199
+ if (flags.json) {
200
+ ctx.out(JSON.stringify({ from: existing.pin.hash, to: live.hash, changes }, null, 2));
201
+ }
202
+ else if (changes.length === 0) {
203
+ ctx.out(`No schema changes since ${SCHEMA_FILE} (${existing.pin.hash}).`);
204
+ }
205
+ else {
206
+ ctx.out(`Schema changes since ${SCHEMA_FILE} (${existing.pin.hash} → ${live.hash}):`);
207
+ ctx.out("");
208
+ for (const line of formatChanges(changes, paintSeverity(useColor(ctx.env))))
209
+ ctx.out(line);
210
+ ctx.out("");
211
+ const typesPath = found.config.types ?? DEFAULT_TYPES_PATH;
212
+ const types = await readFile(path.join(found.dir, typesPath), "utf8").catch(() => null);
213
+ const next = types !== null ? `\`mapled types generate\` (it refreshes ${SCHEMA_FILE} as well)` : `\`mapled schema pull\``;
214
+ ctx.out(`${summarizeChanges(changes)}, judged for a site that reads the content. Update the site where needed, then run ${next}.`);
215
+ }
216
+ return flags["exit-code"] && changes.length > 0 ? 1 : 0;
217
+ }
218
+ async function loadManifest(dir) {
219
+ const found = await readManifestFile(dir);
220
+ if (!found)
221
+ return null;
222
+ const parsed = parseManifest(found.raw, MANIFEST_FILE);
223
+ return { file: found.file, ...parsed };
224
+ }
225
+ /** The schema to check bindings against: the pin when the repository
226
+ keeps one, otherwise the live schema of a signed-in project. */
227
+ async function schemaForChecks(ctx, flags, found) {
228
+ const pin = await readPin(found.dir);
229
+ if (pin)
230
+ return { schema: { collections: pin.pin.collections }, note: null };
231
+ try {
232
+ const { session } = await openSession(ctx, flags);
233
+ return { schema: await session.get("/v1/agent/schema"), note: null };
234
+ }
235
+ catch (err) {
236
+ return {
237
+ schema: null,
238
+ note: `Bindings weren't checked against the schema — ${err instanceof Error ? err.message.replace(/\.$/, "") : "sign in"}, or run \`mapled schema pull\`.`,
239
+ };
240
+ }
241
+ }
242
+ function formatProblems(problems, color) {
243
+ return problems.map((p) => `${paint(p.level === "error" ? "failed" : "warning", GLYPH[p.level === "error" ? "failed" : "warning"], color)} ${p.message}`);
244
+ }
245
+ function problemSummary(problems) {
246
+ const errors = problems.filter((p) => p.level === "error").length;
247
+ const warnings = problems.length - errors;
248
+ const parts = [];
249
+ if (errors > 0)
250
+ parts.push(plural(errors, "problem"));
251
+ if (warnings > 0)
252
+ parts.push(plural(warnings, "warning"));
253
+ return { errors, warnings, text: parts.join(", ") };
254
+ }
255
+ export async function manifestValidate(ctx, flags) {
256
+ const found = await findConfig(ctx.cwd);
257
+ const dir = found?.dir ?? path.resolve(ctx.cwd);
258
+ const loaded = await loadManifest(dir);
259
+ if (!loaded) {
260
+ throw new CliError(`No ${MANIFEST_FILE} here. Run \`mapled scan --write\` to create one from the site's code, or ask your AI agent to write it.`);
261
+ }
262
+ const notes = [];
263
+ let schema = null;
264
+ if (found) {
265
+ const got = await schemaForChecks(ctx, flags, found);
266
+ schema = got.schema;
267
+ if (got.note)
268
+ notes.push(got.note);
269
+ }
270
+ else {
271
+ notes.push(`Bindings weren't checked against the schema — no ${CONFIG_FILE} here; run \`mapled project link\`.`);
272
+ }
273
+ const problems = [...loaded.problems, ...(await checkManifest(loaded.manifest, { schema, fileExists: fileExistsIn(dir) }))];
274
+ problems.sort((a, b) => (a.level === b.level ? 0 : a.level === "error" ? -1 : 1));
275
+ const summary = problemSummary(problems);
276
+ if (flags.json) {
277
+ ctx.out(JSON.stringify({ file: MANIFEST_FILE, bindings: loaded.manifest.bindings.length, pages: loaded.manifest.pages?.length ?? 0, problems, notes }, null, 2));
278
+ }
279
+ else {
280
+ if (problems.length > 0) {
281
+ for (const line of formatProblems(problems, useColor(ctx.env)))
282
+ ctx.out(line);
283
+ ctx.out("");
284
+ }
285
+ for (const note of notes)
286
+ ctx.out(note);
287
+ ctx.out(`${MANIFEST_FILE}: ${manifestSummary(loaded.manifest)} — ${problems.length === 0 ? "valid" : summary.text}.`);
288
+ if (summary.errors > 0)
289
+ ctx.out("Fix the problems before `mapled bindings push`.");
290
+ }
291
+ return summary.errors > 0 ? 1 : 0;
292
+ }
293
+ export async function bindingsPush(ctx, flags) {
294
+ const { found, session } = await openSession(ctx, flags);
295
+ const loaded = await loadManifest(found.dir);
296
+ if (!loaded)
297
+ throw new CliError(`No ${MANIFEST_FILE} here. Run \`mapled scan --write\` to create one from the site's code.`);
298
+ const schema = await session.get("/v1/agent/schema");
299
+ const problems = [...loaded.problems, ...(await checkManifest(loaded.manifest, { schema, fileExists: fileExistsIn(found.dir) }))];
300
+ const summary = problemSummary(problems);
301
+ if (summary.errors > 0) {
302
+ throw new CliError(`${MANIFEST_FILE} has ${plural(summary.errors, "problem")} — run \`mapled manifest validate\` and fix them first.`);
303
+ }
304
+ const color = useColor(ctx.env);
305
+ if (problems.length > 0)
306
+ for (const line of formatProblems(problems, color))
307
+ ctx.out(line);
308
+ const body = orderManifest(loaded.manifest);
309
+ if (flags["dry-run"]) {
310
+ ctx.out(`Would push ${MANIFEST_FILE} to ${session.conn.projectName} — ${manifestSummary(body)}${summary.warnings > 0 ? `, ${plural(summary.warnings, "warning")}` : ""}. Nothing was sent.`);
311
+ return;
312
+ }
313
+ let result;
314
+ try {
315
+ result = await session.send("POST", "/v1/agent/manifest", body);
316
+ }
317
+ catch (err) {
318
+ if (err instanceof ApiError && err.code === "BUILDER_PLAN_REQUIRED") {
319
+ throw new CliError("Pushing bindings changes the project's structure, which needs a builder plan — ask the project owner to upgrade.");
320
+ }
321
+ throw err;
322
+ }
323
+ const s = result.summary;
324
+ const graded = [`${s.healthy ?? 0} healthy`];
325
+ if (s.type_mismatch)
326
+ graded.push(`${s.type_mismatch} type mismatch${s.type_mismatch === 1 ? "" : "es"}`);
327
+ if (s.outdated)
328
+ graded.push(`${s.outdated} outdated`);
329
+ if (s.not_checked)
330
+ graded.push(`${s.not_checked} not checked`);
331
+ if (s.missing_on_site)
332
+ graded.push(`${s.missing_on_site} missing on site`);
333
+ if (s.disabled)
334
+ graded.push(`${s.disabled} disabled`);
335
+ ctx.out(`Pushed ${MANIFEST_FILE} to ${session.conn.projectName} — manifest v${result.manifest.version}, ${plural(body.bindings.length, "binding")}: ${graded.join(", ")}.`);
336
+ for (const w of result.warnings)
337
+ ctx.out(`${paint("warning", GLYPH.warning, color)} ${w}`);
338
+ if ((s.type_mismatch ?? 0) + (s.outdated ?? 0) + (s.missing_on_site ?? 0) > 0 || result.warnings.length > 0) {
339
+ ctx.out("Open Structure → Bindings in Mapled to review them.");
340
+ }
341
+ }
342
+ export async function bindingsPull(ctx, flags) {
343
+ const { found, session } = await openSession(ctx, flags);
344
+ let stored;
345
+ try {
346
+ stored = await session.get("/v1/agent/manifest");
347
+ }
348
+ catch (err) {
349
+ if (err instanceof ApiError && err.status === 404) {
350
+ throw new CliError(`Nothing to pull — no manifest has been pushed to ${session.conn.projectName} yet.`);
351
+ }
352
+ throw err;
353
+ }
354
+ const local = await loadManifest(found.dir);
355
+ if (local && !flags.force) {
356
+ const diff = compareManifests(local.manifest, stored.manifest);
357
+ if (!diff.same) {
358
+ const parts = [];
359
+ if (diff.onlyLocal.length > 0)
360
+ parts.push(`${diff.onlyLocal.length} not pushed`);
361
+ if (diff.changed.length > 0)
362
+ parts.push(`${diff.changed.length} changed`);
363
+ if (diff.onlyRemote.length > 0)
364
+ parts.push(`${diff.onlyRemote.length} only in Mapled`);
365
+ 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\`.`);
366
+ }
367
+ }
368
+ await writeManifestFile(found.dir, stored.manifest);
369
+ ctx.out(`Wrote ${MANIFEST_FILE} from manifest v${stored.version} (pushed ${dr.ago(stored.createdAt)} by ${stored.clientName}) — ${manifestSummary(stored.manifest)}.`);
370
+ }
371
+ /* ---- MAPLED.md ---- */
372
+ export async function mdPull(ctx, flags) {
373
+ const { found, session } = await openSession(ctx, flags);
374
+ const fresh = await session.get("/v1/agent/mapled-md");
375
+ const file = path.join(found.dir, MD_FILE);
376
+ const existing = await readFile(file, "utf8").catch(() => null);
377
+ const merged = mergeMd(fresh.markdown, existing, { force: Boolean(flags.force) });
378
+ if (merged.kind === "unchanged") {
379
+ ctx.out(`${MD_FILE} is up to date (schema ${fresh.schemaHash}).`);
380
+ return;
381
+ }
382
+ await writeFile(file, merged.text);
383
+ const c = fresh.counts;
384
+ const summary = `${plural(c.collections, "collection")}, ${plural(c.singles, "single")}, ${plural(c.bindings, "binding")} (schema ${fresh.schemaHash})`;
385
+ if (merged.kind === "created")
386
+ ctx.out(`Wrote ${MD_FILE} — ${summary}. Commit it: the next agent reads it first.`);
387
+ else if (merged.kind === "replaced")
388
+ ctx.out(`Replaced ${MD_FILE} — ${summary}; the previous content was kept below the notes line.`);
389
+ else
390
+ ctx.out(`Updated ${MD_FILE} — ${summary}; the notes below the notes line were kept.`);
391
+ }
392
+ /* ---- wave 2: the scanner ---- */
393
+ function describeScan(result, schema) {
394
+ const singles = new Set(schema.collections.filter((c) => c.kind === "single").map((c) => c.key));
395
+ const lines = [];
396
+ const byPage = new Map();
397
+ for (const b of result.bindings) {
398
+ if (!byPage.has(b.page))
399
+ byPage.set(b.page, []);
400
+ byPage.get(b.page).push(b);
401
+ }
402
+ const fileOf = new Map(result.pages.map((p) => [p.route, p.file ?? ""]));
403
+ const routeWidth = Math.max(0, ...[...byPage.keys()].map((r) => r.length));
404
+ for (const [page, list] of byPage) {
405
+ lines.push(`${page.padEnd(routeWidth)} ${fileOf.get(page) ?? ""}`.trimEnd());
406
+ const byCollection = new Map();
407
+ for (const b of list) {
408
+ if (!byCollection.has(b.collection))
409
+ byCollection.set(b.collection, []);
410
+ byCollection.get(b.collection).push(b);
411
+ }
412
+ const width = Math.max(0, ...[...byCollection.keys()].map((c) => c.length));
413
+ for (const [collection, bindings] of byCollection) {
414
+ const parts = [];
415
+ if (bindings.some((b) => b.target === "collection"))
416
+ parts.push(singles.has(collection) ? "read" : "list");
417
+ if (bindings.some((b) => b.target === "route_param"))
418
+ parts.push("by slug");
419
+ const fields = bindings.filter((b) => b.field && b.target !== "route_param").map((b) => b.field);
420
+ if (fields.length > 0)
421
+ parts.push(fields.join(", "));
422
+ lines.push(` ${collection.padEnd(width)} ${parts.join(" • ")}`);
423
+ }
424
+ }
425
+ return lines;
426
+ }
427
+ export async function scan(ctx, flags) {
428
+ const found = await findConfig(ctx.cwd);
429
+ if (!found)
430
+ throw new CliError(`No ${CONFIG_FILE} here or above. Run \`mapled project link\` first.`);
431
+ const { schema, note } = await schemaForChecks(ctx, flags, found);
432
+ if (!schema) {
433
+ throw new CliError(`The scan needs the schema to name the fields — sign in with \`mapled auth login\` or run \`mapled schema pull\` first.`);
434
+ }
435
+ const framework = found.config.framework ?? (await dr.detectFramework(found.dir)) ?? null;
436
+ const ts = loadTypeScript(found.dir);
437
+ const result = await scanRepository(found.dir, { framework, schema, ts });
438
+ const existing = await loadManifest(found.dir);
439
+ const merged = mergeManifest(existing?.manifest ?? null, result, { prune: Boolean(flags.prune) });
440
+ if (flags.json) {
441
+ ctx.out(JSON.stringify({ parser: result.parser, files: result.files, manifest: orderManifest(merged.manifest), notes: result.notes, kept: merged.kept, dropped: merged.dropped }, null, 2));
442
+ if (flags.write)
443
+ await writeManifestFile(found.dir, merged.manifest);
444
+ return;
445
+ }
446
+ ctx.out(`Scanned ${plural(result.files, "file")} with ${result.parser === "typescript" ? "TypeScript" : "the tokenizer"} — ${plural(result.pages.length, "page")}, ${plural(result.bindings.length, "binding")}.`);
447
+ if (result.bindings.length > 0) {
448
+ ctx.out("");
449
+ for (const line of describeScan(result, schema))
450
+ ctx.out(line);
451
+ }
452
+ if (result.notes.length > 0) {
453
+ ctx.out("");
454
+ for (const n of result.notes)
455
+ ctx.out(`${paint("warning", GLYPH.warning, useColor(ctx.env))} ${n}`);
456
+ }
457
+ ctx.out("");
458
+ const keptNote = merged.kept.length > 0 ? ` Kept ${plural(merged.kept.length, "binding")} the scan didn't find (${merged.kept.slice(0, 5).join(", ")}${merged.kept.length > 5 ? ", …" : ""}) — pass --prune to drop them.` : "";
459
+ const droppedNote = merged.dropped.length > 0 ? ` Dropped ${plural(merged.dropped.length, "binding")} the scan didn't find (${merged.dropped.slice(0, 5).join(", ")}${merged.dropped.length > 5 ? ", …" : ""}).` : "";
460
+ if (flags.write) {
461
+ await writeManifestFile(found.dir, merged.manifest);
462
+ const parts = [];
463
+ if (merged.added.length > 0)
464
+ parts.push(`${merged.added.length} new`);
465
+ if (merged.changed.length > 0)
466
+ parts.push(`${merged.changed.length} changed`);
467
+ if (merged.unchanged.length > 0)
468
+ parts.push(`${merged.unchanged.length} unchanged`);
469
+ ctx.out(`Wrote ${MANIFEST_FILE} — ${manifestSummary(merged.manifest)}${parts.length > 0 && existing ? ` (${parts.join(", ")})` : ""}.${keptNote}${droppedNote}`);
470
+ ctx.out("Next: `mapled bindings push`.");
471
+ }
472
+ else if (!existing) {
473
+ ctx.out(`No ${MANIFEST_FILE} yet — run \`mapled scan --write\` to create it, then \`mapled bindings push\`.`);
474
+ }
475
+ else {
476
+ const parts = [];
477
+ if (merged.added.length > 0)
478
+ parts.push(`${merged.added.length} new`);
479
+ if (merged.changed.length > 0)
480
+ parts.push(`${merged.changed.length} changed`);
481
+ parts.push(`${merged.unchanged.length} unchanged`);
482
+ if (merged.kept.length > 0)
483
+ parts.push(`${merged.kept.length} not found in the code (${merged.kept.slice(0, 5).join(", ")}${merged.kept.length > 5 ? ", …" : ""})`);
484
+ ctx.out(`Compared with ${MANIFEST_FILE}: ${parts.join(", ")}.`);
485
+ if (merged.added.length > 0 || merged.changed.length > 0) {
486
+ ctx.out(`Run \`mapled scan --write\` to update ${MANIFEST_FILE}, then \`mapled bindings push\`.`);
487
+ }
488
+ else {
489
+ ctx.out(`${MANIFEST_FILE} already describes what the code reads.`);
490
+ }
491
+ }
492
+ if (note)
493
+ ctx.out(note);
150
494
  }
151
495
  export async function doctor(ctx, flags) {
152
496
  const found = await findConfig(ctx.cwd);
@@ -156,6 +500,7 @@ export async function doctor(ctx, flags) {
156
500
  const conn = found ? findConnection(store, api, found.config.project) : undefined;
157
501
  let status = null;
158
502
  let schema = null;
503
+ let guide = null;
159
504
  let authError = null;
160
505
  if (conn) {
161
506
  const session = new Session(store, ctx.credentialsFile, conn, ctx.fetch);
@@ -166,6 +511,15 @@ export async function doctor(ctx, flags) {
166
511
  catch (err) {
167
512
  authError = err instanceof Error ? err.message : String(err);
168
513
  }
514
+ if (status) {
515
+ // the guide is one more read; an API without it must not fail the sign-in line
516
+ try {
517
+ guide = await session.get("/v1/agent/mapled-md");
518
+ }
519
+ catch {
520
+ guide = "unavailable";
521
+ }
522
+ }
169
523
  }
170
524
  const checks = [];
171
525
  checks.push(dr.checkLink(found ? { path: path.relative(ctx.cwd, found.path) || CONFIG_FILE } : null, status?.project.name ?? conn?.projectName ?? null));
@@ -173,6 +527,22 @@ export async function doctor(ctx, flags) {
173
527
  const typesPath = found?.config.types ?? DEFAULT_TYPES_PATH;
174
528
  const existing = await readFile(path.join(dir, typesPath), "utf8").catch(() => null);
175
529
  checks.push(dr.checkTypes(existing, schema ? generateTypes(schema, { projectName: status?.project.name }) : null, typesPath));
530
+ const pin = found ? await readPin(dir).catch(() => null) : null;
531
+ const livePin = schema && found ? pinSchema(schema, found.config.project) : null;
532
+ const pinChanges = pin && livePin ? diffSchema(pin.pin.collections, livePin.collections) : null;
533
+ checks.push(dr.checkSchemaPin(pin?.pin ?? null, livePin?.hash ?? null, pinChanges));
534
+ const localManifest = await loadManifest(dir).catch(() => null);
535
+ let remoteManifest = "unknown";
536
+ if (conn && status && localManifest) {
537
+ try {
538
+ remoteManifest = await new Session(store, ctx.credentialsFile, conn, ctx.fetch).get("/v1/agent/manifest");
539
+ }
540
+ catch (err) {
541
+ remoteManifest = err instanceof ApiError && err.status === 404 ? null : "unknown";
542
+ }
543
+ }
544
+ checks.push(dr.checkManifestFile(localManifest ? { manifest: localManifest.manifest, problems: localManifest.problems } : null, remoteManifest));
545
+ checks.push(checkMapledMd(await readFile(path.join(dir, MD_FILE), "utf8").catch(() => null), guide));
176
546
  checks.push(dr.checkEnv(await dr.envNames(dir), ctx.env));
177
547
  checks.push(dr.checkSecrets(await dr.gitFacts(dir)));
178
548
  checks.push(dr.checkSdk(await dr.installedVersion(dir, "@mapled/next"), status?.sdk["@mapled/next"]));
package/dist/doctor.d.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import type { Connection } from "./credentials.js";
2
+ import { type Manifest, type Problem } from "./manifest.js";
2
3
  import type { Fetch } from "./oauth.js";
4
+ import { type SchemaChange, type SchemaPin } from "./pin.js";
3
5
  import { type Generated } from "./types.js";
4
6
  /** `mapled doctor` (§31.3, wave 1): the checks themselves are pure
5
7
  functions of what was gathered — the repository side here, the
@@ -103,4 +105,16 @@ export declare function checkPreviewOnSite(origin: string | null, previewPath: s
103
105
  status: number | null;
104
106
  } | null): Check;
105
107
  export declare function checkBindings(bindings: IntegrationStatus["bindings"]): Check;
108
+ export declare function checkSchemaPin(pin: SchemaPin | null, liveHash: string | null, changes: SchemaChange[] | null): Check;
109
+ /** The pushed manifest as `GET /v1/agent/manifest` answers it. */
110
+ export type StoredManifest = {
111
+ version: number;
112
+ clientName: string;
113
+ createdAt: string;
114
+ manifest: Manifest;
115
+ };
116
+ export declare function checkManifestFile(local: {
117
+ manifest: Manifest;
118
+ problems: Problem[];
119
+ } | null, remote: StoredManifest | null | "unknown"): Check;
106
120
  export declare const REMOTE_CHECKS: [string, string][];
package/dist/doctor.js CHANGED
@@ -2,6 +2,8 @@ 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 { compareManifests, manifestSummary, MANIFEST_FILE } from "./manifest.js";
6
+ import { SCHEMA_FILE } from "./pin.js";
5
7
  import { splitGenerated } from "./types.js";
6
8
  export function ago(iso) {
7
9
  const m = Math.floor((Date.now() - new Date(iso).getTime()) / 60_000);
@@ -414,6 +416,52 @@ export function checkBindings(bindings) {
414
416
  detail: `${s.healthy ?? 0} healthy • synced ${ago(bindings.manifest.createdAt)} from ${bindings.manifest.clientName}`,
415
417
  };
416
418
  }
419
+ export function checkSchemaPin(pin, liveHash, changes) {
420
+ const label = "Schema pin";
421
+ if (!pin)
422
+ return { key: "schema", label, status: "skipped", detail: `No ${SCHEMA_FILE} — run \`mapled schema pull\` to track schema changes.` };
423
+ if (!liveHash || !changes)
424
+ return { key: "schema", label, status: "skipped", detail: "Sign in to compare with the schema." };
425
+ if (changes.length === 0)
426
+ return { key: "schema", label, status: "passed", detail: `${SCHEMA_FILE} matches the schema (${pin.hash})` };
427
+ const breaking = changes.filter((c) => c.severity === "breaking").length;
428
+ const n = changes.length;
429
+ const detail = breaking > 0
430
+ ? `${n} change${n === 1 ? "" : "s"} since ${SCHEMA_FILE} (${breaking} breaking) — run \`mapled schema diff\`.`
431
+ : `${n} safe change${n === 1 ? "" : "s"} since ${SCHEMA_FILE} — run \`mapled schema diff\`.`;
432
+ return { key: "schema", label, status: "warning", detail };
433
+ }
434
+ export function checkManifestFile(local, remote) {
435
+ const label = "Site manifest";
436
+ if (!local) {
437
+ return { key: "manifest", label, status: "skipped", detail: `No ${MANIFEST_FILE} — run \`mapled scan --write\` to create one from the site's code.` };
438
+ }
439
+ const errors = local.problems.filter((p) => p.level === "error").length;
440
+ if (errors > 0) {
441
+ return { key: "manifest", label, status: "failed", detail: `${MANIFEST_FILE} has ${errors} problem${errors === 1 ? "" : "s"} — run \`mapled manifest validate\`.` };
442
+ }
443
+ if (remote === "unknown")
444
+ return { key: "manifest", label, status: "passed", detail: `${MANIFEST_FILE}: ${manifestSummary(local.manifest)}` };
445
+ if (remote === null)
446
+ return { key: "manifest", label, status: "warning", detail: `${MANIFEST_FILE} isn't pushed yet — run \`mapled bindings push\`.` };
447
+ const diff = compareManifests(local.manifest, remote.manifest);
448
+ if (diff.same) {
449
+ return { key: "manifest", label, status: "passed", detail: `${MANIFEST_FILE} is pushed as manifest v${remote.version} (${manifestSummary(local.manifest)})` };
450
+ }
451
+ const parts = [];
452
+ if (diff.onlyLocal.length > 0)
453
+ parts.push(`${diff.onlyLocal.length} not pushed`);
454
+ if (diff.changed.length > 0)
455
+ parts.push(`${diff.changed.length} changed`);
456
+ if (diff.onlyRemote.length > 0)
457
+ parts.push(`${diff.onlyRemote.length} only in Mapled`);
458
+ return {
459
+ key: "manifest",
460
+ label,
461
+ status: "warning",
462
+ detail: `${MANIFEST_FILE} differs from the pushed manifest (v${remote.version}): ${parts.join(", ")} — run \`mapled bindings push\`.`,
463
+ };
464
+ }
417
465
  export const REMOTE_CHECKS = [
418
466
  ["reads", "Site reads content"],
419
467
  ["webhook", "Publish webhook"],
package/dist/index.js CHANGED
@@ -3,11 +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 { doctor, generate, link, login, logout } 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, check the integration (§31, wave 1). */
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). */
11
13
  const HELP = `mapled — the Mapled CLI
12
14
 
13
15
  Usage
@@ -15,6 +17,13 @@ Usage
15
17
  mapled auth logout [--all] Revoke this machine's access to the linked project
16
18
  mapled project link [--project <id>] Write mapled.json for this repository
17
19
  mapled types generate [--out <file>] Generate TypeScript types for the content
20
+ mapled schema pull Record the schema this site is built against (mapled/schema.json)
21
+ mapled schema diff [--json] [--exit-code] Show what changed in Mapled since the last pull
22
+ mapled scan [--write] [--prune] [--json] Find where the code reads Mapled; --write updates mapled/manifest.json
23
+ mapled manifest validate [--json] Check mapled/manifest.json before pushing it
24
+ mapled bindings push [--dry-run] Push mapled/manifest.json so Mapled grades every binding
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
18
27
  mapled doctor [--json] Check the integration end to end
19
28
 
20
29
  Options
@@ -80,11 +89,34 @@ async function main(argv) {
80
89
  case "types generate":
81
90
  await generate(ctx, flags);
82
91
  return 0;
92
+ case "schema pull":
93
+ await schemaPull(ctx, flags);
94
+ return 0;
95
+ case "schema diff":
96
+ return schemaDiff(ctx, flags);
97
+ case "scan":
98
+ await scan(ctx, flags);
99
+ return 0;
100
+ case "manifest validate":
101
+ return manifestValidate(ctx, flags);
102
+ case "bindings push":
103
+ await bindingsPush(ctx, flags);
104
+ return 0;
105
+ case "bindings pull":
106
+ await bindingsPull(ctx, flags);
107
+ return 0;
108
+ case "md pull":
109
+ await mdPull(ctx, flags);
110
+ return 0;
83
111
  case "doctor":
84
112
  return doctor(ctx, flags);
85
113
  case "auth":
86
114
  case "project":
87
115
  case "types":
116
+ case "schema":
117
+ case "manifest":
118
+ case "bindings":
119
+ case "md":
88
120
  console.log(HELP);
89
121
  return 2;
90
122
  default:
@@ -0,0 +1,95 @@
1
+ import type { Schema } from "./schema.js";
2
+ /** mapled/manifest.json — the site manifest (§21): the pages of the site
3
+ and where each field is rendered (the bindings). The same shape the
4
+ API's POST /v1/agent/manifest accepts and the AI agent's
5
+ push_site_manifest tool sends; only these fields survive a push.
6
+ Everything in the file is content the site's authors wrote — checked
7
+ against the schema, never interpolated anywhere. */
8
+ export declare const MANIFEST_FILE = "mapled/manifest.json";
9
+ export declare const BINDING_TARGETS: readonly ["text", "rich_text", "image", "image_alt", "link", "number", "date", "boolean", "collection", "route_param", "form_field", "other"];
10
+ export type BindingTarget = (typeof BINDING_TARGETS)[number];
11
+ export type ManifestBinding = {
12
+ key: string;
13
+ page: string;
14
+ component?: string;
15
+ file?: string;
16
+ collection: string;
17
+ field?: string;
18
+ target: BindingTarget;
19
+ required?: boolean;
20
+ };
21
+ export type ManifestPage = {
22
+ route: string;
23
+ file?: string;
24
+ };
25
+ export type Manifest = {
26
+ framework?: string;
27
+ integrationMode?: string;
28
+ pages?: ManifestPage[];
29
+ bindings: ManifestBinding[];
30
+ notes?: string[];
31
+ };
32
+ export declare const KEY_PATTERN: RegExp;
33
+ export declare const LIMITS: {
34
+ bindings: number;
35
+ pages: number;
36
+ notes: number;
37
+ };
38
+ /** Field types a target renders without a transform — the API's table
39
+ (lib/bindings.ts), which grades each pushed binding the same way. */
40
+ export declare const COMPATIBLE: Partial<Record<BindingTarget, string[]>>;
41
+ /** The natural target of a field type — what `mapled scan` records when
42
+ the site reads the field. */
43
+ export declare function targetFor(fieldType: string | undefined): BindingTarget;
44
+ export declare const FIELD_TYPE_LABEL: Record<string, string>;
45
+ export declare function typeLabel(type: string): string;
46
+ export type Problem = {
47
+ level: "error" | "warning";
48
+ path: string;
49
+ message: string;
50
+ };
51
+ /** Parses the file's text into a manifest with only the known fields,
52
+ reporting every shape problem the API would refuse (as errors) and the
53
+ parts it would silently drop (as warnings). */
54
+ export declare function parseManifest(raw: string, file: string): {
55
+ manifest: Manifest;
56
+ problems: Problem[];
57
+ };
58
+ export type SchemaIndex = Map<string, {
59
+ displayName: string;
60
+ kind: string;
61
+ fields: Map<string, {
62
+ displayName: string;
63
+ type: string;
64
+ }>;
65
+ }>;
66
+ export declare function indexSchema(schema: Schema): SchemaIndex;
67
+ /** What a well-formed manifest still gets wrong: bindings the schema
68
+ can't back (Mapled would grade them outdated or mismatched), files the
69
+ repository doesn't have, pages the list doesn't know. Warnings only —
70
+ the API accepts all of it and the Bindings screen shows the health. */
71
+ export declare function checkManifest(manifest: Manifest, opts: {
72
+ schema: Schema | null;
73
+ fileExists: (rel: string) => Promise<boolean>;
74
+ }): Promise<Problem[]>;
75
+ export declare function fileExistsIn(dir: string): (rel: string) => Promise<boolean>;
76
+ export declare function readManifestFile(dir: string): Promise<{
77
+ file: string;
78
+ raw: string;
79
+ } | null>;
80
+ /** Writes the manifest with its keys in a stable order; bindings keep
81
+ the order they came in. */
82
+ export declare function writeManifestFile(dir: string, manifest: Manifest): Promise<string>;
83
+ export declare function orderManifest(manifest: Manifest): Manifest;
84
+ export declare function orderBinding(b: ManifestBinding): ManifestBinding;
85
+ export declare function plural(n: number, word: string, pluralWord?: string): string;
86
+ export declare function manifestSummary(manifest: Manifest): string;
87
+ /** Bindings that mean the same thing to Mapled (what a push stores). */
88
+ export declare function sameBinding(a: ManifestBinding, b: ManifestBinding): boolean;
89
+ export type ManifestComparison = {
90
+ same: boolean;
91
+ onlyLocal: string[];
92
+ onlyRemote: string[];
93
+ changed: string[];
94
+ };
95
+ export declare function compareManifests(local: Manifest, remote: Manifest): ManifestComparison;