@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 +3 -123
- package/dist/index.js +69 -315
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
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 {
|
|
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({
|
|
3307
|
-
|
|
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"\`),
|
|
4360
|
-
for
|
|
4361
|
-
|
|
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.
|
|
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: []\`,
|
|
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
|