@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/README.md +91 -11
- package/dist/api.d.ts +4 -0
- package/dist/api.js +22 -5
- package/dist/commands.d.ts +9 -3
- package/dist/commands.js +342 -5
- package/dist/doctor.d.ts +14 -0
- package/dist/doctor.js +48 -0
- package/dist/index.js +28 -2
- package/dist/manifest.d.ts +95 -0
- package/dist/manifest.js +374 -0
- package/dist/pin.d.ts +41 -0
- package/dist/pin.js +297 -0
- package/dist/scan.d.ts +73 -0
- package/dist/scan.js +1295 -0
- package/dist/schema.d.ts +3 -0
- package/dist/types.js +2 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
# @mapled/cli
|
|
2
2
|
|
|
3
|
-
The `mapled` command line for [Mapled](https://mapled.io) — a hosted headless CMS built for sites created with AI. Sign in to a project, link the repository to it, generate TypeScript types for the content your site reads, and check the whole integration in one go.
|
|
3
|
+
The `mapled` command line for [Mapled](https://mapled.io) — a hosted headless CMS built for sites created with AI. Sign in to a project, link the repository to it, generate TypeScript types for the content your site reads, keep a pin of the schema and a manifest of where the code reads it, and check the whole integration in one go.
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
6
|
npx @mapled/cli auth login
|
|
7
7
|
npx @mapled/cli project link
|
|
8
8
|
npx @mapled/cli types generate
|
|
9
|
+
npx @mapled/cli scan --write
|
|
10
|
+
npx @mapled/cli bindings push
|
|
9
11
|
npx @mapled/cli doctor
|
|
10
12
|
```
|
|
11
13
|
|
|
@@ -45,18 +47,82 @@ Only public values live here — the project id, where the generated types go, t
|
|
|
45
47
|
npx @mapled/cli types generate
|
|
46
48
|
```
|
|
47
49
|
|
|
48
|
-
Reads the project's schema and writes `mapled-types.ts` (the path from `mapled.json`, or `--out <file>`): one interface per collection and single, keyed maps, and doc comments with the field types. Sensitive fields never reach the site and are left out.
|
|
50
|
+
Reads the project's schema and writes `mapled-types.ts` (the path from `mapled.json`, or `--out <file>`): one interface per collection and single, keyed maps, a `MapledSchema` that ties them together, and doc comments with the field types. Sensitive fields never reach the site and are left out.
|
|
49
51
|
|
|
50
52
|
```ts
|
|
51
|
-
import type {
|
|
53
|
+
import type { MapledSchema } from "./mapled-types";
|
|
52
54
|
import { createClient } from "@mapled/next";
|
|
53
55
|
|
|
54
|
-
const mapled = createClient({ key: process.env.MAPLED_KEY! });
|
|
55
|
-
const { records } = await mapled.getRecords
|
|
56
|
-
const home = await mapled.getSingle
|
|
56
|
+
const mapled = createClient<MapledSchema>({ key: process.env.MAPLED_KEY! });
|
|
57
|
+
const { records } = await mapled.getRecords("articles"); // records[0].data is an Article
|
|
58
|
+
const home = await mapled.getSingle("homepage"); // Homepage | null
|
|
57
59
|
```
|
|
58
60
|
|
|
59
|
-
The file names the schema it came from; `mapled doctor` tells you when it is out of date. Run `types generate` again after the schema changes.
|
|
61
|
+
The file names the schema it came from; `mapled doctor` tells you when it is out of date. Run `types generate` again after the schema changes — it refreshes `mapled/schema.json` too when you keep one.
|
|
62
|
+
|
|
63
|
+
## Pin the schema and see what changed
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
npx @mapled/cli schema pull
|
|
67
|
+
npx @mapled/cli schema diff
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`schema pull` writes `mapled/schema.json` — the schema this site is built against, without ids or timestamps, so it diffs cleanly in git. Commit it. When editors or agents change the structure in Mapled, `schema diff` compares the pin with the live schema and judges every change **for a site that reads the content**:
|
|
71
|
+
|
|
72
|
+
```text
|
|
73
|
+
Schema changes since mapled/schema.json (3f9a1c2b4d5e → 8c1d2e3f4a5b):
|
|
74
|
+
|
|
75
|
+
Articles (articles)
|
|
76
|
+
+ subtitle added — short text, optional safe
|
|
77
|
+
- legacy-id removed — number breaking
|
|
78
|
+
~ author short text → relation to Authors breaking
|
|
79
|
+
~ title required → optional (may be empty now) breaking
|
|
80
|
+
~ tags option “Legacy” removed breaking
|
|
81
|
+
+ Authors (authors) new collection — 3 fields safe
|
|
82
|
+
|
|
83
|
+
4 breaking changes, 2 safe changes, judged for a site that reads the content. Update the site where needed, then run `mapled types generate` (it refreshes mapled/schema.json as well).
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Breaking: a collection, field or option the site may rely on disappears, a type changes, a required field becomes optional, a field becomes sensitive (it stops being delivered), a relation points elsewhere. Safe: additions, renamed labels, help texts, validation rules. `--json` for scripts and agents; `--exit-code` exits 1 when anything changed, for CI.
|
|
87
|
+
|
|
88
|
+
## Scan the code into a manifest
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
npx @mapled/cli scan
|
|
92
|
+
npx @mapled/cli scan --write
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
The scanner reads the site's source and writes down what it reads from Mapled: the pages (from the framework's file layout — Next.js App and Pages Router, Astro, SvelteKit, Nuxt, Remix) and one binding per place a collection or field is used.
|
|
96
|
+
|
|
97
|
+
```text
|
|
98
|
+
Scanned 42 files with TypeScript — 3 pages, 14 bindings.
|
|
99
|
+
|
|
100
|
+
/ app/page.tsx
|
|
101
|
+
homepage headline, cover, alt
|
|
102
|
+
articles list • title, date, slug
|
|
103
|
+
/blog/[slug] app/blog/[slug]/page.tsx
|
|
104
|
+
articles by slug • title, body
|
|
105
|
+
|
|
106
|
+
No mapled/manifest.json yet — run `mapled scan --write` to create it, then `mapled bindings push`.
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
It follows `getRecords` / `getRecord` / `getRecordBySlug` / `getSingle` calls through variables, destructuring, `.map()` callbacks and into the components a record is passed to as a prop, and through helpers in `lib/` that return a read. The parser is your repository's own TypeScript; without it the scanner still finds the collections, but not the fields. Source code never leaves your machine — only the manifest does, when you push it.
|
|
110
|
+
|
|
111
|
+
`--write` creates or updates `mapled/manifest.json`. Bindings the scan can't see (written by your AI agent, or by hand) are kept and listed; `--prune` drops them. Commit the file.
|
|
112
|
+
|
|
113
|
+
## Validate and push the manifest
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
npx @mapled/cli manifest validate
|
|
117
|
+
npx @mapled/cli bindings push
|
|
118
|
+
npx @mapled/cli bindings pull
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
`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
|
+
`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
|
+
|
|
125
|
+
`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`.
|
|
60
126
|
|
|
61
127
|
## Check the integration
|
|
62
128
|
|
|
@@ -70,21 +136,23 @@ Mapled doctor — Dolphin Landing
|
|
|
70
136
|
✓ Project link mapled.json → Dolphin Landing
|
|
71
137
|
✓ Signed in Mapled CLI • Dolphin Landing
|
|
72
138
|
✓ Generated types mapled-types.ts matches the schema (3f9a1c2b4d5e)
|
|
139
|
+
✓ Schema pin mapled/schema.json matches the schema (3f9a1c2b4d5e)
|
|
140
|
+
✓ Site manifest mapled/manifest.json is pushed as manifest v3 (14 bindings on 3 pages)
|
|
73
141
|
✓ Environment MAPLED_KEY and MAPLED_WEBHOOK_SECRET in .env.local
|
|
74
142
|
✓ Secrets in git No env files or Mapled secrets are tracked.
|
|
75
|
-
✓ @mapled/next 0.
|
|
76
|
-
✓ mapled CLI 0.
|
|
143
|
+
✓ @mapled/next 0.6.0 (current)
|
|
144
|
+
✓ mapled CLI 0.2.0 (current)
|
|
77
145
|
✓ Revalidation route app/api/mapled/revalidate/route.ts
|
|
78
146
|
⚠ Preview route app/api/mapled/preview/route.ts is missing — mount createPreviewHandler from "@mapled/next/server" so Preview from Mapled works.
|
|
79
147
|
✓ Site reads content Last read 3m ago
|
|
80
148
|
✓ Publish webhook Delivered 2h ago to dolphin-landing.example
|
|
81
149
|
✓ Preview on the site Responds at https://dolphin-landing.example/api/mapled/preview
|
|
82
|
-
✓ Bindings
|
|
150
|
+
✓ Bindings 14 healthy • synced 2d ago from Mapled CLI
|
|
83
151
|
|
|
84
152
|
1 warning.
|
|
85
153
|
```
|
|
86
154
|
|
|
87
|
-
The repository side: the link, the sign-in, the generated types, `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.
|
|
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.
|
|
88
156
|
|
|
89
157
|
Exit code 1 when something is marked ✗; `--json` prints the checks for scripts and agents.
|
|
90
158
|
|
|
@@ -92,4 +160,16 @@ Exit code 1 when something is marked ✗; `--json` prints the checks for scripts
|
|
|
92
160
|
|
|
93
161
|
- `--api <origin>` — a Mapled API other than `https://api.mapled.io` (or set `MAPLED_API_URL`); `project link` remembers it in `mapled.json`
|
|
94
162
|
- `--no-browser` — print the sign-in link instead of opening a browser
|
|
163
|
+
- `--json` — machine-readable output (`doctor`, `schema diff`, `scan`, `manifest validate`)
|
|
95
164
|
- `MAPLED_CONFIG_DIR` — where credentials live (default: `$XDG_CONFIG_HOME/mapled` or `~/.config/mapled`)
|
|
165
|
+
|
|
166
|
+
## Files in the repository
|
|
167
|
+
|
|
168
|
+
| File | Written by | Purpose |
|
|
169
|
+
|---|---|---|
|
|
170
|
+
| `mapled.json` | `project link` | The project id, the types path, the framework — public values only |
|
|
171
|
+
| `mapled-types.ts` | `types generate` | TypeScript types of the published content, with `MapledSchema` |
|
|
172
|
+
| `mapled/schema.json` | `schema pull`, `types generate` | The schema this site is built against — `schema diff` starts from it |
|
|
173
|
+
| `mapled/manifest.json` | `scan --write`, `bindings pull`, your agent | Pages and bindings — where the code reads each collection and field |
|
|
174
|
+
|
|
175
|
+
Commit all four. Credentials never live in the repository.
|
package/dist/api.d.ts
CHANGED
|
@@ -6,6 +6,8 @@ export declare class ApiError extends Error {
|
|
|
6
6
|
code: string | null;
|
|
7
7
|
constructor(status: number, code: string | null, message: string);
|
|
8
8
|
}
|
|
9
|
+
export type Method = "GET" | "POST" | "PATCH" | "DELETE";
|
|
10
|
+
export declare function apiRequest<T>(api: string, method: Method, path: string, token: string, body: unknown, f: Fetch): Promise<T>;
|
|
9
11
|
export declare function apiGet<T>(api: string, path: string, token: string, f: Fetch): Promise<T>;
|
|
10
12
|
/** A signed-in connection with its store: reads refresh the access
|
|
11
13
|
token when it is about to expire or when the API says it did, and
|
|
@@ -17,5 +19,7 @@ export declare class Session {
|
|
|
17
19
|
private readonly f;
|
|
18
20
|
constructor(store: CredentialsFile, file: string, conn: Connection, f: Fetch);
|
|
19
21
|
get<T>(path: string): Promise<T>;
|
|
22
|
+
send<T>(method: Method, path: string, body: unknown): Promise<T>;
|
|
23
|
+
private request;
|
|
20
24
|
private refresh;
|
|
21
25
|
}
|
package/dist/api.js
CHANGED
|
@@ -12,10 +12,18 @@ export class ApiError extends Error {
|
|
|
12
12
|
this.code = code;
|
|
13
13
|
}
|
|
14
14
|
}
|
|
15
|
-
export async function
|
|
15
|
+
export async function apiRequest(api, method, path, token, body, f) {
|
|
16
16
|
let res;
|
|
17
17
|
try {
|
|
18
|
-
res = await f(`${api}${path}`, {
|
|
18
|
+
res = await f(`${api}${path}`, {
|
|
19
|
+
method,
|
|
20
|
+
headers: {
|
|
21
|
+
authorization: `Bearer ${token}`,
|
|
22
|
+
accept: "application/json",
|
|
23
|
+
...(body !== undefined ? { "content-type": "application/json" } : {}),
|
|
24
|
+
},
|
|
25
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
26
|
+
});
|
|
19
27
|
}
|
|
20
28
|
catch {
|
|
21
29
|
throw new CliError(`Couldn't reach ${api}. Check your connection and try again.`);
|
|
@@ -26,6 +34,9 @@ export async function apiGet(api, path, token, f) {
|
|
|
26
34
|
}
|
|
27
35
|
return data;
|
|
28
36
|
}
|
|
37
|
+
export function apiGet(api, path, token, f) {
|
|
38
|
+
return apiRequest(api, "GET", path, token, undefined, f);
|
|
39
|
+
}
|
|
29
40
|
/** A signed-in connection with its store: reads refresh the access
|
|
30
41
|
token when it is about to expire or when the API says it did, and
|
|
31
42
|
the rotated pair is written back before the call returns. */
|
|
@@ -40,17 +51,23 @@ export class Session {
|
|
|
40
51
|
this.conn = conn;
|
|
41
52
|
this.f = f;
|
|
42
53
|
}
|
|
43
|
-
|
|
54
|
+
get(path) {
|
|
55
|
+
return this.request("GET", path);
|
|
56
|
+
}
|
|
57
|
+
send(method, path, body) {
|
|
58
|
+
return this.request(method, path, body);
|
|
59
|
+
}
|
|
60
|
+
async request(method, path, body) {
|
|
44
61
|
if (new Date(this.conn.expiresAt).getTime() - Date.now() < 60_000)
|
|
45
62
|
await this.refresh();
|
|
46
63
|
try {
|
|
47
|
-
return await
|
|
64
|
+
return await apiRequest(this.conn.api, method, path, this.conn.accessToken, body, this.f);
|
|
48
65
|
}
|
|
49
66
|
catch (err) {
|
|
50
67
|
if (err instanceof ApiError && err.status === 401) {
|
|
51
68
|
if (err.code === "TOKEN_EXPIRED") {
|
|
52
69
|
await this.refresh();
|
|
53
|
-
return
|
|
70
|
+
return apiRequest(this.conn.api, method, path, this.conn.accessToken, body, this.f);
|
|
54
71
|
}
|
|
55
72
|
throw new CliError(`The connection to ${this.conn.projectName} was revoked. Run \`mapled auth login\` to sign in again.`);
|
|
56
73
|
}
|
package/dist/commands.d.ts
CHANGED
|
@@ -2,9 +2,9 @@ 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
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
/** The commands of waves 1 and 2 (§31). Everything that talks to the
|
|
6
|
+
world comes in through `Ctx`, so the commands run in tests against a
|
|
7
|
+
fake API and a temp directory. */
|
|
8
8
|
export type Ctx = {
|
|
9
9
|
cwd: string;
|
|
10
10
|
env: NodeJS.ProcessEnv;
|
|
@@ -23,5 +23,11 @@ export declare function login(ctx: Ctx, flags: Flags): Promise<Connection>;
|
|
|
23
23
|
export declare function logout(ctx: Ctx, flags: Flags): Promise<void>;
|
|
24
24
|
export declare function link(ctx: Ctx, flags: Flags): Promise<FoundConfig>;
|
|
25
25
|
export declare function generate(ctx: Ctx, flags: Flags): Promise<void>;
|
|
26
|
+
export declare function schemaPull(ctx: Ctx, flags: Flags): Promise<void>;
|
|
27
|
+
export declare function schemaDiff(ctx: Ctx, flags: Flags): Promise<number>;
|
|
28
|
+
export declare function manifestValidate(ctx: Ctx, flags: Flags): Promise<number>;
|
|
29
|
+
export declare function bindingsPush(ctx: Ctx, flags: Flags): Promise<void>;
|
|
30
|
+
export declare function bindingsPull(ctx: Ctx, flags: Flags): Promise<void>;
|
|
31
|
+
export declare function scan(ctx: Ctx, flags: Flags): Promise<void>;
|
|
26
32
|
export declare function doctor(ctx: Ctx, flags: Flags): Promise<number>;
|
|
27
33
|
export {};
|
package/dist/commands.js
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
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 { loadTypeScript, mergeManifest, scanRepository } from "./scan.js";
|
|
11
14
|
import { generateTypes } from "./types.js";
|
|
12
|
-
function plural(n, word) {
|
|
13
|
-
return `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
14
|
-
}
|
|
15
15
|
/** The OAuth client id for this API — registered once per machine, and
|
|
16
16
|
again when the server no longer knows it. */
|
|
17
17
|
async function ensureClient(store, api, f) {
|
|
@@ -147,6 +147,328 @@ export async function generate(ctx, flags) {
|
|
|
147
147
|
await writeFile(file, generated.text);
|
|
148
148
|
ctx.out(`Wrote ${path.relative(ctx.cwd, file) || out} — ${plural(generated.collections, "collection")}, ` +
|
|
149
149
|
`${plural(generated.singles, "single")} (schema ${generated.hash}).`);
|
|
150
|
+
// the pin moves with the types, so `schema diff` starts from what the site was built against
|
|
151
|
+
const existing = await readPin(found.dir);
|
|
152
|
+
if (existing && existing.pin.hash !== generated.hash) {
|
|
153
|
+
await writePin(found.dir, pinSchema(schema, found.config.project));
|
|
154
|
+
ctx.out(`Refreshed ${SCHEMA_FILE} (schema ${existing.pin.hash} → ${generated.hash}).`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/* ---- wave 2: the schema pin ---- */
|
|
158
|
+
function paintSeverity(color) {
|
|
159
|
+
return (severity, text) => paint(severity === "breaking" ? "failed" : "skipped", text, color);
|
|
160
|
+
}
|
|
161
|
+
export async function schemaPull(ctx, flags) {
|
|
162
|
+
const { found, session } = await openSession(ctx, flags);
|
|
163
|
+
const live = await session.get("/v1/agent/schema");
|
|
164
|
+
const pin = pinSchema(live, found.config.project);
|
|
165
|
+
const existing = await readPin(found.dir);
|
|
166
|
+
const collections = pin.collections.filter((c) => c.kind === "collection").length;
|
|
167
|
+
const singles = pin.collections.length - collections;
|
|
168
|
+
if (!existing) {
|
|
169
|
+
await writePin(found.dir, pin);
|
|
170
|
+
ctx.out(`Wrote ${SCHEMA_FILE} — ${plural(collections, "collection")}, ${plural(singles, "single")} (schema ${pin.hash}).`);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (existing.pin.hash === pin.hash) {
|
|
174
|
+
ctx.out(`${SCHEMA_FILE} is up to date (schema ${pin.hash}).`);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
const changes = diffSchema(existing.pin.collections, pin.collections);
|
|
178
|
+
await writePin(found.dir, pin);
|
|
179
|
+
ctx.out(`Updated ${SCHEMA_FILE} (schema ${existing.pin.hash} → ${pin.hash}): ${summarizeChanges(changes).toLowerCase()}.`);
|
|
180
|
+
ctx.out("");
|
|
181
|
+
for (const line of formatChanges(changes, paintSeverity(useColor(ctx.env))))
|
|
182
|
+
ctx.out(line);
|
|
183
|
+
const typesPath = found.config.types ?? DEFAULT_TYPES_PATH;
|
|
184
|
+
const types = await readFile(path.join(found.dir, typesPath), "utf8").catch(() => null);
|
|
185
|
+
if (types !== null) {
|
|
186
|
+
ctx.out("");
|
|
187
|
+
ctx.out(`Next: \`mapled types generate\` — ${typesPath} still describes the previous schema.`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
export async function schemaDiff(ctx, flags) {
|
|
191
|
+
const { found, session } = await openSession(ctx, flags);
|
|
192
|
+
const existing = await readPin(found.dir);
|
|
193
|
+
if (!existing) {
|
|
194
|
+
throw new CliError(`No ${SCHEMA_FILE} yet. Run \`mapled schema pull\` to record the schema this site is built against.`);
|
|
195
|
+
}
|
|
196
|
+
const live = pinSchema(await session.get("/v1/agent/schema"), found.config.project);
|
|
197
|
+
const changes = diffSchema(existing.pin.collections, live.collections);
|
|
198
|
+
if (flags.json) {
|
|
199
|
+
ctx.out(JSON.stringify({ from: existing.pin.hash, to: live.hash, changes }, null, 2));
|
|
200
|
+
}
|
|
201
|
+
else if (changes.length === 0) {
|
|
202
|
+
ctx.out(`No schema changes since ${SCHEMA_FILE} (${existing.pin.hash}).`);
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
ctx.out(`Schema changes since ${SCHEMA_FILE} (${existing.pin.hash} → ${live.hash}):`);
|
|
206
|
+
ctx.out("");
|
|
207
|
+
for (const line of formatChanges(changes, paintSeverity(useColor(ctx.env))))
|
|
208
|
+
ctx.out(line);
|
|
209
|
+
ctx.out("");
|
|
210
|
+
const typesPath = found.config.types ?? DEFAULT_TYPES_PATH;
|
|
211
|
+
const types = await readFile(path.join(found.dir, typesPath), "utf8").catch(() => null);
|
|
212
|
+
const next = types !== null ? `\`mapled types generate\` (it refreshes ${SCHEMA_FILE} as well)` : `\`mapled schema pull\``;
|
|
213
|
+
ctx.out(`${summarizeChanges(changes)}, judged for a site that reads the content. Update the site where needed, then run ${next}.`);
|
|
214
|
+
}
|
|
215
|
+
return flags["exit-code"] && changes.length > 0 ? 1 : 0;
|
|
216
|
+
}
|
|
217
|
+
async function loadManifest(dir) {
|
|
218
|
+
const found = await readManifestFile(dir);
|
|
219
|
+
if (!found)
|
|
220
|
+
return null;
|
|
221
|
+
const parsed = parseManifest(found.raw, MANIFEST_FILE);
|
|
222
|
+
return { file: found.file, ...parsed };
|
|
223
|
+
}
|
|
224
|
+
/** The schema to check bindings against: the pin when the repository
|
|
225
|
+
keeps one, otherwise the live schema of a signed-in project. */
|
|
226
|
+
async function schemaForChecks(ctx, flags, found) {
|
|
227
|
+
const pin = await readPin(found.dir);
|
|
228
|
+
if (pin)
|
|
229
|
+
return { schema: { collections: pin.pin.collections }, note: null };
|
|
230
|
+
try {
|
|
231
|
+
const { session } = await openSession(ctx, flags);
|
|
232
|
+
return { schema: await session.get("/v1/agent/schema"), note: null };
|
|
233
|
+
}
|
|
234
|
+
catch (err) {
|
|
235
|
+
return {
|
|
236
|
+
schema: null,
|
|
237
|
+
note: `Bindings weren't checked against the schema — ${err instanceof Error ? err.message.replace(/\.$/, "") : "sign in"}, or run \`mapled schema pull\`.`,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
function formatProblems(problems, color) {
|
|
242
|
+
return problems.map((p) => `${paint(p.level === "error" ? "failed" : "warning", GLYPH[p.level === "error" ? "failed" : "warning"], color)} ${p.message}`);
|
|
243
|
+
}
|
|
244
|
+
function problemSummary(problems) {
|
|
245
|
+
const errors = problems.filter((p) => p.level === "error").length;
|
|
246
|
+
const warnings = problems.length - errors;
|
|
247
|
+
const parts = [];
|
|
248
|
+
if (errors > 0)
|
|
249
|
+
parts.push(plural(errors, "problem"));
|
|
250
|
+
if (warnings > 0)
|
|
251
|
+
parts.push(plural(warnings, "warning"));
|
|
252
|
+
return { errors, warnings, text: parts.join(", ") };
|
|
253
|
+
}
|
|
254
|
+
export async function manifestValidate(ctx, flags) {
|
|
255
|
+
const found = await findConfig(ctx.cwd);
|
|
256
|
+
const dir = found?.dir ?? path.resolve(ctx.cwd);
|
|
257
|
+
const loaded = await loadManifest(dir);
|
|
258
|
+
if (!loaded) {
|
|
259
|
+
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.`);
|
|
260
|
+
}
|
|
261
|
+
const notes = [];
|
|
262
|
+
let schema = null;
|
|
263
|
+
if (found) {
|
|
264
|
+
const got = await schemaForChecks(ctx, flags, found);
|
|
265
|
+
schema = got.schema;
|
|
266
|
+
if (got.note)
|
|
267
|
+
notes.push(got.note);
|
|
268
|
+
}
|
|
269
|
+
else {
|
|
270
|
+
notes.push(`Bindings weren't checked against the schema — no ${CONFIG_FILE} here; run \`mapled project link\`.`);
|
|
271
|
+
}
|
|
272
|
+
const problems = [...loaded.problems, ...(await checkManifest(loaded.manifest, { schema, fileExists: fileExistsIn(dir) }))];
|
|
273
|
+
problems.sort((a, b) => (a.level === b.level ? 0 : a.level === "error" ? -1 : 1));
|
|
274
|
+
const summary = problemSummary(problems);
|
|
275
|
+
if (flags.json) {
|
|
276
|
+
ctx.out(JSON.stringify({ file: MANIFEST_FILE, bindings: loaded.manifest.bindings.length, pages: loaded.manifest.pages?.length ?? 0, problems, notes }, null, 2));
|
|
277
|
+
}
|
|
278
|
+
else {
|
|
279
|
+
if (problems.length > 0) {
|
|
280
|
+
for (const line of formatProblems(problems, useColor(ctx.env)))
|
|
281
|
+
ctx.out(line);
|
|
282
|
+
ctx.out("");
|
|
283
|
+
}
|
|
284
|
+
for (const note of notes)
|
|
285
|
+
ctx.out(note);
|
|
286
|
+
ctx.out(`${MANIFEST_FILE}: ${manifestSummary(loaded.manifest)} — ${problems.length === 0 ? "valid" : summary.text}.`);
|
|
287
|
+
if (summary.errors > 0)
|
|
288
|
+
ctx.out("Fix the problems before `mapled bindings push`.");
|
|
289
|
+
}
|
|
290
|
+
return summary.errors > 0 ? 1 : 0;
|
|
291
|
+
}
|
|
292
|
+
export async function bindingsPush(ctx, flags) {
|
|
293
|
+
const { found, session } = await openSession(ctx, flags);
|
|
294
|
+
const loaded = await loadManifest(found.dir);
|
|
295
|
+
if (!loaded)
|
|
296
|
+
throw new CliError(`No ${MANIFEST_FILE} here. Run \`mapled scan --write\` to create one from the site's code.`);
|
|
297
|
+
const schema = await session.get("/v1/agent/schema");
|
|
298
|
+
const problems = [...loaded.problems, ...(await checkManifest(loaded.manifest, { schema, fileExists: fileExistsIn(found.dir) }))];
|
|
299
|
+
const summary = problemSummary(problems);
|
|
300
|
+
if (summary.errors > 0) {
|
|
301
|
+
throw new CliError(`${MANIFEST_FILE} has ${plural(summary.errors, "problem")} — run \`mapled manifest validate\` and fix them first.`);
|
|
302
|
+
}
|
|
303
|
+
const color = useColor(ctx.env);
|
|
304
|
+
if (problems.length > 0)
|
|
305
|
+
for (const line of formatProblems(problems, color))
|
|
306
|
+
ctx.out(line);
|
|
307
|
+
const body = orderManifest(loaded.manifest);
|
|
308
|
+
if (flags["dry-run"]) {
|
|
309
|
+
ctx.out(`Would push ${MANIFEST_FILE} to ${session.conn.projectName} — ${manifestSummary(body)}${summary.warnings > 0 ? `, ${plural(summary.warnings, "warning")}` : ""}. Nothing was sent.`);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
let result;
|
|
313
|
+
try {
|
|
314
|
+
result = await session.send("POST", "/v1/agent/manifest", body);
|
|
315
|
+
}
|
|
316
|
+
catch (err) {
|
|
317
|
+
if (err instanceof ApiError && err.code === "BUILDER_PLAN_REQUIRED") {
|
|
318
|
+
throw new CliError("Pushing bindings changes the project's structure, which needs a builder plan — ask the project owner to upgrade.");
|
|
319
|
+
}
|
|
320
|
+
throw err;
|
|
321
|
+
}
|
|
322
|
+
const s = result.summary;
|
|
323
|
+
const graded = [`${s.healthy ?? 0} healthy`];
|
|
324
|
+
if (s.type_mismatch)
|
|
325
|
+
graded.push(`${s.type_mismatch} type mismatch${s.type_mismatch === 1 ? "" : "es"}`);
|
|
326
|
+
if (s.outdated)
|
|
327
|
+
graded.push(`${s.outdated} outdated`);
|
|
328
|
+
if (s.not_checked)
|
|
329
|
+
graded.push(`${s.not_checked} not checked`);
|
|
330
|
+
if (s.missing_on_site)
|
|
331
|
+
graded.push(`${s.missing_on_site} missing on site`);
|
|
332
|
+
if (s.disabled)
|
|
333
|
+
graded.push(`${s.disabled} disabled`);
|
|
334
|
+
ctx.out(`Pushed ${MANIFEST_FILE} to ${session.conn.projectName} — manifest v${result.manifest.version}, ${plural(body.bindings.length, "binding")}: ${graded.join(", ")}.`);
|
|
335
|
+
for (const w of result.warnings)
|
|
336
|
+
ctx.out(`${paint("warning", GLYPH.warning, color)} ${w}`);
|
|
337
|
+
if ((s.type_mismatch ?? 0) + (s.outdated ?? 0) + (s.missing_on_site ?? 0) > 0 || result.warnings.length > 0) {
|
|
338
|
+
ctx.out("Open Structure → Bindings in Mapled to review them.");
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
export async function bindingsPull(ctx, flags) {
|
|
342
|
+
const { found, session } = await openSession(ctx, flags);
|
|
343
|
+
let stored;
|
|
344
|
+
try {
|
|
345
|
+
stored = await session.get("/v1/agent/manifest");
|
|
346
|
+
}
|
|
347
|
+
catch (err) {
|
|
348
|
+
if (err instanceof ApiError && err.status === 404) {
|
|
349
|
+
throw new CliError(`Nothing to pull — no manifest has been pushed to ${session.conn.projectName} yet.`);
|
|
350
|
+
}
|
|
351
|
+
throw err;
|
|
352
|
+
}
|
|
353
|
+
const local = await loadManifest(found.dir);
|
|
354
|
+
if (local && !flags.force) {
|
|
355
|
+
const diff = compareManifests(local.manifest, stored.manifest);
|
|
356
|
+
if (!diff.same) {
|
|
357
|
+
const parts = [];
|
|
358
|
+
if (diff.onlyLocal.length > 0)
|
|
359
|
+
parts.push(`${diff.onlyLocal.length} not pushed`);
|
|
360
|
+
if (diff.changed.length > 0)
|
|
361
|
+
parts.push(`${diff.changed.length} changed`);
|
|
362
|
+
if (diff.onlyRemote.length > 0)
|
|
363
|
+
parts.push(`${diff.onlyRemote.length} only in Mapled`);
|
|
364
|
+
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\`.`);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
await writeManifestFile(found.dir, stored.manifest);
|
|
368
|
+
ctx.out(`Wrote ${MANIFEST_FILE} from manifest v${stored.version} (pushed ${dr.ago(stored.createdAt)} by ${stored.clientName}) — ${manifestSummary(stored.manifest)}.`);
|
|
369
|
+
}
|
|
370
|
+
/* ---- wave 2: the scanner ---- */
|
|
371
|
+
function describeScan(result, schema) {
|
|
372
|
+
const singles = new Set(schema.collections.filter((c) => c.kind === "single").map((c) => c.key));
|
|
373
|
+
const lines = [];
|
|
374
|
+
const byPage = new Map();
|
|
375
|
+
for (const b of result.bindings) {
|
|
376
|
+
if (!byPage.has(b.page))
|
|
377
|
+
byPage.set(b.page, []);
|
|
378
|
+
byPage.get(b.page).push(b);
|
|
379
|
+
}
|
|
380
|
+
const fileOf = new Map(result.pages.map((p) => [p.route, p.file ?? ""]));
|
|
381
|
+
const routeWidth = Math.max(0, ...[...byPage.keys()].map((r) => r.length));
|
|
382
|
+
for (const [page, list] of byPage) {
|
|
383
|
+
lines.push(`${page.padEnd(routeWidth)} ${fileOf.get(page) ?? ""}`.trimEnd());
|
|
384
|
+
const byCollection = new Map();
|
|
385
|
+
for (const b of list) {
|
|
386
|
+
if (!byCollection.has(b.collection))
|
|
387
|
+
byCollection.set(b.collection, []);
|
|
388
|
+
byCollection.get(b.collection).push(b);
|
|
389
|
+
}
|
|
390
|
+
const width = Math.max(0, ...[...byCollection.keys()].map((c) => c.length));
|
|
391
|
+
for (const [collection, bindings] of byCollection) {
|
|
392
|
+
const parts = [];
|
|
393
|
+
if (bindings.some((b) => b.target === "collection"))
|
|
394
|
+
parts.push(singles.has(collection) ? "read" : "list");
|
|
395
|
+
if (bindings.some((b) => b.target === "route_param"))
|
|
396
|
+
parts.push("by slug");
|
|
397
|
+
const fields = bindings.filter((b) => b.field && b.target !== "route_param").map((b) => b.field);
|
|
398
|
+
if (fields.length > 0)
|
|
399
|
+
parts.push(fields.join(", "));
|
|
400
|
+
lines.push(` ${collection.padEnd(width)} ${parts.join(" • ")}`);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
return lines;
|
|
404
|
+
}
|
|
405
|
+
export async function scan(ctx, flags) {
|
|
406
|
+
const found = await findConfig(ctx.cwd);
|
|
407
|
+
if (!found)
|
|
408
|
+
throw new CliError(`No ${CONFIG_FILE} here or above. Run \`mapled project link\` first.`);
|
|
409
|
+
const { schema, note } = await schemaForChecks(ctx, flags, found);
|
|
410
|
+
if (!schema) {
|
|
411
|
+
throw new CliError(`The scan needs the schema to name the fields — sign in with \`mapled auth login\` or run \`mapled schema pull\` first.`);
|
|
412
|
+
}
|
|
413
|
+
const framework = found.config.framework ?? (await dr.detectFramework(found.dir)) ?? null;
|
|
414
|
+
const ts = loadTypeScript(found.dir);
|
|
415
|
+
const result = await scanRepository(found.dir, { framework, schema, ts });
|
|
416
|
+
const existing = await loadManifest(found.dir);
|
|
417
|
+
const merged = mergeManifest(existing?.manifest ?? null, result, { prune: Boolean(flags.prune) });
|
|
418
|
+
if (flags.json) {
|
|
419
|
+
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));
|
|
420
|
+
if (flags.write)
|
|
421
|
+
await writeManifestFile(found.dir, merged.manifest);
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
ctx.out(`Scanned ${plural(result.files, "file")} with ${result.parser === "typescript" ? "TypeScript" : "the tokenizer"} — ${plural(result.pages.length, "page")}, ${plural(result.bindings.length, "binding")}.`);
|
|
425
|
+
if (result.bindings.length > 0) {
|
|
426
|
+
ctx.out("");
|
|
427
|
+
for (const line of describeScan(result, schema))
|
|
428
|
+
ctx.out(line);
|
|
429
|
+
}
|
|
430
|
+
if (result.notes.length > 0) {
|
|
431
|
+
ctx.out("");
|
|
432
|
+
for (const n of result.notes)
|
|
433
|
+
ctx.out(`${paint("warning", GLYPH.warning, useColor(ctx.env))} ${n}`);
|
|
434
|
+
}
|
|
435
|
+
ctx.out("");
|
|
436
|
+
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.` : "";
|
|
437
|
+
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 ? ", …" : ""}).` : "";
|
|
438
|
+
if (flags.write) {
|
|
439
|
+
await writeManifestFile(found.dir, merged.manifest);
|
|
440
|
+
const parts = [];
|
|
441
|
+
if (merged.added.length > 0)
|
|
442
|
+
parts.push(`${merged.added.length} new`);
|
|
443
|
+
if (merged.changed.length > 0)
|
|
444
|
+
parts.push(`${merged.changed.length} changed`);
|
|
445
|
+
if (merged.unchanged.length > 0)
|
|
446
|
+
parts.push(`${merged.unchanged.length} unchanged`);
|
|
447
|
+
ctx.out(`Wrote ${MANIFEST_FILE} — ${manifestSummary(merged.manifest)}${parts.length > 0 && existing ? ` (${parts.join(", ")})` : ""}.${keptNote}${droppedNote}`);
|
|
448
|
+
ctx.out("Next: `mapled bindings push`.");
|
|
449
|
+
}
|
|
450
|
+
else if (!existing) {
|
|
451
|
+
ctx.out(`No ${MANIFEST_FILE} yet — run \`mapled scan --write\` to create it, then \`mapled bindings push\`.`);
|
|
452
|
+
}
|
|
453
|
+
else {
|
|
454
|
+
const parts = [];
|
|
455
|
+
if (merged.added.length > 0)
|
|
456
|
+
parts.push(`${merged.added.length} new`);
|
|
457
|
+
if (merged.changed.length > 0)
|
|
458
|
+
parts.push(`${merged.changed.length} changed`);
|
|
459
|
+
parts.push(`${merged.unchanged.length} unchanged`);
|
|
460
|
+
if (merged.kept.length > 0)
|
|
461
|
+
parts.push(`${merged.kept.length} not found in the code (${merged.kept.slice(0, 5).join(", ")}${merged.kept.length > 5 ? ", …" : ""})`);
|
|
462
|
+
ctx.out(`Compared with ${MANIFEST_FILE}: ${parts.join(", ")}.`);
|
|
463
|
+
if (merged.added.length > 0 || merged.changed.length > 0) {
|
|
464
|
+
ctx.out(`Run \`mapled scan --write\` to update ${MANIFEST_FILE}, then \`mapled bindings push\`.`);
|
|
465
|
+
}
|
|
466
|
+
else {
|
|
467
|
+
ctx.out(`${MANIFEST_FILE} already describes what the code reads.`);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
if (note)
|
|
471
|
+
ctx.out(note);
|
|
150
472
|
}
|
|
151
473
|
export async function doctor(ctx, flags) {
|
|
152
474
|
const found = await findConfig(ctx.cwd);
|
|
@@ -173,6 +495,21 @@ export async function doctor(ctx, flags) {
|
|
|
173
495
|
const typesPath = found?.config.types ?? DEFAULT_TYPES_PATH;
|
|
174
496
|
const existing = await readFile(path.join(dir, typesPath), "utf8").catch(() => null);
|
|
175
497
|
checks.push(dr.checkTypes(existing, schema ? generateTypes(schema, { projectName: status?.project.name }) : null, typesPath));
|
|
498
|
+
const pin = found ? await readPin(dir).catch(() => null) : null;
|
|
499
|
+
const livePin = schema && found ? pinSchema(schema, found.config.project) : null;
|
|
500
|
+
const pinChanges = pin && livePin ? diffSchema(pin.pin.collections, livePin.collections) : null;
|
|
501
|
+
checks.push(dr.checkSchemaPin(pin?.pin ?? null, livePin?.hash ?? null, pinChanges));
|
|
502
|
+
const localManifest = await loadManifest(dir).catch(() => null);
|
|
503
|
+
let remoteManifest = "unknown";
|
|
504
|
+
if (conn && status && localManifest) {
|
|
505
|
+
try {
|
|
506
|
+
remoteManifest = await new Session(store, ctx.credentialsFile, conn, ctx.fetch).get("/v1/agent/manifest");
|
|
507
|
+
}
|
|
508
|
+
catch (err) {
|
|
509
|
+
remoteManifest = err instanceof ApiError && err.status === 404 ? null : "unknown";
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
checks.push(dr.checkManifestFile(localManifest ? { manifest: localManifest.manifest, problems: localManifest.problems } : null, remoteManifest));
|
|
176
513
|
checks.push(dr.checkEnv(await dr.envNames(dir), ctx.env));
|
|
177
514
|
checks.push(dr.checkSecrets(await dr.gitFacts(dir)));
|
|
178
515
|
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][];
|