@patronage/mcp-wordpress 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 +22 -1
- package/dist/index.d.ts +18 -4
- package/dist/index.js +271 -39
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,7 +4,9 @@ The WordPress connector for [`@patronage/mcp-core`](../mcp-core). In the gateway
|
|
|
4
4
|
|
|
5
5
|
- `wp.graphql({ query, variables? })` runs WPGraphQL queries, never mutations.
|
|
6
6
|
- `wp.get({ path, query? })` reads the REST API under `/wp-json/`, and refuses method overrides.
|
|
7
|
-
- `wp.apply({ intent, operations })` stages
|
|
7
|
+
- `wp.apply({ intent, operations })` stages operations for approval:
|
|
8
|
+
- `update_post` changes core fields, ACF fields and registered post meta (`meta`), with an optional `expected_modified_gmt` check.
|
|
9
|
+
- `upsert_redirect` creates or updates one plain-URL rule in the Redirection plugin: `{ source: "/old-page", target: "/new-page", code?: 301 | 302 | 307 | 308 }`.
|
|
8
10
|
|
|
9
11
|
It also gives `describe_site` the site's post types and GraphQL types.
|
|
10
12
|
|
|
@@ -13,3 +15,22 @@ Requests go out as the signed-in person: the gateway forwards the Access JWT wit
|
|
|
13
15
|
```ts
|
|
14
16
|
import { wordpressConnector } from "@patronage/mcp-wordpress";
|
|
15
17
|
```
|
|
18
|
+
|
|
19
|
+
## Post meta
|
|
20
|
+
|
|
21
|
+
WordPress only accepts meta keys a site registers for REST, so the site decides what is writable. A key that isn't registered is dropped, and the receipt reports it as not stored. For example, to let editors change Yoast's meta description and noindex setting on pages:
|
|
22
|
+
|
|
23
|
+
```php
|
|
24
|
+
foreach ( [ '_yoast_wpseo_metadesc', '_yoast_wpseo_meta-robots-noindex' ] as $key ) {
|
|
25
|
+
register_post_meta( 'page', $key, [
|
|
26
|
+
'single' => true,
|
|
27
|
+
'type' => 'string',
|
|
28
|
+
'show_in_rest' => true,
|
|
29
|
+
'auth_callback' => fn ( $allowed, $meta_key, $post_id ) => current_user_can( 'edit_post', $post_id ),
|
|
30
|
+
] );
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Redirects
|
|
35
|
+
|
|
36
|
+
`upsert_redirect` uses Redirection's REST API (`/wp-json/redirection/v1/`). It owns the one rule with the same source, a plain URL match and no regex. It updates that rule in place, keeping its group and title, or creates one in Redirection's default group (else the first enabled WordPress group). The preview shows the current target and code against the new ones, and apply refuses to write if that rule changed after the preview. A disabled rule, or two rules for the same source, stop the operation for an editor to sort out in Redirection. The approver needs Redirection's capability: `manage_options`, unless the site filters `redirection_role`.
|
package/dist/index.d.ts
CHANGED
|
@@ -50,13 +50,21 @@ declare const updatePostOperationSchema: z.ZodObject<{
|
|
|
50
50
|
}, z.core.$strict>>;
|
|
51
51
|
id: z.ZodNumber;
|
|
52
52
|
kind: z.ZodLiteral<"update_post">;
|
|
53
|
+
meta: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodJSONSchema>>;
|
|
53
54
|
rest_base: z.ZodString;
|
|
54
55
|
}, z.core.$strict>;
|
|
55
56
|
type UpdatePostOperation = z.infer<typeof updatePostOperationSchema>;
|
|
56
|
-
|
|
57
|
+
declare const upsertRedirectOperationSchema: z.ZodObject<{
|
|
58
|
+
code: z.ZodDefault<z.ZodLiteral<301 | 302 | 307 | 308>>;
|
|
59
|
+
kind: z.ZodLiteral<"upsert_redirect">;
|
|
60
|
+
source: z.ZodString;
|
|
61
|
+
target: z.ZodString;
|
|
62
|
+
}, z.core.$strict>;
|
|
63
|
+
type UpsertRedirectOperation = z.infer<typeof upsertRedirectOperationSchema>;
|
|
64
|
+
type WriteOperation = UpdatePostOperation | UpsertRedirectOperation;
|
|
57
65
|
export declare const applyInputSchema: z.ZodObject<{
|
|
58
66
|
intent: z.ZodString;
|
|
59
|
-
operations: z.ZodArray<z.ZodObject<{
|
|
67
|
+
operations: z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
60
68
|
acf: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodJSONSchema>>;
|
|
61
69
|
expected_modified_gmt: z.ZodOptional<z.ZodString>;
|
|
62
70
|
fields: z.ZodOptional<z.ZodObject<{
|
|
@@ -73,8 +81,14 @@ export declare const applyInputSchema: z.ZodObject<{
|
|
|
73
81
|
}, z.core.$strict>>;
|
|
74
82
|
id: z.ZodNumber;
|
|
75
83
|
kind: z.ZodLiteral<"update_post">;
|
|
84
|
+
meta: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodJSONSchema>>;
|
|
76
85
|
rest_base: z.ZodString;
|
|
77
|
-
}, z.core.$strict
|
|
86
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
87
|
+
code: z.ZodDefault<z.ZodLiteral<301 | 302 | 307 | 308>>;
|
|
88
|
+
kind: z.ZodLiteral<"upsert_redirect">;
|
|
89
|
+
source: z.ZodString;
|
|
90
|
+
target: z.ZodString;
|
|
91
|
+
}, z.core.$strict>], "kind">>;
|
|
78
92
|
}, z.core.$strip>;
|
|
79
93
|
type ApplyInput = z.infer<typeof applyInputSchema>;
|
|
80
94
|
export declare const parseApplyInput: <T>(input: T) => ApplyInput;
|
|
@@ -144,4 +158,4 @@ export declare const createWordPressTools: (deps: ConnectorDeps<WordPressEnv>) =
|
|
|
144
158
|
};
|
|
145
159
|
};
|
|
146
160
|
};
|
|
147
|
-
export type { ApplyInput, WordPressDescription, WordPressEnv, WriteOperation };
|
|
161
|
+
export type { ApplyInput, UpdatePostOperation, UpsertRedirectOperation, WordPressDescription, WordPressEnv, WriteOperation };
|
package/dist/index.js
CHANGED
|
@@ -244,14 +244,30 @@ const updatePostOperationSchema = z.strictObject({
|
|
|
244
244
|
fields: updatePostFieldsSchema.optional(),
|
|
245
245
|
id: z.number().int().positive(),
|
|
246
246
|
kind: z.literal("update_post"),
|
|
247
|
+
meta: z.record(z.string().min(1).max(255), z.json()).optional(),
|
|
247
248
|
rest_base: restBaseSchema
|
|
248
|
-
}).refine((operation) => changedFieldKeys(operation.fields).length > 0 || Object.keys(operation.acf ?? {}).length > 0, { message: "operation must change at least one field or
|
|
249
|
-
const
|
|
249
|
+
}).refine((operation) => changedFieldKeys(operation.fields).length > 0 || Object.keys(operation.acf ?? {}).length > 0 || Object.keys(operation.meta ?? {}).length > 0, { message: "operation must change at least one field, acf key or meta key" });
|
|
250
|
+
const redirectSourceSchema = z.string().trim().min(2).max(2e3).regex(/^\/(?!\/)[^\s#]*$/u, "source must be a site path such as /old-page");
|
|
251
|
+
const redirectTargetSchema = z.string().trim().min(1).max(2e3).refine((value) => /^\/(?!\/)\S*$/u.test(value) || /^https?:\/\/[^\s/]+\S*$/iu.test(value), { message: "target must be a site path or an http(s) URL" });
|
|
252
|
+
const upsertRedirectOperationSchema = z.strictObject({
|
|
253
|
+
code: z.literal([
|
|
254
|
+
301,
|
|
255
|
+
302,
|
|
256
|
+
307,
|
|
257
|
+
308
|
|
258
|
+
]).default(301),
|
|
259
|
+
kind: z.literal("upsert_redirect"),
|
|
260
|
+
source: redirectSourceSchema,
|
|
261
|
+
target: redirectTargetSchema
|
|
262
|
+
}).refine((operation) => operation.source !== operation.target, { message: "a redirect cannot point at its own source" });
|
|
263
|
+
const writeOperationSchema = z.discriminatedUnion("kind", [updatePostOperationSchema, upsertRedirectOperationSchema]);
|
|
264
|
+
const isRedirectOperation = (operation) => operation.kind === "upsert_redirect";
|
|
265
|
+
const operationTarget = (operation) => isRedirectOperation(operation) ? `redirect ${operation.source}` : `${operation.rest_base}/${operation.id}`;
|
|
250
266
|
const DUPLICATE_TARGET_CODE = "duplicate_target";
|
|
251
267
|
const duplicateTargetIssueSchema = z.object({ params: z.object({ code: z.literal(DUPLICATE_TARGET_CODE) }) });
|
|
252
268
|
const applyInputSchema = z.object({
|
|
253
269
|
intent: z.string().trim().min(1).max(500),
|
|
254
|
-
operations: z.array(
|
|
270
|
+
operations: z.array(writeOperationSchema).min(1).max(MAX_OPERATIONS)
|
|
255
271
|
}).superRefine((input, ctx) => {
|
|
256
272
|
const seen = /* @__PURE__ */ new Set();
|
|
257
273
|
for (const [index, operation] of input.operations.entries()) {
|
|
@@ -272,33 +288,264 @@ const parseApplyInput = (input) => {
|
|
|
272
288
|
const duplicate = parsed.error.issues.some((issue) => duplicateTargetIssueSchema.safeParse(issue).success);
|
|
273
289
|
throw new ApplyValidationError(duplicate ? DUPLICATE_TARGET_CODE : "invalid_operations", detail);
|
|
274
290
|
};
|
|
291
|
+
const REDIRECT_PATHS = ["target", "code"];
|
|
275
292
|
const changedPaths = (operation) => {
|
|
293
|
+
if (isRedirectOperation(operation)) return [...REDIRECT_PATHS];
|
|
276
294
|
const paths = changedFieldKeys(operation.fields);
|
|
277
295
|
for (const key of Object.keys(operation.acf ?? {}).toSorted()) paths.push(`acf.${key}`);
|
|
296
|
+
for (const key of Object.keys(operation.meta ?? {}).toSorted()) paths.push(`meta.${key}`);
|
|
278
297
|
return paths;
|
|
279
298
|
};
|
|
280
299
|
const operationSentence = (operation) => {
|
|
300
|
+
if (isRedirectOperation(operation)) return `Redirect ${operation.source} to ${operation.target} (${operation.code})`;
|
|
281
301
|
return `Update ${joinPhrase(changedPaths(operation))} on ${operation.rest_base} ${operation.id}`;
|
|
282
302
|
};
|
|
283
|
-
// src/wp-
|
|
303
|
+
// src/wp-receipts.ts
|
|
284
304
|
const MISSED_PATH_LIMIT = 5;
|
|
305
|
+
const toReceiptError = (error) => {
|
|
306
|
+
if (error instanceof ApplyConflictError) return {
|
|
307
|
+
code: error.code,
|
|
308
|
+
message: error.message
|
|
309
|
+
};
|
|
310
|
+
if (error instanceof UpstreamRequestError) return {
|
|
311
|
+
code: "wordpress_request_failed",
|
|
312
|
+
message: error.message
|
|
313
|
+
};
|
|
314
|
+
return {
|
|
315
|
+
code: "unexpected_error",
|
|
316
|
+
message: "The operation failed"
|
|
317
|
+
};
|
|
318
|
+
};
|
|
319
|
+
const missedPathsMessage = (missed) => {
|
|
320
|
+
const listed = missed.slice(0, MISSED_PATH_LIMIT);
|
|
321
|
+
const extra = missed.length - listed.length;
|
|
322
|
+
const suffix = extra > 0 ? ` and ${extra} more` : "";
|
|
323
|
+
return `WordPress did not store ${listed.join(", ")}${suffix}`;
|
|
324
|
+
};
|
|
325
|
+
// src/wp-redirects.ts
|
|
326
|
+
const REDIRECTS_PATH = "/wp-json/redirection/v1/redirect";
|
|
327
|
+
const GROUPS_PATH = "/wp-json/redirection/v1/group";
|
|
328
|
+
const WORDPRESS_MODULE = 1;
|
|
329
|
+
const PAGE_SIZE = 200;
|
|
330
|
+
const MAX_PAGES = 10;
|
|
331
|
+
const REDIRECTION_EDITOR = "Redirection";
|
|
332
|
+
const redirectItemSchema = z.object({
|
|
333
|
+
action_code: z.coerce.number().int(),
|
|
334
|
+
action_data: z.unknown().optional(),
|
|
335
|
+
action_type: z.string().optional(),
|
|
336
|
+
enabled: z.boolean().optional(),
|
|
337
|
+
group_id: z.coerce.number().int(),
|
|
338
|
+
id: z.coerce.number().int().positive(),
|
|
339
|
+
match_type: z.string().optional(),
|
|
340
|
+
regex: z.coerce.boolean().optional(),
|
|
341
|
+
status: z.string().optional(),
|
|
342
|
+
title: z.string().nullish(),
|
|
343
|
+
url: z.string()
|
|
344
|
+
});
|
|
345
|
+
const listSchema = z.object({
|
|
346
|
+
items: z.array(z.unknown()),
|
|
347
|
+
total: z.coerce.number().int().nonnegative().optional()
|
|
348
|
+
});
|
|
349
|
+
const actionUrlSchema = z.object({ url: z.string() });
|
|
350
|
+
const groupSchema = z.object({
|
|
351
|
+
default: z.boolean().optional(),
|
|
352
|
+
enabled: z.boolean().optional(),
|
|
353
|
+
id: z.coerce.number().int().positive(),
|
|
354
|
+
module_id: z.coerce.number().int()
|
|
355
|
+
});
|
|
356
|
+
const isEnabled = (item) => item.enabled ?? item.status !== "disabled";
|
|
357
|
+
const actionUrl = (item) => {
|
|
358
|
+
const parsed = actionUrlSchema.safeParse(item.action_data);
|
|
359
|
+
return parsed.success ? parsed.data.url : null;
|
|
360
|
+
};
|
|
361
|
+
const toRule = (item) => ({
|
|
362
|
+
code: item.action_code,
|
|
363
|
+
enabled: isEnabled(item),
|
|
364
|
+
groupId: item.group_id,
|
|
365
|
+
id: item.id,
|
|
366
|
+
target: actionUrl(item),
|
|
367
|
+
title: item.title ?? ""
|
|
368
|
+
});
|
|
369
|
+
const redirectionError = (result, fallback) => {
|
|
370
|
+
if (result.status === 404 && result.errorCode === "rest_no_route") return new ApplyConflictError("redirection_unavailable", "The Redirection plugin's REST API is not available on this site");
|
|
371
|
+
return new ApplyConflictError(result.errorCode ?? "read_failed", wordPressErrorMessage(result.status, result.errorCode) ?? fallback);
|
|
372
|
+
};
|
|
373
|
+
const listPath = (base, query) => `${base}?${new URLSearchParams(query).toString()}`;
|
|
374
|
+
const readPage = async (client, source, page, telemetry) => {
|
|
375
|
+
const result = await wpFetch(client, listPath(REDIRECTS_PATH, {
|
|
376
|
+
"filterBy[url]": source,
|
|
377
|
+
page: String(page),
|
|
378
|
+
per_page: String(PAGE_SIZE)
|
|
379
|
+
}), telemetry);
|
|
380
|
+
const parsed = listSchema.safeParse(result.json);
|
|
381
|
+
if (result.status < 200 || result.status >= 300 || !parsed.success) throw redirectionError(result, "Could not read Redirection rules");
|
|
382
|
+
const items = parsed.data.items.flatMap((item) => {
|
|
383
|
+
const rule = redirectItemSchema.safeParse(item);
|
|
384
|
+
return rule.success ? [rule.data] : [];
|
|
385
|
+
});
|
|
386
|
+
const seen = (page + 1) * PAGE_SIZE;
|
|
387
|
+
return {
|
|
388
|
+
items,
|
|
389
|
+
more: parsed.data.items.length === PAGE_SIZE && (parsed.data.total === void 0 || seen < parsed.data.total)
|
|
390
|
+
};
|
|
391
|
+
};
|
|
392
|
+
const collectPages = async (client, source, telemetry, page, collected) => {
|
|
393
|
+
const { items, more } = await readPage(client, source, page, telemetry);
|
|
394
|
+
collected.push(...items);
|
|
395
|
+
if (!more) return collected;
|
|
396
|
+
if (page + 1 >= MAX_PAGES) throw new ApplyConflictError("too_many_redirect_matches", `Too many Redirection rules contain ${source} to check it safely`);
|
|
397
|
+
return collectPages(client, source, telemetry, page + 1, collected);
|
|
398
|
+
};
|
|
399
|
+
const findRule = async (client, source, telemetry) => {
|
|
400
|
+
const exact = (await collectPages(client, source, telemetry, 0, [])).filter((item) => item.url === source && item.regex !== true && (item.match_type ?? "url") === "url" && (item.action_type ?? "url") === "url");
|
|
401
|
+
if (exact.length > 1) throw new ApplyConflictError("ambiguous_redirect", `Redirection has ${exact.length} rules for ${source}; merge them in Redirection first`);
|
|
402
|
+
const [only] = exact;
|
|
403
|
+
return only === void 0 ? void 0 : toRule(only);
|
|
404
|
+
};
|
|
405
|
+
const defaultGroup = async (client, telemetry) => {
|
|
406
|
+
const result = await wpFetch(client, listPath(GROUPS_PATH, { per_page: String(PAGE_SIZE) }), telemetry);
|
|
407
|
+
const parsed = listSchema.safeParse(result.json);
|
|
408
|
+
if (result.status < 200 || result.status >= 300 || !parsed.success) throw redirectionError(result, "Could not read Redirection groups");
|
|
409
|
+
const usable = parsed.data.items.flatMap((item) => {
|
|
410
|
+
const group = groupSchema.safeParse(item);
|
|
411
|
+
return group.success && group.data.module_id === WORDPRESS_MODULE && group.data.enabled !== false ? [group.data] : [];
|
|
412
|
+
});
|
|
413
|
+
const chosen = usable.find((group) => group.default === true) ?? usable[0];
|
|
414
|
+
if (chosen === void 0) throw new ApplyConflictError("no_redirect_group", "Redirection has no enabled WordPress group to add the rule to");
|
|
415
|
+
return chosen.id;
|
|
416
|
+
};
|
|
417
|
+
const ruleStamp = (rule) => rule === void 0 ? "redirect:none" : `redirect:${JSON.stringify([
|
|
418
|
+
rule.id,
|
|
419
|
+
rule.target,
|
|
420
|
+
rule.code,
|
|
421
|
+
rule.enabled
|
|
422
|
+
])}`;
|
|
423
|
+
const ruleValue = (rule, path) => {
|
|
424
|
+
if (rule === void 0) return null;
|
|
425
|
+
return path === "code" ? rule.code : rule.target;
|
|
426
|
+
};
|
|
427
|
+
const requestedRuleValue = (operation, path) => path === "code" ? operation.code : operation.target;
|
|
428
|
+
const redirectionAdminUrl = (apiUrl) => new URL("/wp-admin/tools.php?page=redirection.php", apiUrl).toString();
|
|
429
|
+
const assertWritable = (operation, rule) => {
|
|
430
|
+
if (rule !== void 0 && !rule.enabled) throw new ApplyConflictError("redirect_disabled", `The Redirection rule for ${operation.source} is disabled; enable or delete it in Redirection first`);
|
|
431
|
+
};
|
|
432
|
+
const preflightRedirect = async (client, operation, telemetry, index) => {
|
|
433
|
+
const rule = await findRule(client, operation.source, telemetry);
|
|
434
|
+
assertWritable(operation, rule);
|
|
435
|
+
if (rule === void 0) await defaultGroup(client, telemetry);
|
|
436
|
+
return {
|
|
437
|
+
editUrl: redirectionAdminUrl(client.apiUrl),
|
|
438
|
+
editor: REDIRECTION_EDITOR,
|
|
439
|
+
fields: REDIRECT_PATHS.map((path) => {
|
|
440
|
+
const before = ruleValue(rule, path);
|
|
441
|
+
const after = requestedRuleValue(operation, path);
|
|
442
|
+
return {
|
|
443
|
+
after: previewValue(after),
|
|
444
|
+
before: previewValue(before),
|
|
445
|
+
changed: !deepEqual(before, after),
|
|
446
|
+
path
|
|
447
|
+
};
|
|
448
|
+
}),
|
|
449
|
+
modifiedGmt: ruleStamp(rule),
|
|
450
|
+
operationIndex: index,
|
|
451
|
+
sentence: operationSentence(operation),
|
|
452
|
+
slug: operation.source,
|
|
453
|
+
target: operationTarget(operation)
|
|
454
|
+
};
|
|
455
|
+
};
|
|
456
|
+
const ruleBody = (operation, groupId, title) => ({
|
|
457
|
+
action_code: operation.code,
|
|
458
|
+
action_data: { url: operation.target },
|
|
459
|
+
action_type: "url",
|
|
460
|
+
group_id: groupId,
|
|
461
|
+
match_type: "url",
|
|
462
|
+
regex: false,
|
|
463
|
+
title,
|
|
464
|
+
url: operation.source
|
|
465
|
+
});
|
|
466
|
+
const writeRule = async (client, operation, rule, telemetry) => {
|
|
467
|
+
const body = rule === void 0 ? ruleBody(operation, await defaultGroup(client, telemetry), "") : ruleBody(operation, rule.groupId, rule.title);
|
|
468
|
+
return await wpFetch(client, rule === void 0 ? REDIRECTS_PATH : `${REDIRECTS_PATH}/${rule.id}`, telemetry, {
|
|
469
|
+
body: JSON.stringify(body),
|
|
470
|
+
headers: { "Content-Type": "application/json" },
|
|
471
|
+
method: "POST"
|
|
472
|
+
});
|
|
473
|
+
};
|
|
474
|
+
const applyRedirect = async (client, operation, snapshot, telemetry) => {
|
|
475
|
+
const target = operationTarget(operation);
|
|
476
|
+
const result = (status, changed, error) => {
|
|
477
|
+
const receipt = {
|
|
478
|
+
changed_paths: changed,
|
|
479
|
+
kind: operation.kind,
|
|
480
|
+
normalized_paths: [],
|
|
481
|
+
operation_index: snapshot.operationIndex,
|
|
482
|
+
status,
|
|
483
|
+
target
|
|
484
|
+
};
|
|
485
|
+
if (error !== void 0) receipt.error = error;
|
|
486
|
+
return receipt;
|
|
487
|
+
};
|
|
488
|
+
if (snapshot.target !== target) return result("failed", [], {
|
|
489
|
+
code: "preflight_mismatch",
|
|
490
|
+
message: "The approval preview describes a different target"
|
|
491
|
+
});
|
|
492
|
+
let before;
|
|
493
|
+
try {
|
|
494
|
+
before = await findRule(client, operation.source, telemetry);
|
|
495
|
+
} catch (error) {
|
|
496
|
+
return result("failed", [], toReceiptError(error));
|
|
497
|
+
}
|
|
498
|
+
if (ruleStamp(before) !== snapshot.modifiedGmt) return result("conflict", [], {
|
|
499
|
+
code: "modified_since_preflight",
|
|
500
|
+
message: `${target} changed after the approval preview`
|
|
501
|
+
});
|
|
502
|
+
const changed = REDIRECT_PATHS.filter((path) => !deepEqual(ruleValue(before, path), requestedRuleValue(operation, path)));
|
|
503
|
+
if (changed.length === 0) return result("unchanged", []);
|
|
504
|
+
let write;
|
|
505
|
+
try {
|
|
506
|
+
write = await writeRule(client, operation, before, telemetry);
|
|
507
|
+
} catch (error) {
|
|
508
|
+
return result("failed", [], toReceiptError(error));
|
|
509
|
+
}
|
|
510
|
+
if (write.status < 200 || write.status >= 300) return result("failed", [], {
|
|
511
|
+
code: write.errorCode ?? "write_failed",
|
|
512
|
+
message: wordPressErrorMessage(write.status, write.errorCode) ?? `Redirection rejected the rule for ${operation.source}`
|
|
513
|
+
});
|
|
514
|
+
let after;
|
|
515
|
+
try {
|
|
516
|
+
after = await findRule(client, operation.source, telemetry);
|
|
517
|
+
} catch (error) {
|
|
518
|
+
return result("failed", [], toReceiptError(error));
|
|
519
|
+
}
|
|
520
|
+
const missed = changed.filter((path) => !deepEqual(ruleValue(after, path), requestedRuleValue(operation, path)));
|
|
521
|
+
if (missed.length > 0) return result("failed", changed.filter((path) => !missed.includes(path)), {
|
|
522
|
+
code: "readback_mismatch",
|
|
523
|
+
message: missedPathsMessage(missed)
|
|
524
|
+
});
|
|
525
|
+
return result("changed", changed);
|
|
526
|
+
};
|
|
527
|
+
// src/wp-apply.ts
|
|
285
528
|
const ACF_PREFIX = "acf.";
|
|
529
|
+
const META_PREFIX = "meta.";
|
|
286
530
|
const UNREADABLE_MODIFIED_GMT_CODE = "unreadable_modified_gmt";
|
|
287
531
|
const UNREADABLE_MODIFIED_GMT_MESSAGE = "WordPress did not report when this post was last modified, so the change cannot be checked against the approval preview";
|
|
288
532
|
const recordSchema = z.record(z.string(), z.json());
|
|
289
533
|
const stringSchema = z.string();
|
|
290
534
|
const editableFieldSchema = z.object({ raw: z.json() });
|
|
535
|
+
const nestedValue = (container, key) => {
|
|
536
|
+
const parsed = recordSchema.safeParse(container);
|
|
537
|
+
return parsed.success ? parsed.data[key] : void 0;
|
|
538
|
+
};
|
|
291
539
|
const readPath = (record, path) => {
|
|
292
|
-
if (path.startsWith(ACF_PREFIX))
|
|
293
|
-
|
|
294
|
-
return acf.success ? acf.data[path.slice(4)] : void 0;
|
|
295
|
-
}
|
|
540
|
+
if (path.startsWith(ACF_PREFIX)) return nestedValue(record.acf, path.slice(4));
|
|
541
|
+
if (path.startsWith(META_PREFIX)) return nestedValue(record.meta, path.slice(5));
|
|
296
542
|
const value = record[path];
|
|
297
543
|
const editable = editableFieldSchema.safeParse(value);
|
|
298
544
|
return editable.success ? editable.data.raw : value;
|
|
299
545
|
};
|
|
300
546
|
const requestedValue = (operation, path) => {
|
|
301
547
|
if (path.startsWith(ACF_PREFIX)) return operation.acf?.[path.slice(4)];
|
|
548
|
+
if (path.startsWith(META_PREFIX)) return operation.meta?.[path.slice(5)];
|
|
302
549
|
const { fields } = operation;
|
|
303
550
|
if (fields === void 0) return;
|
|
304
551
|
return Object.entries(fields).find(([key]) => key === path)?.[1];
|
|
@@ -319,23 +566,7 @@ const readRecord = async (client, operation, telemetry) => {
|
|
|
319
566
|
if (result.status < 200 || result.status >= 300 || !parsed.success) throw new ApplyConflictError(result.errorCode ?? "read_failed", wordPressErrorMessage(result.status, result.errorCode) ?? `Could not read ${operationTarget(operation)}`);
|
|
320
567
|
return parsed.data;
|
|
321
568
|
};
|
|
322
|
-
const
|
|
323
|
-
if (error instanceof ApplyConflictError) return {
|
|
324
|
-
code: error.code,
|
|
325
|
-
message: error.message
|
|
326
|
-
};
|
|
327
|
-
if (error instanceof UpstreamRequestError) return {
|
|
328
|
-
code: "wordpress_request_failed",
|
|
329
|
-
message: error.message
|
|
330
|
-
};
|
|
331
|
-
return {
|
|
332
|
-
code: "unexpected_error",
|
|
333
|
-
message: "The operation failed"
|
|
334
|
-
};
|
|
335
|
-
};
|
|
336
|
-
const preflightRemaining = async (client, operations, telemetry, index, collected) => {
|
|
337
|
-
const operation = operations.at(index);
|
|
338
|
-
if (operation === void 0) return collected;
|
|
569
|
+
const preflightPost = async (client, operation, telemetry, index) => {
|
|
339
570
|
const record = await readRecord(client, operation, telemetry);
|
|
340
571
|
const current = modifiedGmt(record);
|
|
341
572
|
if (current === void 0) throw new ApplyConflictError(UNREADABLE_MODIFIED_GMT_CODE, UNREADABLE_MODIFIED_GMT_MESSAGE);
|
|
@@ -350,7 +581,7 @@ const preflightRemaining = async (client, operations, telemetry, index, collecte
|
|
|
350
581
|
path
|
|
351
582
|
};
|
|
352
583
|
});
|
|
353
|
-
|
|
584
|
+
return {
|
|
354
585
|
editUrl: adminEditUrl(client.apiUrl, operation.id),
|
|
355
586
|
fields,
|
|
356
587
|
modifiedGmt: current,
|
|
@@ -358,7 +589,12 @@ const preflightRemaining = async (client, operations, telemetry, index, collecte
|
|
|
358
589
|
sentence: operationSentence(operation),
|
|
359
590
|
slug: postSlug(record),
|
|
360
591
|
target: operationTarget(operation)
|
|
361
|
-
}
|
|
592
|
+
};
|
|
593
|
+
};
|
|
594
|
+
const preflightRemaining = async (client, operations, telemetry, index, collected) => {
|
|
595
|
+
const operation = operations.at(index);
|
|
596
|
+
if (operation === void 0) return collected;
|
|
597
|
+
collected.push(isRedirectOperation(operation) ? await preflightRedirect(client, operation, telemetry, index) : await preflightPost(client, operation, telemetry, index));
|
|
362
598
|
return preflightRemaining(client, operations, telemetry, index + 1, collected);
|
|
363
599
|
};
|
|
364
600
|
const preflightOperations = async (client, intent, operations, telemetry) => {
|
|
@@ -374,6 +610,7 @@ const writeBody = (operation) => {
|
|
|
374
610
|
for (const [key, value] of Object.entries(fields)) if (value !== void 0) body[key] = value;
|
|
375
611
|
}
|
|
376
612
|
if (operation.acf !== void 0) body.acf = operation.acf;
|
|
613
|
+
if (operation.meta !== void 0) body.meta = operation.meta;
|
|
377
614
|
return body;
|
|
378
615
|
};
|
|
379
616
|
const classifyReadback = (operation, before, after) => {
|
|
@@ -398,13 +635,7 @@ const classifyReadback = (operation, before, after) => {
|
|
|
398
635
|
normalized
|
|
399
636
|
};
|
|
400
637
|
};
|
|
401
|
-
const
|
|
402
|
-
const listed = missed.slice(0, MISSED_PATH_LIMIT);
|
|
403
|
-
const extra = missed.length - listed.length;
|
|
404
|
-
const suffix = extra > 0 ? ` and ${extra} more` : "";
|
|
405
|
-
return `WordPress did not store ${listed.join(", ")}${suffix}`;
|
|
406
|
-
};
|
|
407
|
-
const applyOne = async (client, operation, snapshot, telemetry) => {
|
|
638
|
+
const applyPost = async (client, operation, snapshot, telemetry) => {
|
|
408
639
|
const base = {
|
|
409
640
|
kind: "update_post",
|
|
410
641
|
operation_index: snapshot.operationIndex,
|
|
@@ -520,13 +751,13 @@ const applyRemaining = async (client, operations, preflight, telemetry, index, c
|
|
|
520
751
|
code: "missing_preflight",
|
|
521
752
|
message: "No approval preview exists for this operation"
|
|
522
753
|
},
|
|
523
|
-
kind:
|
|
754
|
+
kind: operation.kind,
|
|
524
755
|
normalized_paths: [],
|
|
525
756
|
operation_index: index,
|
|
526
757
|
status: "failed",
|
|
527
758
|
target: operationTarget(operation)
|
|
528
759
|
});
|
|
529
|
-
else collected.push(await
|
|
760
|
+
else collected.push(isRedirectOperation(operation) ? await applyRedirect(client, operation, snapshot, telemetry) : await applyPost(client, operation, snapshot, telemetry));
|
|
530
761
|
return applyRemaining(client, operations, preflight, telemetry, index + 1, collected);
|
|
531
762
|
};
|
|
532
763
|
const applyOperations = async (options) => {
|
|
@@ -545,7 +776,7 @@ const WP_CONNECTOR_INSTRUCTIONS = [
|
|
|
545
776
|
"Reads: wp.graphql({query, variables?}) runs WPGraphQL (introspection allowed);",
|
|
546
777
|
"wp.get({path, query?}) GETs any /wp-json/ path and throws on a non-2xx response.",
|
|
547
778
|
"Writes: wp.apply({intent, operations}) is the only write path and pauses for human approval.",
|
|
548
|
-
`Send 1-${MAX_OPERATIONS} update_post operations per call and set expected_modified_gmt when you read the post first.`
|
|
779
|
+
`Send 1-${MAX_OPERATIONS} update_post or upsert_redirect operations per call and set expected_modified_gmt when you read the post first.`
|
|
549
780
|
].join(" ");
|
|
550
781
|
const assertQueryOnly = (query) => {
|
|
551
782
|
let document;
|
|
@@ -584,7 +815,7 @@ const queryString = (query) => {
|
|
|
584
815
|
};
|
|
585
816
|
const createWordPressTools = (deps) => ({
|
|
586
817
|
apply: {
|
|
587
|
-
description: `Apply 1-${MAX_OPERATIONS} update_post operations to WordPress. Give the work a concise, one-sentence intent for the human approval card. Pauses for human approval; returns one receipt per operation.`,
|
|
818
|
+
description: `Apply 1-${MAX_OPERATIONS} update_post or upsert_redirect operations to WordPress. Give the work a concise, one-sentence intent for the human approval card. Pauses for human approval; returns one receipt per operation.`,
|
|
588
819
|
execute: async (args, ctx) => {
|
|
589
820
|
const input = parseApplyInput(args);
|
|
590
821
|
const caller = deps.scope.require();
|
|
@@ -664,7 +895,8 @@ const wordpressConnector = {
|
|
|
664
895
|
executeDescription: [
|
|
665
896
|
"The wp global reaches WordPress.",
|
|
666
897
|
"Reads: await wp.graphql({ query, variables? }); or await wp.get({ path, query? }), where path starts /wp-json/, contains no ?, and query values are scalars.",
|
|
667
|
-
"Writes: await wp.apply({ intent, operations }), with 1-20 operations shaped { kind: \"update_post\", rest_base, id, expected_modified_gmt?, fields?, acf? }
|
|
898
|
+
"Writes: await wp.apply({ intent, operations }), with 1-20 operations shaped { kind: \"update_post\", rest_base, id, expected_modified_gmt?, fields?, acf?, meta? } or { kind: \"upsert_redirect\", source, target, code? }. fields may contain title, content, excerpt, slug, or status; meta holds registered post meta as the post's context: \"edit\" read shows it under meta (keys missing there are not writable). Put all changes for one post in one operation. Read with context: \"edit\" first and copy modified_gmt to expected_modified_gmt.",
|
|
899
|
+
"upsert_redirect creates or updates the Redirection plugin rule for one site path (source, such as /old-page) to a site path or URL (target), with code 301 (default), 302, 307 or 308. Read existing rules with wp.get({ path: \"/wp-json/redirection/v1/redirect\", query: { \"filterBy[url]\": source } }).",
|
|
668
900
|
"Read example: async () => await wp.get({ path: \"/wp-json/wp/v2/posts/123\", query: { context: \"edit\" } }).",
|
|
669
901
|
"Write example: async () => { const post = await wp.get({ path: \"/wp-json/wp/v2/posts/123\", query: { context: \"edit\" } }); return await wp.apply({ intent: \"Fix the title.\", operations: [{ kind: \"update_post\", rest_base: \"posts\", id: post.id, expected_modified_gmt: post.modified_gmt, fields: { title: \"Correct title\" } }] }); }"
|
|
670
902
|
].join(" "),
|
package/package.json
CHANGED