@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 +17 -3
- package/dist/commands.d.ts +2 -1
- package/dist/commands.js +36 -1
- package/dist/doctor.d.ts +32 -0
- package/dist/doctor.js +68 -0
- package/dist/index.js +9 -3
- package/dist/integrationHash.d.ts +35 -0
- package/dist/integrationHash.js +26 -0
- package/dist/md.d.ts +62 -0
- package/dist/md.js +113 -0
- package/package.json +1 -1
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
|
|
189
|
+
Commit all five. Credentials never live in the repository.
|
package/dist/commands.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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