@bettercms-ai/mcp 0.26.0 → 0.28.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/index.d.ts CHANGED
@@ -1,126 +1,6 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
-
3
- /**
4
- * Resolved configuration for the BetterCMS MCP server.
5
- *
6
- * A single `BETTERCMS_API_URL` (origin, no path) drives both the device-auth
7
- * endpoints and the Management API base the SDK targets:
8
- * device: {apiUrl}/api/v1/auth/device/*
9
- * management: {apiUrl}/api/v1 (SDK appends /management/content/*)
10
- */
11
- interface McpConfig {
12
- apiUrl: string;
13
- deviceBaseUrl: string;
14
- managementBaseUrl: string;
15
- credentialsPath: string;
16
- clientName: string;
17
- }
18
- declare function loadConfig(env?: NodeJS.ProcessEnv): McpConfig;
19
-
20
- /** Credentials cached between runs so the device flow runs only once per env. */
21
- interface StoredCredentials {
22
- accessToken: string;
23
- refreshToken: string;
24
- /** Epoch ms when the access token expires. */
25
- accessTokenExpiresAt: number;
26
- workspaceId: string | null;
27
- projectId: string | null;
28
- }
29
- /**
30
- * An authorization the user has been sent off to approve but hasn't yet.
31
- * Persisted so a later tool call can *resume* polling that same code instead of
32
- * minting a fresh one — this is what lets the flow survive across the
33
- * "return the link → user approves → retry" round-trip in clients (VS Code)
34
- * that never surface the server's stderr prompt.
35
- */
36
- interface PendingDevice {
37
- deviceCode: string;
38
- userCode: string;
39
- verificationUri: string;
40
- /** verification_uri with `?code=` prefilled — the link we hand the user. */
41
- verificationUriComplete: string;
42
- intervalSeconds: number;
43
- /** Epoch ms when the device code expires. */
44
- expiresAt: number;
45
- }
46
- /** Persistence boundary for credentials (file-backed in prod, in-memory in tests). */
47
- interface TokenStore {
48
- read(): Promise<StoredCredentials | null>;
49
- write(creds: StoredCredentials): Promise<void>;
50
- clear(): Promise<void>;
51
- /** In-progress device authorization awaiting approval, if any. */
52
- readPending(): Promise<PendingDevice | null>;
53
- writePending(pending: PendingDevice): Promise<void>;
54
- clearPending(): Promise<void>;
55
- }
56
-
57
- /** Injectable seams so tests can run without real timers / network / stderr. */
58
- interface DeviceAuthDeps {
59
- fetch?: typeof fetch;
60
- sleep?: (ms: number) => Promise<void>;
61
- log?: (message: string) => void;
62
- now?: () => number;
63
- }
64
- /**
65
- * Drives the OAuth 2.0 Device Authorization Grant (RFC 8628) against the
66
- * BetterCMS backend and hands the SDK a valid `content:manage` access token.
67
- *
68
- * - `getAccessToken()` returns a usable token: cached if fresh, refreshed if
69
- * expired, or freshly minted via the full device flow if there's nothing valid.
70
- * - All human-facing output goes to stderr — stdout is the MCP JSON-RPC channel.
71
- */
72
- declare class DeviceAuthClient {
73
- private readonly config;
74
- private readonly store;
75
- private readonly fetchImpl;
76
- private readonly sleep;
77
- private readonly log;
78
- private readonly now;
79
- private inFlight;
80
- private refreshInFlight;
81
- /** The single live poller for the current device code (see runDeviceFlow). */
82
- private pollTask;
83
- constructor(config: McpConfig, store: TokenStore, deps?: DeviceAuthDeps);
84
- /** Return a valid access token, doing the least work necessary. Single-flighted. */
85
- getAccessToken(): Promise<string>;
86
- private resolveToken;
87
- /**
88
- * Resume a still-live authorization if one is persisted, otherwise start a
89
- * fresh one; then grace-poll. Throws {@link DeviceAuthPendingError} (carrying
90
- * the activation link) if the user hasn't approved within the grace window.
91
- */
92
- private runDeviceFlow;
93
- /** Keep redeeming this code until it expires, detached from any tool call. One per code. */
94
- private pollInBackground;
95
- /** Request a fresh device code, persist it as pending, and log a breadcrumb. */
96
- private startDeviceFlow;
97
- /**
98
- * Poll the token endpoint until `deadline`. Returns the access token on
99
- * approval, or null if the deadline passes while still pending. Throws
100
- * {@link DeviceAuthError} on a terminal outcome (denied / expired).
101
- */
102
- private pollForApproval;
103
- /**
104
- * Exchange the stored refresh token for a new access token. Single-flighted:
105
- * the device `/refresh` endpoint is single-use (it rotates the refresh token
106
- * and revokes the prior access key), so a burst of concurrent 401s must NOT
107
- * each fire their own refresh — the first would rotate, and the rest would
108
- * send the now-stale token, get `invalid_grant`, and wipe the freshly-minted
109
- * credentials. Collapsing them into one in-flight rotation keeps the session
110
- * alive without a needless re-auth.
111
- */
112
- refresh(): Promise<string | null>;
113
- /**
114
- * Forget the cached credentials and start a fresh device flow. Called when the
115
- * bound project was deleted server-side (a key bound to a dead project can never
116
- * succeed again) — clearing lets the user re-authorize against a LIVE project.
117
- * Returns a new token if approval is fast, else throws {@link DeviceAuthPendingError}
118
- * carrying the activation link (the next tool call resumes into the new project).
119
- */
120
- resetAndReauthorize(): Promise<string>;
121
- private doRefresh;
122
- private persist;
123
- }
2
+ import { DeviceAuthClient } from '@bettercms-ai/device-auth';
3
+ export { DeviceAuthClient, loadConfig } from '@bettercms-ai/device-auth';
124
4
 
125
5
  interface BuildServerDeps {
126
6
  auth: DeviceAuthClient;
@@ -133,4 +13,4 @@ interface BuildServerDeps {
133
13
  */
134
14
  declare function buildServer(deps: BuildServerDeps): McpServer;
135
15
 
136
- export { DeviceAuthClient, buildServer, loadConfig };
16
+ export { buildServer };
package/dist/index.js CHANGED
@@ -4,309 +4,7 @@
4
4
  import { realpathSync } from "fs";
5
5
  import { fileURLToPath } from "url";
6
6
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
7
-
8
- // src/config.ts
9
- import { homedir } from "os";
10
- import { join } from "path";
11
- var DEFAULT_API_URL = "https://api.bettercms.ai";
12
- function loadConfig(env = process.env) {
13
- const apiUrl = (env.BETTERCMS_API_URL?.trim() || DEFAULT_API_URL).replace(/\/+$/, "");
14
- return {
15
- apiUrl,
16
- deviceBaseUrl: `${apiUrl}/api/v1/auth/device`,
17
- managementBaseUrl: `${apiUrl}/api/v1`,
18
- credentialsPath: env.BETTERCMS_MCP_CREDENTIALS?.trim() || join(homedir(), ".bettercms", "mcp-credentials.json"),
19
- clientName: env.BETTERCMS_MCP_CLIENT_NAME?.trim() || "BetterCMS MCP"
20
- };
21
- }
22
-
23
- // src/token-store.ts
24
- import { mkdir, readFile, writeFile } from "fs/promises";
25
- import { dirname } from "path";
26
- var FileTokenStore = class {
27
- constructor(path, key) {
28
- this.path = path;
29
- this.key = key;
30
- this.pendingKey = `${key}::pending`;
31
- }
32
- path;
33
- key;
34
- /** Pending authorizations live under a sibling key so they never shadow creds. */
35
- pendingKey;
36
- async readAll() {
37
- try {
38
- const raw = await readFile(this.path, "utf-8");
39
- const parsed = JSON.parse(raw);
40
- return parsed && typeof parsed === "object" ? parsed : {};
41
- } catch {
42
- return {};
43
- }
44
- }
45
- async writeAll(all) {
46
- await mkdir(dirname(this.path), { recursive: true });
47
- await writeFile(this.path, JSON.stringify(all, null, 2), { mode: 384 });
48
- }
49
- async read() {
50
- const all = await this.readAll();
51
- return all[this.key] ?? null;
52
- }
53
- async write(creds) {
54
- const all = await this.readAll();
55
- all[this.key] = creds;
56
- await this.writeAll(all);
57
- }
58
- async clear() {
59
- const all = await this.readAll();
60
- delete all[this.key];
61
- await this.writeAll(all);
62
- }
63
- async readPending() {
64
- const all = await this.readAll();
65
- return all[this.pendingKey] ?? null;
66
- }
67
- async writePending(pending) {
68
- const all = await this.readAll();
69
- all[this.pendingKey] = pending;
70
- await this.writeAll(all);
71
- }
72
- async clearPending() {
73
- const all = await this.readAll();
74
- delete all[this.pendingKey];
75
- await this.writeAll(all);
76
- }
77
- };
78
-
79
- // src/device-auth.ts
80
- var EXPIRY_SKEW_MS = 6e4;
81
- var GRACE_POLL_MS = 25e3;
82
- var DeviceAuthError = class extends Error {
83
- constructor(message) {
84
- super(message);
85
- this.name = "DeviceAuthError";
86
- }
87
- };
88
- var DeviceAuthPendingError = class extends Error {
89
- verificationUri;
90
- verificationUriComplete;
91
- userCode;
92
- expiresAt;
93
- constructor(pending) {
94
- super("Authorization pending \u2014 approve in the browser, then retry.");
95
- this.name = "DeviceAuthPendingError";
96
- this.verificationUri = pending.verificationUri;
97
- this.verificationUriComplete = pending.verificationUriComplete;
98
- this.userCode = pending.userCode;
99
- this.expiresAt = pending.expiresAt;
100
- }
101
- };
102
- var DeviceAuthClient = class {
103
- constructor(config, store, deps = {}) {
104
- this.config = config;
105
- this.store = store;
106
- this.fetchImpl = deps.fetch ?? globalThis.fetch;
107
- this.sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
108
- this.log = deps.log ?? ((m) => process.stderr.write(`${m}
109
- `));
110
- this.now = deps.now ?? (() => Date.now());
111
- }
112
- config;
113
- store;
114
- fetchImpl;
115
- sleep;
116
- log;
117
- now;
118
- inFlight = null;
119
- refreshInFlight = null;
120
- /** The single live poller for the current device code (see runDeviceFlow). */
121
- pollTask = null;
122
- /** Return a valid access token, doing the least work necessary. Single-flighted. */
123
- async getAccessToken() {
124
- if (this.inFlight) return this.inFlight;
125
- this.inFlight = this.resolveToken().finally(() => {
126
- this.inFlight = null;
127
- });
128
- return this.inFlight;
129
- }
130
- async resolveToken() {
131
- const creds = await this.store.read();
132
- if (creds && creds.accessTokenExpiresAt - this.now() > EXPIRY_SKEW_MS) {
133
- return creds.accessToken;
134
- }
135
- if (creds?.refreshToken) {
136
- const refreshed = await this.refresh();
137
- if (refreshed) return refreshed;
138
- }
139
- return this.runDeviceFlow();
140
- }
141
- /**
142
- * Resume a still-live authorization if one is persisted, otherwise start a
143
- * fresh one; then grace-poll. Throws {@link DeviceAuthPendingError} (carrying
144
- * the activation link) if the user hasn't approved within the grace window.
145
- */
146
- async runDeviceFlow() {
147
- let pending = await this.store.readPending();
148
- if (pending && pending.expiresAt - this.now() <= EXPIRY_SKEW_MS) {
149
- await this.store.clearPending();
150
- pending = null;
151
- }
152
- if (!pending) {
153
- pending = await this.startDeviceFlow();
154
- }
155
- const graceDeadline = Math.min(this.now() + GRACE_POLL_MS, pending.expiresAt);
156
- const token = this.pollTask ? await Promise.race([this.pollTask, this.sleep(GRACE_POLL_MS).then(() => null)]) : await this.pollForApproval(pending, graceDeadline);
157
- if (token) return token;
158
- this.pollInBackground(pending);
159
- throw new DeviceAuthPendingError(pending);
160
- }
161
- /** Keep redeeming this code until it expires, detached from any tool call. One per code. */
162
- pollInBackground(pending) {
163
- if (this.pollTask) return;
164
- const task = this.pollForApproval(pending, pending.expiresAt);
165
- this.pollTask = task;
166
- void task.catch(() => {
167
- }).finally(() => {
168
- if (this.pollTask === task) this.pollTask = null;
169
- });
170
- }
171
- /** Request a fresh device code, persist it as pending, and log a breadcrumb. */
172
- async startDeviceFlow() {
173
- const start = await this.fetchImpl(`${this.config.deviceBaseUrl}/code`, {
174
- method: "POST",
175
- headers: { "Content-Type": "application/json" },
176
- body: JSON.stringify({ client_name: this.config.clientName })
177
- });
178
- if (!start.ok) {
179
- throw new DeviceAuthError(
180
- `Failed to start device authorization (HTTP ${start.status}).`
181
- );
182
- }
183
- const code = await start.json();
184
- const pending = {
185
- deviceCode: code.device_code,
186
- userCode: code.user_code,
187
- verificationUri: code.verification_uri,
188
- verificationUriComplete: code.verification_uri_complete ?? `${code.verification_uri}?code=${encodeURIComponent(code.user_code)}`,
189
- intervalSeconds: code.interval,
190
- expiresAt: this.now() + code.expires_in * 1e3
191
- };
192
- await this.store.writePending(pending);
193
- this.pollTask = null;
194
- this.log("");
195
- this.log("\u250C\u2500 BetterCMS authorization required \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
196
- this.log(`\u2502 Visit: ${pending.verificationUri}`);
197
- this.log(`\u2502 Enter code: ${pending.userCode}`);
198
- this.log(`\u2502 Or open: ${pending.verificationUriComplete}`);
199
- this.log("\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
200
- return pending;
201
- }
202
- /**
203
- * Poll the token endpoint until `deadline`. Returns the access token on
204
- * approval, or null if the deadline passes while still pending. Throws
205
- * {@link DeviceAuthError} on a terminal outcome (denied / expired).
206
- */
207
- async pollForApproval(pending, deadline) {
208
- let intervalMs = pending.intervalSeconds * 1e3;
209
- while (this.now() < deadline) {
210
- await this.sleep(intervalMs);
211
- if (this.now() >= deadline) break;
212
- const res = await this.fetchImpl(`${this.config.deviceBaseUrl}/token`, {
213
- method: "POST",
214
- headers: { "Content-Type": "application/json" },
215
- body: JSON.stringify({
216
- device_code: pending.deviceCode,
217
- grant_type: "urn:ietf:params:oauth:grant-type:device_code"
218
- })
219
- });
220
- if (res.ok) {
221
- const body = await res.json();
222
- await this.store.clearPending();
223
- this.log("[bettercms-mcp] authorized \u2713");
224
- return this.persist(body);
225
- }
226
- const err = await res.json().catch(() => ({}));
227
- switch (err.error) {
228
- case "authorization_pending":
229
- continue;
230
- case "slow_down":
231
- intervalMs += 5e3;
232
- continue;
233
- case "access_denied":
234
- await this.store.clearPending();
235
- throw new DeviceAuthError("Authorization was denied.");
236
- case "expired_token":
237
- await this.store.clearPending();
238
- throw new DeviceAuthError("The device code expired before approval. Try again.");
239
- default:
240
- throw new DeviceAuthError(
241
- `Device authorization failed: ${err.error ?? `HTTP ${res.status}`}.`
242
- );
243
- }
244
- }
245
- return null;
246
- }
247
- /**
248
- * Exchange the stored refresh token for a new access token. Single-flighted:
249
- * the device `/refresh` endpoint is single-use (it rotates the refresh token
250
- * and revokes the prior access key), so a burst of concurrent 401s must NOT
251
- * each fire their own refresh — the first would rotate, and the rest would
252
- * send the now-stale token, get `invalid_grant`, and wipe the freshly-minted
253
- * credentials. Collapsing them into one in-flight rotation keeps the session
254
- * alive without a needless re-auth.
255
- */
256
- async refresh() {
257
- if (this.refreshInFlight) return this.refreshInFlight;
258
- this.refreshInFlight = this.doRefresh().finally(() => {
259
- this.refreshInFlight = null;
260
- });
261
- return this.refreshInFlight;
262
- }
263
- /**
264
- * Forget the cached credentials and start a fresh device flow. Called when the
265
- * bound project was deleted server-side (a key bound to a dead project can never
266
- * succeed again) — clearing lets the user re-authorize against a LIVE project.
267
- * Returns a new token if approval is fast, else throws {@link DeviceAuthPendingError}
268
- * carrying the activation link (the next tool call resumes into the new project).
269
- */
270
- async resetAndReauthorize() {
271
- await this.store.clear();
272
- await this.store.clearPending();
273
- return this.getAccessToken();
274
- }
275
- async doRefresh() {
276
- const creds = await this.store.read();
277
- if (!creds?.refreshToken) return null;
278
- let res;
279
- try {
280
- res = await this.fetchImpl(`${this.config.deviceBaseUrl}/refresh`, {
281
- method: "POST",
282
- headers: { "Content-Type": "application/json" },
283
- body: JSON.stringify({ refresh_token: creds.refreshToken })
284
- });
285
- } catch {
286
- return null;
287
- }
288
- if (res.ok) {
289
- const body = await res.json();
290
- return this.persist(body);
291
- }
292
- const err = await res.json().catch(() => ({}));
293
- if (err.error === "invalid_grant" || res.status === 401 || res.status === 403) {
294
- await this.store.clear();
295
- }
296
- return null;
297
- }
298
- async persist(body) {
299
- const creds = {
300
- accessToken: body.access_token,
301
- refreshToken: body.refresh_token,
302
- accessTokenExpiresAt: this.now() + body.expires_in * 1e3,
303
- workspaceId: body.workspace_id,
304
- projectId: body.project_id
305
- };
306
- await this.store.write(creds);
307
- return creds.accessToken;
308
- }
309
- };
7
+ import { loadConfig, FileTokenStore, DeviceAuthClient } from "@bettercms-ai/device-auth";
310
8
 
311
9
  // src/server.ts
312
10
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -2282,6 +1980,7 @@ var LAYOUT_SECTION_ICON_SET = new Set(LAYOUT_SECTION_ICONS);
2282
1980
 
2283
1981
  // src/tools.ts
2284
1982
  import { BetterCMSError } from "@bettercms-ai/sdk";
1983
+ import { DeviceAuthPendingError } from "@bettercms-ai/device-auth";
2285
1984
  var FRAMEWORK_CHOICES = ["astro", "next", "react-ts", "other"];
2286
1985
  var FRAMEWORK_LABELS = {
2287
1986
  astro: "Astro \u2014 recommended default, static by default and fastest to publish",
@@ -3183,6 +2882,16 @@ function buildToolDefs(deps) {
3183
2882
  z.object({ declaredBindings: z.boolean().describe("true = trust the template's declared bindings; false = text-match (the default)") }).shape,
3184
2883
  async (c, a) => ok("Recorded the binding mode.", await data(c, "PATCH", `/management/projects/current/binding-mode`, { declaredBindings: a.declaredBindings }))
3185
2884
  ),
2885
+ def(
2886
+ "submit_conversion_receipt",
2887
+ "Record what the conversion codemod could and could not do",
2888
+ "Hand BetterCMS the codemod's own account of a conversion run, so the coverage meter can say WHY a path is not declared instead of only that it is not. Submit the receipt `npx @bettercms-ai/convert` wrote (`--receipt out.json`) for the SAME `briefDigest` get_conversion_brief { complete: true } returned: `{ briefDigest, receipt }`, where the receipt carries `paths: { declared, rewritten, alreadyDeclared, pending[{ route, scope, path, kind, file, reason, message }] }`. `paths.declared` must equal rewritten + alreadyDeclared + pending.length, and each pending `reason` is one of the converter's own (IN_EXPRESSION, AMBIGUOUS_LITERAL, REPEATER_FIXED_LENGTH, PARSE_ERROR, \u2026) \u2014 a path with no receipt row simply reads `not-declared`. It is a RECORD, not a release: it changes nothing about the site, and the meter picks it up on the next get_binding_report after the next deploy. A 404 `unknown-brief` means that digest was never issued here, so convert against a brief this project actually returned. Requires a project-scoped connection carrying artifact:write, the same authority as set_binding_mode.",
2889
+ z.object({
2890
+ briefDigest: z.string().min(1).describe("The `briefDigest` get_conversion_brief { complete: true } returned. Must match the receipt's own."),
2891
+ receipt: z.record(z.string(), z.unknown()).describe("The receipt `npx @bettercms-ai/convert --receipt out.json` wrote, verbatim.")
2892
+ }).shape,
2893
+ async (c, a) => ok("Recorded the conversion receipt.", await data(c, "POST", `/management/projects/current/conversion-receipt`, { briefDigest: a.briefDigest, receipt: a.receipt }))
2894
+ ),
3186
2895
  def(
3187
2896
  "clone_project",
3188
2897
  "Clone a project",
@@ -3302,14 +3011,17 @@ function buildToolDefs(deps) {
3302
3011
  def(
3303
3012
  "get_conversion_brief",
3304
3013
  "Get the brief for making this site's bindings durable",
3305
- "The per-project brief for making this site's bindings DURABLE \u2014 read it before you touch the templates. Returns what already exists in the CMS: every live page with its route, and every bindable field path with its `label`, `kind`, the value the CMS holds now (`current`) and the copy the repo renders today (`original`, the field's defaultValue) \u2014 plus the exact attributes to declare, and the ordered steps. Call it for any site whose pages were DERIVED at import, and whenever get_next_steps reports `bindings-not-declared`. It REPLACES re-registering a schema: these pages, fields and values exist already, so create_page / add_page_field / create_content_model would build a second schema over the first \u2014 edit values with set_page_content instead. `lane` says how to get the source ('git-connected' = pull_project_source returns a repo; 'archive' = a tarball). The full recipe is section 13 of the bettercms://playbook/schema resource; get_binding_report is the receipt that says you finished.",
3306
- z.object({}).shape,
3307
- async (c) => ok("Conversion brief.", await data(c, "GET", `/management/projects/current/conversion-brief`))
3014
+ "The per-project brief for making this site's bindings DURABLE \u2014 read it before you touch the templates. Returns what already exists in the CMS: every live page with its route, and every bindable field path with its `label`, `kind`, the value the CMS holds now (`current`) and the copy the repo renders today (`original`, the field's defaultValue) \u2014 plus the exact attributes to declare, and the ordered steps. Call it for any site whose pages were DERIVED at import, and whenever get_next_steps reports `bindings-not-declared`. It REPLACES re-registering a schema: these pages, fields and values exist already, so create_page / add_page_field / create_content_model would build a second schema over the first \u2014 edit values with set_page_content instead. `lane` says how to get the source ('git-connected' = pull_project_source returns a repo; 'archive' = a tarball). The full recipe is section 13 of the bettercms://playbook/schema resource; get_binding_report is the receipt that says you finished. Pass `complete: true` when you are about to run the CODEMOD (`npx @bettercms-ai/convert`): that returns the brief UNCAPPED and paged \u2014 nothing truncated, no page omitted \u2014 and pins the full path list under a `briefDigest`, which is the list the coverage meter in get_binding_report measures the build against. Follow `cursor` until it stops coming back; a 409 `BRIEF_CHANGED` means the brief was re-derived while you paged, so start again.",
3015
+ z.object({
3016
+ complete: z.boolean().optional().describe("true = the COMPLETE brief for a codemod: nothing truncated, no page omitted, paged 50 pages at a time. Page 1 pins the path list the coverage meter measures against."),
3017
+ cursor: z.string().optional().describe("The `cursor` from the previous page. Implies complete. A 409 BRIEF_CHANGED means the brief was re-derived mid-pagination \u2014 start again with no cursor.")
3018
+ }).shape,
3019
+ async (c, a) => ok("Conversion brief.", await data(c, "GET", `/management/projects/current/conversion-brief${q({ complete: a.complete, cursor: a.cursor })}`))
3308
3020
  ),
3309
3021
  def(
3310
3022
  "get_conversion_plan",
3311
3023
  "Get the approved conversion to apply",
3312
- "The APPROVED conversion a human reviewed in the BetterCMS dashboard: the exact new contents of each template file, already checked against this project's real field paths. Apply it instead of writing the bindings by hand. Check out `baseHeadOid` (the exact commit it was written against \u2014 a plan applied to a different base is a different change), branch from there, write each file's `content` verbatim (whole file, no merge, no reformatting), then push or deploy however this project ships. `stale.head` / `stale.brief` say the repository or the CMS moved since it was approved: stop and ask for a fresh proposal rather than applying it anyway. Finish the loop the same way as a hand conversion \u2014 get_binding_report until `unmatched` is empty, then set_binding_mode { declaredBindings: true } and release once more. 404 with `code: \"no-approved-plan\"` means nobody has approved one: use get_conversion_brief and do the conversion yourself. Requires a project-scoped connection carrying artifact:write.",
3024
+ "The APPROVED conversion a human reviewed in the BetterCMS dashboard: the exact new contents of each template file, already checked against this project's real field paths. Apply it instead of writing the bindings by hand. Check out `baseHeadOid` (the exact commit it was written against \u2014 a plan applied to a different base is a different change), branch from there, write each file's `content` verbatim (whole file, no merge, no reformatting), then push or deploy however this project ships. `stale.head` / `stale.brief` say the repository or the CMS moved since it was approved: stop and ask for a fresh proposal rather than applying it anyway. `receipt.paths.pending` lists every field the codemod could NOT bind, each with a reason (`DIALECT_UNSUPPORTED`, `PROP_TARGET_NOT_FOUND`, `REPEATER_FIXED_LENGTH`, \u2026) \u2014 apply the plan first, then bind those by hand. Finish the loop the same way as a hand conversion \u2014 get_binding_report until `unmatched` is empty, then set_binding_mode { declaredBindings: true } and release once more. 404 with `code: \"no-approved-plan\"` means nobody has approved one: use get_conversion_brief and do the conversion yourself. Requires a project-scoped connection carrying artifact:write.",
3313
3025
  z.object({}).shape,
3314
3026
  async (c) => ok("Approved conversion plan.", await data(c, "GET", `/management/projects/current/conversion-plan`))
3315
3027
  ),
@@ -4321,6 +4033,34 @@ first: it lists those pages, their routes, every bindable path with its current
4321
4033
  value, and the attributes to declare. SKIP steps 3 and 4 below and bind the keys it names \u2014
4322
4034
  registering the schema again builds a second one over the first.
4323
4035
 
4036
+ **Run the CODEMOD rather than editing by hand.** For a derived site the whole of step 5 is
4037
+ mechanical, and there is a tool that does it. THE ORDER, and every step of it matters:
4038
+
4039
+ 1. \`get_conversion_brief { complete: true }\` \u2014 the COMPLETE brief, not the capped one. It comes
4040
+ back uncapped and paged (50 pages at a time): follow \`cursor\` until it stops coming back and
4041
+ keep every page. The capped read truncates values and omits pages, and a codemod fed a
4042
+ truncated \`original\` searches the source for a string that is not there. Page 1 PINS the
4043
+ path list under a \`briefDigest\`; that pinned list is what the coverage meter measures the
4044
+ build against, so nothing later can move the denominator. A 409 \`BRIEF_CHANGED\` mid-pagination
4045
+ means the brief was re-derived under you \u2014 start again with no cursor.
4046
+ 2. Write the pages you collected to \`brief.json\` and run
4047
+ \`npx @bettercms-ai/convert --brief brief.json --root . --receipt receipt.json\`, then read
4048
+ \`git diff\`. It rewrites the templates to read from BetterCMS, keeps the in-code copy as the
4049
+ fallback, and declares each binding.
4050
+ 3. REVIEW THE PENDING LIST. Every path the codemod could not do is in \`paths.pending\` with a
4051
+ reason (\`IN_EXPRESSION\`, \`AMBIGUOUS_LITERAL\`, \`REPEATER_FIXED_LENGTH\`, \`PARSE_ERROR\`, \u2026).
4052
+ Do those by hand, or decide they are genuinely not convertible \u2014 do not skip past them.
4053
+ 4. \`submit_conversion_receipt { briefDigest, receipt }\` with the receipt file, verbatim. This is
4054
+ what lets the coverage meter say WHY a path is undeclared instead of only that it is; without
4055
+ it every one of them reads \`not-declared\`, which looks like a broken site rather than work
4056
+ with a reason. It records and releases nothing.
4057
+ 5. Push, or \`deploy_project\`, and wait for the release to be live (step 6 below).
4058
+ 6. \`get_binding_report\`. Alongside \`unmatched\` it now carries \`coverage\` \u2014 \`declared\` (what the
4059
+ brief listed), \`bound\` (what the build declares) and \`pending\` with the reasons. Fix what it
4060
+ names and release again until \`coverage.pending\` is empty.
4061
+ 7. \`set_binding_mode { declaredBindings: true }\` and release ONE MORE TIME \u2014 the mode applies to
4062
+ the next release, not to the one already out.
4063
+
4324
4064
  **Or let BetterCMS propose the edit.** \`get_conversion_plan\` returns an APPROVED conversion \u2014 the
4325
4065
  exact new contents of each template file, reviewed by a human in the dashboard and already checked
4326
4066
  against this project's real field paths. When there is one, apply it instead of doing step 5 by
@@ -4356,9 +4096,20 @@ conversion is yours to write.
4356
4096
  Prefer schema-derived bindings \u2014 the TypeGen analogue:
4357
4097
  \`npx @bettercms-ai/codegen --bindings-out src/bettercms.bindings.generated.ts\`, then spread
4358
4098
  \`{...bcms.home.hero.title}\` / \`{...bcms.blog.features.$(i)}\`. The hand form is
4359
- \`data-bcms-field="<path>"\` (plus \`data-bcms-kind="richtext"|"image"\`), \`data-bcms-props\`
4360
- for an \`href\` / \`alt\` / \`src\`, the \xA711 layout markers for nav and footer, and
4361
- \`<div data-bcms-field="body" data-bcms-kind="document">\` around a Portable Text render.
4099
+ \`data-bcms-field="<path>"\` (plus \`data-bcms-kind="richtext"|"image"\`), the \xA711 layout markers
4100
+ for nav and footer, and \`<div data-bcms-field="body" data-bcms-kind="document">\` around a
4101
+ Portable Text render.
4102
+ **A value that lives in an ATTRIBUTE \u2014 an \`href\`, an \`alt\`, an \`src\` \u2014 rides
4103
+ \`data-bcms-props\`, and its grammar is PIPES, NOT JSON:**
4104
+ \`data-bcms-props="<path>|<kind>|<domAttribute>"\`, semicolon-separated for several on one
4105
+ element, where \`kind\` is \`text | richtext | image | url | number | array\`. A link is
4106
+ \`data-bcms-props="cta.url|url|href"\`; an image's alt text is
4107
+ \`data-bcms-props="hero.image.alt|text|alt"\`; an \`<a>\` wrapping an \`<img>\` carries both as
4108
+ \`data-bcms-props="cta.url|url|href;hero.image.alt|text|alt"\`. A JSON object there parses to
4109
+ NOTHING \u2014 the reader splits on \`;\` and \`|\` and drops what does not match \u2014 so the value stays
4110
+ uneditable and the binding report never mentions it. The SDK helpers
4111
+ (\`@bettercms-ai/astro\`, \`@bettercms-ai/next\`) and \`@bettercms-ai/codegen\` emit this form
4112
+ for you; write it by hand only when you are not using them.
4362
4113
  Bind CONDITIONALLY (\`fromCms ? path : undefined\`) so a fallback row is never bound. A value
4363
4114
  rendered in N places carries the binding on ALL N \u2014 the editor keeps the copies in sync.
4364
4115
  **Read the LIVE SCHEMA before you bind \u2014 \`get_page\` / \`get_content_model\`, never the
@@ -4382,11 +4133,13 @@ conversion is yours to write.
4382
4133
  one the site renders its fallbacks and the report says \`no-element\` for every path.
4383
4134
  6. Push, or \`deploy_project\`; poll \`get_deploy_status\` until it is live. Then
4384
4135
  \`get_binding_report\` \u2014 still \`text-match\`, and \`unmatched\` should be EMPTY because the
4385
- values are byte-equal to what the build renders. Now \`set_binding_mode
4136
+ values are byte-equal to what the build renders. On a converted site read \`coverage\` too: it
4137
+ counts the PINNED brief's paths, which \`unmatched\` cannot, because \`unmatched\` only ever
4138
+ speaks about fields that already hold a value. Now \`set_binding_mode
4386
4139
  {declaredBindings: true}\` and release again (an empty commit is enough). Do not flip before
4387
4140
  the report is clean: in declared mode an undeclared field simply stops being editable.
4388
- 7. **Receipts.** \`get_binding_report\` reads \`mode: "declared"\`, \`unmatched: []\`, and
4389
- \`bound > 0\`. That certifies one thing only \u2014 that every non-empty field has SOME element
4141
+ 7. **Receipts.** \`get_binding_report\` reads \`mode: "declared"\`, \`unmatched: []\`,
4142
+ \`bound > 0\`, and \u2014 for a site converted against a pinned brief \u2014 \`coverage.pending: []\`. That certifies one thing only \u2014 that every non-empty field has SOME element
4390
4143
  carrying its path. It CANNOT see copy that was never modelled, so diff each route's visible
4391
4144
  text against its entry values yourself before you call the page done. Then publish, and
4392
4145
  fetch the live URL cache-busted (\xA712: publish and deploy are separate claims).
@@ -4870,6 +4623,7 @@ function buildServer(deps) {
4870
4623
  }
4871
4624
 
4872
4625
  // src/index.ts
4626
+ import { DeviceAuthClient as DeviceAuthClient2, loadConfig as loadConfig2 } from "@bettercms-ai/device-auth";
4873
4627
  async function main() {
4874
4628
  const config = loadConfig();
4875
4629
  const store = new FileTokenStore(config.credentialsPath, config.apiUrl);
@@ -4897,8 +4651,8 @@ if (isMainModule()) {
4897
4651
  });
4898
4652
  }
4899
4653
  export {
4900
- DeviceAuthClient,
4654
+ DeviceAuthClient2 as DeviceAuthClient,
4901
4655
  buildServer,
4902
- loadConfig
4656
+ loadConfig2 as loadConfig
4903
4657
  };
4904
4658
  //# sourceMappingURL=index.js.map