@mapled/cli 0.1.0 → 0.2.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/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,12 @@ 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, 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 and the site manifest in step
11
+ with the code, check the integration (§31, waves 1 and 2). */
11
12
  const HELP = `mapled — the Mapled CLI
12
13
 
13
14
  Usage
@@ -15,6 +16,12 @@ Usage
15
16
  mapled auth logout [--all] Revoke this machine's access to the linked project
16
17
  mapled project link [--project <id>] Write mapled.json for this repository
17
18
  mapled types generate [--out <file>] Generate TypeScript types for the content
19
+ mapled schema pull Record the schema this site is built against (mapled/schema.json)
20
+ mapled schema diff [--json] [--exit-code] Show what changed in Mapled since the last pull
21
+ mapled scan [--write] [--prune] [--json] Find where the code reads Mapled; --write updates mapled/manifest.json
22
+ mapled manifest validate [--json] Check mapled/manifest.json before pushing it
23
+ mapled bindings push [--dry-run] Push mapled/manifest.json so Mapled grades every binding
24
+ mapled bindings pull [--force] Write the pushed manifest into mapled/manifest.json
18
25
  mapled doctor [--json] Check the integration end to end
19
26
 
20
27
  Options
@@ -80,11 +87,30 @@ async function main(argv) {
80
87
  case "types generate":
81
88
  await generate(ctx, flags);
82
89
  return 0;
90
+ case "schema pull":
91
+ await schemaPull(ctx, flags);
92
+ return 0;
93
+ case "schema diff":
94
+ return schemaDiff(ctx, flags);
95
+ case "scan":
96
+ await scan(ctx, flags);
97
+ return 0;
98
+ case "manifest validate":
99
+ return manifestValidate(ctx, flags);
100
+ case "bindings push":
101
+ await bindingsPush(ctx, flags);
102
+ return 0;
103
+ case "bindings pull":
104
+ await bindingsPull(ctx, flags);
105
+ return 0;
83
106
  case "doctor":
84
107
  return doctor(ctx, flags);
85
108
  case "auth":
86
109
  case "project":
87
110
  case "types":
111
+ case "schema":
112
+ case "manifest":
113
+ case "bindings":
88
114
  console.log(HELP);
89
115
  return 2;
90
116
  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;
@@ -0,0 +1,374 @@
1
+ import { access, mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { CliError } from "./errors.js";
4
+ /** mapled/manifest.json — the site manifest (§21): the pages of the site
5
+ and where each field is rendered (the bindings). The same shape the
6
+ API's POST /v1/agent/manifest accepts and the AI agent's
7
+ push_site_manifest tool sends; only these fields survive a push.
8
+ Everything in the file is content the site's authors wrote — checked
9
+ against the schema, never interpolated anywhere. */
10
+ export const MANIFEST_FILE = "mapled/manifest.json";
11
+ export const BINDING_TARGETS = [
12
+ "text",
13
+ "rich_text",
14
+ "image",
15
+ "image_alt",
16
+ "link",
17
+ "number",
18
+ "date",
19
+ "boolean",
20
+ "collection",
21
+ "route_param",
22
+ "form_field",
23
+ "other",
24
+ ];
25
+ export const KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/\[\]-]{0,119}$/;
26
+ export const LIMITS = { bindings: 500, pages: 200, notes: 20 };
27
+ /** Field types a target renders without a transform — the API's table
28
+ (lib/bindings.ts), which grades each pushed binding the same way. */
29
+ export const COMPATIBLE = {
30
+ text: ["short_text", "long_text", "slug", "number", "date", "datetime", "enum", "url", "email", "color"],
31
+ rich_text: ["rich_text", "long_text"],
32
+ image: ["image"],
33
+ image_alt: ["short_text", "long_text"],
34
+ link: ["short_text", "slug", "url", "email", "file"],
35
+ number: ["number"],
36
+ date: ["date", "datetime"],
37
+ boolean: ["boolean"],
38
+ route_param: ["slug", "short_text"],
39
+ };
40
+ /** The natural target of a field type — what `mapled scan` records when
41
+ the site reads the field. */
42
+ export function targetFor(fieldType) {
43
+ switch (fieldType) {
44
+ case "rich_text":
45
+ return "rich_text";
46
+ case "image":
47
+ return "image";
48
+ case "url":
49
+ case "email":
50
+ case "file":
51
+ return "link";
52
+ case "number":
53
+ return "number";
54
+ case "date":
55
+ case "datetime":
56
+ return "date";
57
+ case "boolean":
58
+ return "boolean";
59
+ case "relation":
60
+ case "group":
61
+ case "json":
62
+ case "location":
63
+ return "other";
64
+ default:
65
+ return "text";
66
+ }
67
+ }
68
+ export const FIELD_TYPE_LABEL = {
69
+ short_text: "short text",
70
+ long_text: "long text",
71
+ rich_text: "rich text",
72
+ slug: "slug",
73
+ image: "image",
74
+ file: "file",
75
+ number: "number",
76
+ boolean: "boolean",
77
+ date: "date",
78
+ datetime: "date and time",
79
+ enum: "choice",
80
+ url: "URL",
81
+ email: "email",
82
+ color: "color",
83
+ relation: "relation",
84
+ group: "group",
85
+ json: "JSON",
86
+ location: "location",
87
+ };
88
+ export function typeLabel(type) {
89
+ return FIELD_TYPE_LABEL[type] ?? type;
90
+ }
91
+ const isObject = (v) => !!v && typeof v === "object" && !Array.isArray(v);
92
+ const isText = (v, max, min = 1) => typeof v === "string" && v.length >= min && v.length <= max;
93
+ const TOP_KEYS = new Set(["framework", "integrationMode", "pages", "bindings", "notes"]);
94
+ const BINDING_KEYS = new Set(["key", "page", "component", "file", "collection", "field", "target", "required"]);
95
+ /** Parses the file's text into a manifest with only the known fields,
96
+ reporting every shape problem the API would refuse (as errors) and the
97
+ parts it would silently drop (as warnings). */
98
+ export function parseManifest(raw, file) {
99
+ let data;
100
+ try {
101
+ data = JSON.parse(raw);
102
+ }
103
+ catch {
104
+ throw new CliError(`${file} isn't valid JSON.`);
105
+ }
106
+ const problems = [];
107
+ const error = (p, message) => problems.push({ level: "error", path: p, message });
108
+ const warn = (p, message) => problems.push({ level: "warning", path: p, message });
109
+ const manifest = { bindings: [] };
110
+ if (!isObject(data)) {
111
+ error("", `${file} must be a JSON object with a "bindings" list.`);
112
+ return { manifest, problems };
113
+ }
114
+ for (const key of Object.keys(data)) {
115
+ if (!TOP_KEYS.has(key))
116
+ warn(key, `"${key}" isn't part of the manifest and is dropped on push.`);
117
+ }
118
+ if (data.framework !== undefined) {
119
+ if (isText(data.framework, 40))
120
+ manifest.framework = data.framework;
121
+ else
122
+ error("framework", `"framework" must be a short name (up to 40 characters).`);
123
+ }
124
+ if (data.integrationMode !== undefined) {
125
+ if (isText(data.integrationMode, 40))
126
+ manifest.integrationMode = data.integrationMode;
127
+ else
128
+ error("integrationMode", `"integrationMode" must be a short name (up to 40 characters).`);
129
+ }
130
+ if (data.pages !== undefined) {
131
+ if (!Array.isArray(data.pages))
132
+ error("pages", `"pages" must be a list of { route, file }.`);
133
+ else {
134
+ if (data.pages.length > LIMITS.pages)
135
+ error("pages", `"pages" has ${data.pages.length} entries — the limit is ${LIMITS.pages}.`);
136
+ manifest.pages = [];
137
+ data.pages.forEach((p, i) => {
138
+ const at = `pages[${i}]`;
139
+ if (!isObject(p))
140
+ return error(at, `${at} must be an object with a "route".`);
141
+ if (!isText(p.route, 200))
142
+ return error(`${at}.route`, `${at}.route must be the page route, e.g. "/" or "/blog/[slug]".`);
143
+ const page = { route: p.route };
144
+ if (p.file !== undefined) {
145
+ if (isText(p.file, 300, 0))
146
+ page.file = p.file;
147
+ else
148
+ return error(`${at}.file`, `${at}.file must be a path (up to 300 characters).`);
149
+ }
150
+ manifest.pages.push(page);
151
+ });
152
+ }
153
+ }
154
+ if (!Array.isArray(data.bindings)) {
155
+ error("bindings", `"bindings" must be a list of bindings — { key, page, collection, field, target } each.`);
156
+ }
157
+ else {
158
+ if (data.bindings.length > LIMITS.bindings) {
159
+ error("bindings", `"bindings" has ${data.bindings.length} entries — the limit is ${LIMITS.bindings}.`);
160
+ }
161
+ const seen = new Map();
162
+ data.bindings.forEach((b, i) => {
163
+ const at = `bindings[${i}]`;
164
+ if (!isObject(b))
165
+ return error(at, `${at} must be an object.`);
166
+ for (const key of Object.keys(b)) {
167
+ if (!BINDING_KEYS.has(key))
168
+ warn(`${at}.${key}`, `${at}.${key} isn't part of a binding and is dropped on push.`);
169
+ }
170
+ let ok = true;
171
+ if (typeof b.key !== "string" || !KEY_PATTERN.test(b.key)) {
172
+ error(`${at}.key`, `${at}.key must be 1–120 characters — letters, digits, . _ : / [ ] and -, starting with a letter or digit.`);
173
+ ok = false;
174
+ }
175
+ else if (seen.has(b.key)) {
176
+ error(`${at}.key`, `${at}.key "${b.key}" is already used by bindings[${seen.get(b.key)}].`);
177
+ ok = false;
178
+ }
179
+ else
180
+ seen.set(b.key, i);
181
+ if (!isText(b.page, 200)) {
182
+ error(`${at}.page`, `${at}.page must name the page route, e.g. "/" or "/blog/[slug]".`);
183
+ ok = false;
184
+ }
185
+ if (!isText(b.collection, 120)) {
186
+ error(`${at}.collection`, `${at}.collection must be a collection key.`);
187
+ ok = false;
188
+ }
189
+ if (b.field !== undefined && !isText(b.field, 120)) {
190
+ error(`${at}.field`, `${at}.field must be a field key (up to 120 characters).`);
191
+ ok = false;
192
+ }
193
+ if (typeof b.target !== "string" || !BINDING_TARGETS.includes(b.target)) {
194
+ error(`${at}.target`, `${at}.target ${typeof b.target === "string" ? `"${b.target}" ` : ""}isn't one of ${BINDING_TARGETS.join(", ")}.`);
195
+ ok = false;
196
+ }
197
+ if (b.component !== undefined && !isText(b.component, 120, 0)) {
198
+ error(`${at}.component`, `${at}.component must be a name (up to 120 characters).`);
199
+ ok = false;
200
+ }
201
+ if (b.file !== undefined && !isText(b.file, 300, 0)) {
202
+ error(`${at}.file`, `${at}.file must be a path (up to 300 characters).`);
203
+ ok = false;
204
+ }
205
+ if (b.required !== undefined && typeof b.required !== "boolean") {
206
+ error(`${at}.required`, `${at}.required must be true or false.`);
207
+ ok = false;
208
+ }
209
+ if (!ok)
210
+ return;
211
+ const binding = {
212
+ key: b.key,
213
+ page: b.page,
214
+ collection: b.collection,
215
+ target: b.target,
216
+ };
217
+ if (typeof b.component === "string")
218
+ binding.component = b.component;
219
+ if (typeof b.file === "string")
220
+ binding.file = b.file;
221
+ if (typeof b.field === "string")
222
+ binding.field = b.field;
223
+ if (typeof b.required === "boolean")
224
+ binding.required = b.required;
225
+ manifest.bindings.push(binding);
226
+ });
227
+ }
228
+ if (data.notes !== undefined) {
229
+ if (!Array.isArray(data.notes) || data.notes.some((n) => !isText(n, 300, 0))) {
230
+ error("notes", `"notes" must be a list of short texts (up to 300 characters each).`);
231
+ }
232
+ else {
233
+ if (data.notes.length > LIMITS.notes)
234
+ error("notes", `"notes" has ${data.notes.length} entries — the limit is ${LIMITS.notes}.`);
235
+ manifest.notes = data.notes;
236
+ }
237
+ }
238
+ return { manifest, problems };
239
+ }
240
+ export function indexSchema(schema) {
241
+ const index = new Map();
242
+ for (const c of schema.collections) {
243
+ const fields = new Map();
244
+ for (const f of c.fields)
245
+ fields.set(f.key, { displayName: f.displayName, type: f.type });
246
+ index.set(c.key, { displayName: c.displayName, kind: c.kind, fields });
247
+ }
248
+ return index;
249
+ }
250
+ function article(word) {
251
+ return /^[aeiou]/i.test(word) ? "an" : "a";
252
+ }
253
+ /** What a well-formed manifest still gets wrong: bindings the schema
254
+ can't back (Mapled would grade them outdated or mismatched), files the
255
+ repository doesn't have, pages the list doesn't know. Warnings only —
256
+ the API accepts all of it and the Bindings screen shows the health. */
257
+ export async function checkManifest(manifest, opts) {
258
+ const problems = [];
259
+ const warn = (p, message) => problems.push({ level: "warning", path: p, message });
260
+ const index = opts.schema ? indexSchema(opts.schema) : null;
261
+ const routes = new Set((manifest.pages ?? []).map((p) => p.route));
262
+ for (const [i, p] of (manifest.pages ?? []).entries()) {
263
+ if (p.file && !(await opts.fileExists(p.file))) {
264
+ warn(`pages[${i}].file`, `pages[${i}] (${p.route}): ${p.file} doesn't exist in the repository.`);
265
+ }
266
+ }
267
+ for (const [i, b] of manifest.bindings.entries()) {
268
+ const at = `bindings[${i}]`;
269
+ const who = `${at} (${b.key})`;
270
+ if (index) {
271
+ const collection = index.get(b.collection);
272
+ if (!collection)
273
+ warn(`${at}.collection`, `${who}: no collection "${b.collection}" in the schema — Mapled marks it outdated.`);
274
+ else if (b.field) {
275
+ const field = collection.fields.get(b.field);
276
+ if (!field) {
277
+ warn(`${at}.field`, `${who}: no field "${b.field}" in "${collection.displayName}" — Mapled marks it outdated.`);
278
+ }
279
+ else {
280
+ const allowed = COMPATIBLE[b.target];
281
+ if (allowed && !allowed.includes(field.type)) {
282
+ const label = typeLabel(field.type);
283
+ warn(`${at}.target`, `${who}: "${field.displayName}" is ${article(label)} ${label} field, but the binding renders it as ${b.target.replace("_", " ")}.`);
284
+ }
285
+ }
286
+ }
287
+ }
288
+ if (b.file && !(await opts.fileExists(b.file)))
289
+ warn(`${at}.file`, `${who}: ${b.file} doesn't exist in the repository.`);
290
+ if (routes.size > 0 && !routes.has(b.page))
291
+ warn(`${at}.page`, `${who}: page "${b.page}" isn't listed in "pages".`);
292
+ if (b.target === "route_param" && !/\[[^\]]+\]/.test(b.page)) {
293
+ warn(`${at}.target`, `${who}: route_param on "${b.page}", which has no dynamic segment like [slug].`);
294
+ }
295
+ }
296
+ return problems;
297
+ }
298
+ export function fileExistsIn(dir) {
299
+ return async (rel) => {
300
+ const target = path.resolve(dir, rel);
301
+ if (path.relative(dir, target).startsWith(".."))
302
+ return false;
303
+ try {
304
+ await access(target);
305
+ return true;
306
+ }
307
+ catch {
308
+ return false;
309
+ }
310
+ };
311
+ }
312
+ export async function readManifestFile(dir) {
313
+ const file = path.join(dir, MANIFEST_FILE);
314
+ try {
315
+ return { file, raw: await readFile(file, "utf8") };
316
+ }
317
+ catch {
318
+ return null;
319
+ }
320
+ }
321
+ /** Writes the manifest with its keys in a stable order; bindings keep
322
+ the order they came in. */
323
+ export async function writeManifestFile(dir, manifest) {
324
+ const file = path.join(dir, MANIFEST_FILE);
325
+ await mkdir(path.dirname(file), { recursive: true });
326
+ await writeFile(file, JSON.stringify(orderManifest(manifest), null, 2) + "\n");
327
+ return file;
328
+ }
329
+ export function orderManifest(manifest) {
330
+ return {
331
+ ...(manifest.framework ? { framework: manifest.framework } : {}),
332
+ ...(manifest.integrationMode ? { integrationMode: manifest.integrationMode } : {}),
333
+ ...(manifest.pages ? { pages: manifest.pages.map((p) => ({ route: p.route, ...(p.file !== undefined ? { file: p.file } : {}) })) } : {}),
334
+ bindings: manifest.bindings.map(orderBinding),
335
+ ...(manifest.notes && manifest.notes.length > 0 ? { notes: [...manifest.notes] } : {}),
336
+ };
337
+ }
338
+ export function orderBinding(b) {
339
+ const out = { key: b.key, page: b.page, collection: b.collection, target: b.target };
340
+ if (b.field !== undefined)
341
+ out.field = b.field;
342
+ if (b.component !== undefined)
343
+ out.component = b.component;
344
+ if (b.file !== undefined)
345
+ out.file = b.file;
346
+ if (b.required !== undefined)
347
+ out.required = b.required;
348
+ return out;
349
+ }
350
+ export function plural(n, word, pluralWord = `${word}s`) {
351
+ return `${n} ${n === 1 ? word : pluralWord}`;
352
+ }
353
+ export function manifestSummary(manifest) {
354
+ const pages = new Set([...(manifest.pages ?? []).map((p) => p.route), ...manifest.bindings.map((b) => b.page)]);
355
+ return `${plural(manifest.bindings.length, "binding")} on ${plural(pages.size, "page")}`;
356
+ }
357
+ /** Bindings that mean the same thing to Mapled (what a push stores). */
358
+ export function sameBinding(a, b) {
359
+ return (a.page === b.page &&
360
+ a.collection === b.collection &&
361
+ (a.field ?? null) === (b.field ?? null) &&
362
+ a.target === b.target &&
363
+ (a.required ?? true) === (b.required ?? true) &&
364
+ (a.component ?? null) === (b.component ?? null) &&
365
+ (a.file ?? null) === (b.file ?? null));
366
+ }
367
+ export function compareManifests(local, remote) {
368
+ const remoteByKey = new Map(remote.bindings.map((b) => [b.key, b]));
369
+ const localByKey = new Map(local.bindings.map((b) => [b.key, b]));
370
+ const onlyLocal = local.bindings.filter((b) => !remoteByKey.has(b.key)).map((b) => b.key);
371
+ const onlyRemote = remote.bindings.filter((b) => !localByKey.has(b.key)).map((b) => b.key);
372
+ const changed = local.bindings.filter((b) => remoteByKey.has(b.key) && !sameBinding(b, remoteByKey.get(b.key))).map((b) => b.key);
373
+ return { same: onlyLocal.length === 0 && onlyRemote.length === 0 && changed.length === 0, onlyLocal, onlyRemote, changed };
374
+ }
package/dist/pin.d.ts ADDED
@@ -0,0 +1,41 @@
1
+ import { type Schema, type SchemaCollection } from "./schema.js";
2
+ /** mapled/schema.json — the repository's pin of the schema it was last
3
+ synced with (§21.5). `mapled schema diff` compares it with the live
4
+ schema and says which changes a site that READS the content must
5
+ care about; `schema pull` and `types generate` move the pin. The
6
+ file is deterministic: no timestamps, ids or counters. */
7
+ export declare const SCHEMA_FILE = "mapled/schema.json";
8
+ export type SchemaPin = {
9
+ project: string;
10
+ hash: string;
11
+ collections: SchemaCollection[];
12
+ };
13
+ /** The schema reduced to what the pin keeps — the live answer of
14
+ GET /v1/agent/schema without ids, positions and counters. */
15
+ export declare function pinSchema(schema: Schema, project: string): SchemaPin;
16
+ export declare function parsePin(raw: string, file: string): SchemaPin;
17
+ export declare function readPin(dir: string): Promise<{
18
+ file: string;
19
+ pin: SchemaPin;
20
+ } | null>;
21
+ export declare function writePin(dir: string, pin: SchemaPin): Promise<string>;
22
+ export type Severity = "breaking" | "safe";
23
+ export type ChangeOp = "added" | "removed" | "changed";
24
+ export type SchemaChange = {
25
+ scope: "collection" | "field" | "subfield";
26
+ collection: string;
27
+ collectionName: string;
28
+ /** The field key; "group.sub" for a sub-field. */
29
+ field?: string;
30
+ op: ChangeOp;
31
+ detail: string;
32
+ severity: Severity;
33
+ };
34
+ /** What changed between two schemas, judged for a site that reads the
35
+ content: anything that can make a read fail or a value disappear is
36
+ breaking; additions and renames of labels are safe. */
37
+ export declare function diffSchema(before: SchemaCollection[], after: SchemaCollection[]): SchemaChange[];
38
+ /** The diff as terminal lines: one heading per collection, one line per
39
+ change, the severity in the last column. */
40
+ export declare function formatChanges(changes: SchemaChange[], paint?: (severity: Severity, text: string) => string): string[];
41
+ export declare function summarizeChanges(changes: SchemaChange[]): string;