drupal-mcp-connector 2.15.1 → 2.16.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/.agents/commands/drupal-codegen-diff.md +17 -0
- package/.agents/commands/drupal-codegen-generate.md +17 -0
- package/.agents/commands/drupal-codegen-inspect.md +17 -0
- package/.agents/commands/drupal-content-by-moderation-state.md +4 -3
- package/.agents/commands/drupal-describe-fields.md +2 -2
- package/.agents/commands/drupal-get-node.md +4 -3
- package/.agents/commands/drupal-get-taxonomy-term.md +4 -3
- package/.agents/commands/drupal-list-revisions.md +2 -2
- package/.agents/commands/drupal-list-translations.md +2 -2
- package/.agents/commands/drupal-report-translation-coverage.md +4 -5
- package/.agents/commands/drupal-report-workflow-bottlenecks.md +2 -1
- package/.agents/commands/drupal-set-moderation-state.md +4 -3
- package/.agents/commands/drupal-update-menu-link.md +2 -1
- package/.agents/commands/drupal-update-node.md +2 -2
- package/.agents/commands/drupal-update-taxonomy-term.md +4 -3
- package/CHANGELOG.md +51 -0
- package/README.md +3 -3
- package/bin/drupal-mcp-agent.js +2 -2
- package/package.json +2 -2
- package/scripts/generate-commands.js +2 -2
- package/src/index.js +2 -2
- package/src/lib/backends/jsonapi.js +85 -9
- package/src/lib/config.js +18 -1
- package/src/lib/err-relationships.js +22 -0
- package/src/lib/mcp-server.js +1 -1
- package/src/lib/node-draft-inventory.js +47 -0
- package/src/lib/patch-preflight.js +23 -6
- package/src/lib/server-tools.js +2 -2
- package/src/lib/translation-rows.js +59 -0
- package/src/lib/write-revision.js +9 -2
- package/src/tools/codegen.js +150 -0
- package/src/tools/entities.js +1 -0
- package/src/tools/fields.js +12 -4
- package/src/tools/index.js +2 -1
- package/src/tools/moderation.js +82 -13
- package/src/tools/nodes.js +60 -8
- package/src/tools/reports-content.js +117 -28
- package/src/tools/revisions.js +18 -2
- package/src/tools/structure.js +6 -3
- package/src/tools/taxonomy.js +15 -6
- package/src/tools/translations.js +4 -8
package/src/lib/config.js
CHANGED
|
@@ -16,9 +16,26 @@ import { getAccessToken } from "./oauth.js";
|
|
|
16
16
|
// eslint-disable-next-line security/detect-non-literal-fs-filename -- fixed path relative to this module (the package's own package.json), not user input
|
|
17
17
|
const pkg = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8"));
|
|
18
18
|
|
|
19
|
+
/** npm / protocol machine name. Public identifier — not the display name. */
|
|
20
|
+
export const CLIENT_NAME = "drupal-mcp-connector";
|
|
21
|
+
|
|
22
|
+
/** Human-readable product name for docs and MCP `serverInfo.title`. */
|
|
23
|
+
export const CLIENT_TITLE = "Drupal MCP Connector";
|
|
24
|
+
|
|
19
25
|
/** Connector version, sourced from package.json so it never drifts out of sync. */
|
|
20
26
|
export const CLIENT_VERSION = pkg.version;
|
|
21
27
|
|
|
28
|
+
/**
|
|
29
|
+
* MCP Implementation advertised in the handshake.
|
|
30
|
+
* `name` is the protocol identifier; `title` is the display name.
|
|
31
|
+
* @type {{name: string, title: string, version: string}}
|
|
32
|
+
*/
|
|
33
|
+
export const SERVER_INFO = {
|
|
34
|
+
name: CLIENT_NAME,
|
|
35
|
+
title: CLIENT_TITLE,
|
|
36
|
+
version: CLIENT_VERSION,
|
|
37
|
+
};
|
|
38
|
+
|
|
22
39
|
/**
|
|
23
40
|
* Identity headers sent on every outbound Drupal request. Lets governance layers
|
|
24
41
|
* label/identify connector traffic. ON by default; set MCP_CLIENT_ID to override
|
|
@@ -26,7 +43,7 @@ export const CLIENT_VERSION = pkg.version;
|
|
|
26
43
|
* @returns {Object<string,string>} Header map (empty when the identity is disabled).
|
|
27
44
|
*/
|
|
28
45
|
export function clientHeaders() {
|
|
29
|
-
const id = process.env.MCP_CLIENT_ID ??
|
|
46
|
+
const id = process.env.MCP_CLIENT_ID ?? `${CLIENT_NAME}/${CLIENT_VERSION}`;
|
|
30
47
|
if (!id) return {};
|
|
31
48
|
return { "X-MCP-Client": id, "User-Agent": id };
|
|
32
49
|
}
|
|
@@ -38,6 +38,28 @@ export function isParagraphResourceType(type) {
|
|
|
38
38
|
return typeof type === "string" && type.startsWith("paragraph--");
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Paragraph ERR pins on a canonical host entity.
|
|
43
|
+
* @param {object} entity Canonical node (or other host).
|
|
44
|
+
* @returns {Array<{field: string, id: string, paragraphType: string, revisionId: ?string}>}
|
|
45
|
+
*/
|
|
46
|
+
export function paragraphPinsFromEntity(entity) {
|
|
47
|
+
const pins = [];
|
|
48
|
+
const rels = entity?.relationships && typeof entity.relationships === "object" ? entity.relationships : {};
|
|
49
|
+
for (const [field, value] of Object.entries(rels)) {
|
|
50
|
+
const list = Array.isArray(value) ? value : value ? [value] : [];
|
|
51
|
+
for (const ref of list) {
|
|
52
|
+
if (!ref?.id) continue;
|
|
53
|
+
const paragraphType = ref.bundle || parseResourceType(ref.type)?.bundle;
|
|
54
|
+
if (ref.entityType !== "paragraph" && !isParagraphResourceType(ref.type)) continue;
|
|
55
|
+
if (!paragraphType) continue;
|
|
56
|
+
const revisionId = ref.meta?.target_revision_id ?? null;
|
|
57
|
+
pins.push({ field, id: ref.id, paragraphType, revisionId });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return pins;
|
|
61
|
+
}
|
|
62
|
+
|
|
41
63
|
/**
|
|
42
64
|
* Split a JSON:API resource type into entity type + bundle.
|
|
43
65
|
* @param {string} type e.g. "paragraph--capability".
|
package/src/lib/mcp-server.js
CHANGED
|
@@ -29,7 +29,7 @@ function resourceUriIsListed(listed, requested) {
|
|
|
29
29
|
* Create the server factory shared by HTTP and stdio transports.
|
|
30
30
|
*
|
|
31
31
|
* @param {object} surface
|
|
32
|
-
* @param {{name: string, version: string}} surface.serverInfo
|
|
32
|
+
* @param {{name: string, title?: string, version: string}} surface.serverInfo
|
|
33
33
|
* @param {{definitions: Array<object>, list?: () => Promise<Array<object>>, call: (name: string, args: object, context: object) => Promise<object>}} surface.tools
|
|
34
34
|
* `definitions` is the full static surface (schema projection); the optional
|
|
35
35
|
* `list` hook decides what is DISCOVERABLE per request (governance + entitlement).
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/** Sentinel discovery for translation-only node revisions (#297). */
|
|
2
|
+
import { readTranslationInventory } from "./draft-write.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Read optional inventory, falling back only when the endpoint is unsupported.
|
|
6
|
+
* Permission, transport and malformed-response failures are not absence.
|
|
7
|
+
* @param {object} backend
|
|
8
|
+
* @param {{entityType: string, bundle: string, id: string}} ref
|
|
9
|
+
* @returns {Promise<object|null>}
|
|
10
|
+
*/
|
|
11
|
+
export async function readNodeDraftInventory(backend, ref) {
|
|
12
|
+
if (ref.entityType !== "node" || typeof backend.rawQuery !== "function"
|
|
13
|
+
|| typeof backend.resourcePath !== "function") return null;
|
|
14
|
+
let inventory;
|
|
15
|
+
try {
|
|
16
|
+
inventory = await readTranslationInventory(backend, ref);
|
|
17
|
+
} catch (error) {
|
|
18
|
+
if (/does not provide Sentinel's governed draft-translation endpoint/.test(error.message)) return null;
|
|
19
|
+
throw error;
|
|
20
|
+
}
|
|
21
|
+
const validVid = (vid) => /^[1-9]\d*$/.test(String(vid ?? "")) && Number.isSafeInteger(Number(vid));
|
|
22
|
+
if (!validVid(inventory.live?.vid)
|
|
23
|
+
|| (inventory.working && (!validVid(inventory.working.vid)
|
|
24
|
+
|| !Array.isArray(inventory.working.translations)
|
|
25
|
+
|| inventory.working.translations.some((row) => !row || typeof row.langcode !== "string"
|
|
26
|
+
|| typeof row.status !== "boolean")))) {
|
|
27
|
+
throw new Error("Sentinel returned an invalid node revision inventory. Re-read before updating.");
|
|
28
|
+
}
|
|
29
|
+
return inventory;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Ensure inventory discovery does not turn a published language into a draft.
|
|
34
|
+
* Sentinel still validates the revision pair and language on every request.
|
|
35
|
+
* @param {object} inventory
|
|
36
|
+
* @param {string|undefined} langcode
|
|
37
|
+
*/
|
|
38
|
+
export function assertInventoryDraftLanguage(inventory, langcode) {
|
|
39
|
+
const rows = inventory.working?.translations ?? [];
|
|
40
|
+
if (!langcode && rows.length !== 1) {
|
|
41
|
+
throw new Error("This working revision contains translations. Pass an explicit langcode for an existing unpublished language; no draft was created.");
|
|
42
|
+
}
|
|
43
|
+
const row = langcode ? rows.find((item) => item.langcode === langcode) : rows[0];
|
|
44
|
+
if (!row || row.status !== false) {
|
|
45
|
+
throw new Error("The requested language is not an existing unpublished working draft. Published languages and other drafts were left unchanged.");
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
* Sentinel's /mcp-draft endpoint with verified live/working revision IDs.
|
|
17
17
|
* Core rejects resourceVersion on PATCH. Never retry the canonical URL.
|
|
18
18
|
* - **#201** — the working-copy alias does not resolve, but core still
|
|
19
|
-
* blocks.
|
|
20
|
-
*
|
|
19
|
+
* blocks. Consult Sentinel inventory for translation-only revisions before
|
|
20
|
+
* refusing the unresolved revision conflict. `workingCopy: null` is not proof the node is writable.
|
|
21
21
|
*
|
|
22
22
|
* Unsupported endpoints and stale revisions fail closed, without discard.
|
|
23
23
|
*/
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
import { entityLooksModerated, hasExplicitModerationState } from "./moderation-default.js";
|
|
26
26
|
import { entityRevisionId } from "./write-revision.js";
|
|
27
27
|
import { writeDraft } from "./draft-write.js";
|
|
28
|
+
import { readNodeDraftInventory, assertInventoryDraftLanguage } from "./node-draft-inventory.js";
|
|
28
29
|
|
|
29
30
|
/** Stable error code for a core working-copy / not-latest-revision block. */
|
|
30
31
|
export const PATCH_BLOCKED_CODE = "PATCH_BLOCKED";
|
|
@@ -48,7 +49,7 @@ export const PATCH_BLOCKED_MESSAGE =
|
|
|
48
49
|
"This entity cannot be updated over JSON:API because the stored entity is not " +
|
|
49
50
|
"the latest revision (Drupal core #2795279). The JSON:API aliases " +
|
|
50
51
|
"rel:latest-version and rel:working-copy cannot show the blocking row. " +
|
|
51
|
-
"
|
|
52
|
+
"Inspect the translation inventory and revision history before considering repair; do not delete drafts. " +
|
|
52
53
|
"See connector #201. Do not retry the same canonical PATCH.";
|
|
53
54
|
|
|
54
55
|
/**
|
|
@@ -248,17 +249,32 @@ export async function loadWorkingCopy(backend, { entityType, bundle, id }) {
|
|
|
248
249
|
*/
|
|
249
250
|
export async function resolveWorkingCopyPatchTarget(backend, { entityType, bundle, id, existing }) {
|
|
250
251
|
const workingCopy = await loadWorkingCopy(backend, { entityType, bundle, id });
|
|
252
|
+
if (workingCopy?.id && workingCopy.id !== id) {
|
|
253
|
+
throw new PatchTargetAmbiguousError(id, workingCopy.id);
|
|
254
|
+
}
|
|
251
255
|
let liveVid = entityRevisionId(existing);
|
|
252
256
|
if (workingCopy && (liveVid === null || liveVid === undefined) && typeof backend?.getEntity === "function") {
|
|
253
257
|
const live = await backend.getEntity({ entityType, bundle, id }).catch(() => null);
|
|
254
258
|
liveVid = entityRevisionId(live);
|
|
255
259
|
}
|
|
260
|
+
const aliasVid = entityRevisionId(workingCopy);
|
|
261
|
+
if (!workingCopy || (liveVid !== null && String(aliasVid) === String(liveVid))) {
|
|
262
|
+
const inventory = await readNodeDraftInventory(backend, { entityType, bundle, id });
|
|
263
|
+
if (inventory) {
|
|
264
|
+
if (liveVid !== null && String(liveVid) !== String(inventory.live.vid)) {
|
|
265
|
+
throw new WorkingCopyStaleError(new Error("Sentinel's live revision changed during discovery."));
|
|
266
|
+
}
|
|
267
|
+
liveVid = Number(inventory.live.vid);
|
|
268
|
+
const workingVid = inventory.working ? Number(inventory.working.vid) : null;
|
|
269
|
+
if (workingVid !== null && workingVid !== liveVid) {
|
|
270
|
+
return { resourceVersion: `id:${workingVid}`, workingCopy: null,
|
|
271
|
+
liveVid, workingVid, inventory };
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
256
275
|
if (!workingCopy) {
|
|
257
276
|
return { resourceVersion: undefined, workingCopy: null, liveVid, workingVid: null };
|
|
258
277
|
}
|
|
259
|
-
if (workingCopy.id && workingCopy.id !== id) {
|
|
260
|
-
throw new PatchTargetAmbiguousError(id, workingCopy.id);
|
|
261
|
-
}
|
|
262
278
|
const workingVid = entityRevisionId(workingCopy);
|
|
263
279
|
if (workingVid !== null && liveVid !== null && String(workingVid) === String(liveVid)) {
|
|
264
280
|
return { resourceVersion: undefined, workingCopy, liveVid, workingVid };
|
|
@@ -402,6 +418,7 @@ export async function prepareGuardedPatch(backend, {
|
|
|
402
418
|
throw new StaleCopyError();
|
|
403
419
|
}
|
|
404
420
|
}
|
|
421
|
+
if (target.inventory) assertInventoryDraftLanguage(target.inventory, langcode);
|
|
405
422
|
if (langcode) {
|
|
406
423
|
if (!target.workingVid || !target.liveVid || String(target.workingVid) === String(target.liveVid)) {
|
|
407
424
|
throw new Error(
|
package/src/lib/server-tools.js
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
25
|
import fetch from "node-fetch";
|
|
26
|
-
import { authHeadersAsync, clientHeaders, CLIENT_VERSION } from "./config.js";
|
|
26
|
+
import { authHeadersAsync, clientHeaders, CLIENT_NAME, CLIENT_VERSION } from "./config.js";
|
|
27
27
|
import { consumeBudgetIfEnforced, northboundHeaders, sourceBudgetDenial } from "./data-flow.js";
|
|
28
28
|
import { clearToken } from "./oauth.js";
|
|
29
29
|
|
|
@@ -164,7 +164,7 @@ async function initializeSession(site, endpoint) {
|
|
|
164
164
|
params: {
|
|
165
165
|
protocolVersion: MCP_PROTOCOL_VERSION,
|
|
166
166
|
capabilities: {},
|
|
167
|
-
clientInfo: { name:
|
|
167
|
+
clientInfo: { name: CLIENT_NAME, version: CLIENT_VERSION },
|
|
168
168
|
},
|
|
169
169
|
};
|
|
170
170
|
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalize Sentinel translation-inventory rows for tools and reports.
|
|
3
|
+
*
|
|
4
|
+
* Extra keys (`outdated`, `source`) are passed through only when present so
|
|
5
|
+
* older Sentinel versions do not grow invented `outdated: false`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @param {object} row Inventory translation row.
|
|
10
|
+
* @returns {object}
|
|
11
|
+
*/
|
|
12
|
+
export function mapTranslationRow(row) {
|
|
13
|
+
if (!row || typeof row !== "object") return row;
|
|
14
|
+
const out = {
|
|
15
|
+
langcode: row.langcode,
|
|
16
|
+
default: Boolean(row.default),
|
|
17
|
+
status: row.status,
|
|
18
|
+
title: row.title,
|
|
19
|
+
moderation_state: row.moderation_state ?? null,
|
|
20
|
+
};
|
|
21
|
+
if (Object.prototype.hasOwnProperty.call(row, "outdated")) {
|
|
22
|
+
out.outdated = Boolean(row.outdated);
|
|
23
|
+
}
|
|
24
|
+
if (typeof row.source === "string" && row.source) {
|
|
25
|
+
out.source = row.source;
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Editorial current row per langcode: working copy wins over live.
|
|
32
|
+
* @param {?object} inventory
|
|
33
|
+
* @returns {object[]}
|
|
34
|
+
*/
|
|
35
|
+
export function inventoryTranslationRows(inventory) {
|
|
36
|
+
const byLang = new Map();
|
|
37
|
+
for (const row of inventory?.live?.translations ?? []) {
|
|
38
|
+
if (row?.langcode) byLang.set(row.langcode, { ...mapTranslationRow(row), revision: "live" });
|
|
39
|
+
}
|
|
40
|
+
for (const row of inventory?.working?.translations ?? []) {
|
|
41
|
+
if (row?.langcode) byLang.set(row.langcode, { ...mapTranslationRow(row), revision: "working" });
|
|
42
|
+
}
|
|
43
|
+
return [...byLang.values()];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Whether inventory has a row matching langcode and/or moderation state.
|
|
48
|
+
* @param {?object} inventory
|
|
49
|
+
* @param {{langcode?: string, state?: string}} match
|
|
50
|
+
* @returns {?object}
|
|
51
|
+
*/
|
|
52
|
+
export function inventoryRowMatching(inventory, { langcode, state } = {}) {
|
|
53
|
+
const wantedState = state === undefined || state === null ? null : String(state).toLowerCase();
|
|
54
|
+
return inventoryTranslationRows(inventory).find((row) => {
|
|
55
|
+
if (langcode && row.langcode !== langcode) return false;
|
|
56
|
+
if (wantedState && String(row.moderation_state || "").toLowerCase() !== wantedState) return false;
|
|
57
|
+
return true;
|
|
58
|
+
}) ?? null;
|
|
59
|
+
}
|
|
@@ -65,7 +65,10 @@ export async function attachWrittenRevisionPair({
|
|
|
65
65
|
backend, entityType, bundle, id, entity, liveVid,
|
|
66
66
|
}) {
|
|
67
67
|
if (liveVid === null || liveVid === undefined || !entity) return entity;
|
|
68
|
-
let workingVid =
|
|
68
|
+
let workingVid = entityRevisionId(entity);
|
|
69
|
+
if (workingVid !== null && String(workingVid) !== String(liveVid)) {
|
|
70
|
+
return attachRevisionPair(entity, { live: liveVid, working: workingVid });
|
|
71
|
+
}
|
|
69
72
|
if (typeof backend?.getEntity === "function") {
|
|
70
73
|
try {
|
|
71
74
|
const wc = await backend.getEntity({
|
|
@@ -93,12 +96,16 @@ export async function attachWrittenRevisionPair({
|
|
|
93
96
|
* for the write (e.g. `rel:working-copy`). PreferCanonical re-reads this
|
|
94
97
|
* resource instead of the default revision so a draft PATCH is not
|
|
95
98
|
* replaced by the live body.
|
|
99
|
+
* @param {?string} [args.langcode] Language returned by governed draft continuation.
|
|
96
100
|
* @returns {Promise<object>} Entity to return, with `_revision` when relevant.
|
|
97
101
|
*/
|
|
98
102
|
export async function readWrittenRevision({
|
|
99
103
|
backend, entityType, bundle, id, relationshipsSent, patchResult = null, preferCanonical = false,
|
|
100
|
-
resourceVersion,
|
|
104
|
+
resourceVersion, langcode,
|
|
101
105
|
}) {
|
|
106
|
+
// Sentinel returned the requested translation; a canonical read would replace
|
|
107
|
+
// it with live English, and the core working-copy alias may not address it.
|
|
108
|
+
if (langcode && patchResult) return patchResult;
|
|
102
109
|
if (!relationshipsSent) {
|
|
103
110
|
if (preferCanonical && typeof backend.getEntity === "function") {
|
|
104
111
|
const fresh = await backend.getEntity({
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool group: GraphQL Compose Codegen (TypeScript / Next.js scaffolds).
|
|
3
|
+
*
|
|
4
|
+
* Thin Drush wrappers around `drupal/graphql_compose_codegen`. The generator
|
|
5
|
+
* lives on the Drupal site; this connector does not reimplement it and does
|
|
6
|
+
* not run GraphQL Code Generator against the SDL.
|
|
7
|
+
*
|
|
8
|
+
* All three tools are reads. `drupal_codegen_generate` always passes
|
|
9
|
+
* `--dry-run` and never `--output-dir`, so artefacts come back as text for
|
|
10
|
+
* the agent to write locally. Disk writes stay a local DDEV/script concern.
|
|
11
|
+
*
|
|
12
|
+
* Capability: missing module or unknown Drush command fails loud. If
|
|
13
|
+
* `drushSsh.allowedCommands` is set, the `graphql-compose-codegen:*`
|
|
14
|
+
* subcommand must be on that list (same exact-match rule as other Drush tools).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { getSiteConfig } from "../lib/config.js";
|
|
18
|
+
import { sshDrush } from "./drush.js";
|
|
19
|
+
import { SecurityError } from "../lib/security.js";
|
|
20
|
+
import { validateMachineName } from "../lib/validate.js";
|
|
21
|
+
|
|
22
|
+
const INSPECT = "graphql-compose-codegen:inspect";
|
|
23
|
+
const DIFF = "graphql-compose-codegen:diff";
|
|
24
|
+
const GENERATE = "graphql-compose-codegen:generate";
|
|
25
|
+
|
|
26
|
+
const GENERATE_TIMEOUT_MS = 60000;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Parse an optional list of Drupal machine names (array or comma string).
|
|
30
|
+
*
|
|
31
|
+
* @param {string[]|string|undefined} raw Caller value.
|
|
32
|
+
* @param {string} fieldName Error label.
|
|
33
|
+
* @returns {string[]} Validated names, possibly empty.
|
|
34
|
+
*/
|
|
35
|
+
function parseMachineNameList(raw, fieldName) {
|
|
36
|
+
if (raw === undefined || raw === null || raw === "") return [];
|
|
37
|
+
const parts = Array.isArray(raw)
|
|
38
|
+
? raw
|
|
39
|
+
: String(raw).split(",");
|
|
40
|
+
return parts
|
|
41
|
+
.map((v) => String(v).trim())
|
|
42
|
+
.filter(Boolean)
|
|
43
|
+
.map((v) => validateMachineName(v, fieldName));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Whether a Drush failure looks like the codegen module is absent.
|
|
48
|
+
*
|
|
49
|
+
* @param {Error} err Bridge error.
|
|
50
|
+
* @returns {boolean}
|
|
51
|
+
*/
|
|
52
|
+
function looksLikeMissingCodegen(err) {
|
|
53
|
+
const msg = String(err?.message || err || "");
|
|
54
|
+
return /graphql-compose-codegen|gqcc:|no commands defined in the ["']graphql-compose-codegen|command .* does not exist|could not find command/i.test(msg);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Run a codegen Drush subcommand and return stdout.
|
|
59
|
+
*
|
|
60
|
+
* @param {object} args Tool args.
|
|
61
|
+
* @param {string} [args.site] Site name.
|
|
62
|
+
* @param {string[]|string} [args.bundles] Bundle ids.
|
|
63
|
+
* @param {string[]|string} [args.skipFields] Extra field names to exclude.
|
|
64
|
+
* @param {string} subcommand Drush subcommand (first arg).
|
|
65
|
+
* @param {string[]} extra Extra flags (e.g. --dry-run).
|
|
66
|
+
* @param {number} timeoutMs SSH timeout.
|
|
67
|
+
* @returns {Promise<{output: string, command: string, wroteFiles: false}>}
|
|
68
|
+
*/
|
|
69
|
+
async function runGqcc(
|
|
70
|
+
{ site: siteName, bundles, skipFields },
|
|
71
|
+
subcommand,
|
|
72
|
+
extra = [],
|
|
73
|
+
timeoutMs = 30000,
|
|
74
|
+
) {
|
|
75
|
+
const site = getSiteConfig(siteName);
|
|
76
|
+
const names = parseMachineNameList(bundles, "bundles");
|
|
77
|
+
const skip = parseMachineNameList(skipFields, "skipFields");
|
|
78
|
+
const args = [subcommand];
|
|
79
|
+
if (names.length) args.push(`--bundles=${names.join(",")}`);
|
|
80
|
+
if (skip.length) args.push(`--skip-fields=${skip.join(",")}`);
|
|
81
|
+
args.push(...extra);
|
|
82
|
+
try {
|
|
83
|
+
const output = await sshDrush(site, args, timeoutMs);
|
|
84
|
+
return { output, command: args.join(" "), wroteFiles: false };
|
|
85
|
+
} catch (err) {
|
|
86
|
+
if (err instanceof SecurityError) throw err;
|
|
87
|
+
if (looksLikeMissingCodegen(err)) {
|
|
88
|
+
throw new Error(
|
|
89
|
+
"graphql_compose_codegen is not available on this site " +
|
|
90
|
+
`(Drush command "${subcommand}" failed). Install and enable ` +
|
|
91
|
+
"drupal/graphql_compose_codegen, and if drushSsh.allowedCommands is " +
|
|
92
|
+
`set, add "${subcommand}" to that list. ${err.message}`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
throw err;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function inspect(args) {
|
|
100
|
+
return runGqcc(args, INSPECT);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function diff(args) {
|
|
104
|
+
return runGqcc(args, DIFF);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function generate(args) {
|
|
108
|
+
return runGqcc(args, GENERATE, ["--dry-run"], GENERATE_TIMEOUT_MS);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const LIST_PROPS = {
|
|
112
|
+
site: { type: "string" },
|
|
113
|
+
bundles: {
|
|
114
|
+
type: "array",
|
|
115
|
+
items: { type: "string", pattern: "^[a-z][a-z0-9_]*$" },
|
|
116
|
+
description: "Node/paragraph bundle ids. Omit for every bundle graphql_compose exposes.",
|
|
117
|
+
},
|
|
118
|
+
skipFields: {
|
|
119
|
+
type: "array",
|
|
120
|
+
items: { type: "string", pattern: "^[a-z][a-z0-9_]*$" },
|
|
121
|
+
description: "Extra field machine names to exclude from the scaffold.",
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
export const definitions = [
|
|
126
|
+
{
|
|
127
|
+
name: "drupal_codegen_inspect",
|
|
128
|
+
description:
|
|
129
|
+
"List node and paragraph bundles and extra fields from graphql_compose_codegen (`drush graphql-compose-codegen:inspect`). Requires the module and drushSsh. Missing command fails loud. If allowedCommands is set, include graphql-compose-codegen:inspect. Does not write files.",
|
|
130
|
+
inputSchema: { type: "object", properties: LIST_PROPS },
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
name: "drupal_codegen_diff",
|
|
134
|
+
description:
|
|
135
|
+
"Compare the live graphql_compose schema to the last gqcc:generate snapshot (`drush graphql-compose-codegen:diff`). Requires the module and drushSsh. Missing command fails loud. If allowedCommands is set, include graphql-compose-codegen:diff.",
|
|
136
|
+
inputSchema: { type: "object", properties: LIST_PROPS },
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
name: "drupal_codegen_generate",
|
|
140
|
+
description:
|
|
141
|
+
"Return TypeScript/GraphQL scaffold artefacts from graphql_compose_codegen as text (`drush graphql-compose-codegen:generate --dry-run`). Never writes on the Drupal host (no --output-dir). Copy artefacts locally. Requires the module and drushSsh. Missing command fails loud. If allowedCommands is set, include graphql-compose-codegen:generate.",
|
|
142
|
+
inputSchema: { type: "object", properties: LIST_PROPS },
|
|
143
|
+
},
|
|
144
|
+
];
|
|
145
|
+
|
|
146
|
+
export const handlers = {
|
|
147
|
+
drupal_codegen_inspect: inspect,
|
|
148
|
+
drupal_codegen_diff: diff,
|
|
149
|
+
drupal_codegen_generate: generate,
|
|
150
|
+
};
|
package/src/tools/entities.js
CHANGED
|
@@ -136,6 +136,7 @@ async function updateEntity({ site: siteName, entityType, bundle, id, attributes
|
|
|
136
136
|
const written = await readWrittenRevision({
|
|
137
137
|
backend, entityType, bundle, id,
|
|
138
138
|
relationshipsSent: relationshipsWereSent(resolvedRelationships),
|
|
139
|
+
langcode,
|
|
139
140
|
patchResult: result,
|
|
140
141
|
preferCanonical: false,
|
|
141
142
|
});
|
package/src/tools/fields.js
CHANGED
|
@@ -96,11 +96,18 @@ async function describeFields({ site: siteName, type, entityType: entityTypeArg,
|
|
|
96
96
|
|
|
97
97
|
const backend = await resolveBackend(site);
|
|
98
98
|
const schema = await backend.getEntitySchema(entityType, resolvedBundle);
|
|
99
|
+
const translatable = typeof backend.listFieldTranslatability === "function"
|
|
100
|
+
? await backend.listFieldTranslatability(entityType, resolvedBundle).catch(() => ({}))
|
|
101
|
+
: {};
|
|
102
|
+
const translatableMap = translatable && typeof translatable === "object" ? new Map(Object.entries(translatable)) : new Map();
|
|
99
103
|
|
|
100
104
|
const fields = [
|
|
101
105
|
...Object.entries(schema.attributes ?? {}).map(([name, t]) => attributeField(name, t)),
|
|
102
106
|
...Object.keys(schema.relationships ?? {}).map((name) => relationshipField(name)),
|
|
103
|
-
].sort((a, b) => a.name.localeCompare(b.name))
|
|
107
|
+
].sort((a, b) => a.name.localeCompare(b.name)).map((field) => {
|
|
108
|
+
if (!translatableMap.has(field.name)) return field;
|
|
109
|
+
return { ...field, translatable: Boolean(translatableMap.get(field.name)) };
|
|
110
|
+
});
|
|
104
111
|
|
|
105
112
|
const sampledEmpty = fields.length === 0;
|
|
106
113
|
|
|
@@ -125,11 +132,12 @@ export const definitions = [
|
|
|
125
132
|
name: "drupal_describe_fields",
|
|
126
133
|
description:
|
|
127
134
|
"Introspect the fields of a Drupal entity type + bundle: returns a per-field " +
|
|
128
|
-
"list of { name, type, kind, cardinality?, approximate }. Read-only. Built on " +
|
|
135
|
+
"list of { name, type, kind, cardinality?, translatable?, approximate }. Read-only. Built on " +
|
|
129
136
|
"schema SAMPLING (an existing entity), so results are approximate — only " +
|
|
130
137
|
"populated fields are visible and required/cardinality/allowedValues are " +
|
|
131
|
-
"inferred from value shape.
|
|
132
|
-
"
|
|
138
|
+
"inferred from value shape. When JSON:API field_config is readable, translatable " +
|
|
139
|
+
"is copied from Field API; omitted means unknown, not false. Authoritative field " +
|
|
140
|
+
"metadata comes from the Drush bridge (Field API). Use this before creating/updating entities to learn field names.",
|
|
133
141
|
inputSchema: {
|
|
134
142
|
type: "object",
|
|
135
143
|
required: ["site"],
|
package/src/tools/index.js
CHANGED
|
@@ -38,11 +38,12 @@ import * as reportsConfig from "./reports-config.js";
|
|
|
38
38
|
import * as reportsContent from "./reports-content.js";
|
|
39
39
|
import * as auditComposite from "./audit-composite.js";
|
|
40
40
|
import * as config from "./config.js";
|
|
41
|
+
import * as codegen from "./codegen.js";
|
|
41
42
|
import { SITE_PARAM } from "../lib/site-target.js";
|
|
42
43
|
|
|
43
44
|
export const allModules = [nodes, taxonomy, users, media, graphql, site, entities, reports, drush,
|
|
44
45
|
revisions, moderation, scheduler, fields, references, bulk, translations, paragraphs, structure, redirects, search, reportsExtra,
|
|
45
|
-
reportsLinks, reportsConfig, reportsContent, auditComposite, config];
|
|
46
|
+
reportsLinks, reportsConfig, reportsContent, auditComposite, config, codegen];
|
|
46
47
|
|
|
47
48
|
/**
|
|
48
49
|
* Stamp the shared `site` description onto every tool that accepts one so
|
package/src/tools/moderation.js
CHANGED
|
@@ -21,6 +21,9 @@ import {
|
|
|
21
21
|
redactCanonicalEntity,
|
|
22
22
|
} from "../lib/security.js";
|
|
23
23
|
import { collectEntities } from "../lib/reports-support.js";
|
|
24
|
+
import { prepareGuardedPatch, updateEntityGuarded } from "../lib/patch-preflight.js";
|
|
25
|
+
import { assertDraftLangcode, readTranslationInventory } from "../lib/draft-write.js";
|
|
26
|
+
import { inventoryRowMatching } from "../lib/translation-rows.js";
|
|
24
27
|
|
|
25
28
|
/** Cap for the client-side scan when JSON:API cannot filter the field. */
|
|
26
29
|
const SAMPLE_CAP = 500;
|
|
@@ -54,7 +57,7 @@ function moderationStateOf(entity) {
|
|
|
54
57
|
* @returns {Promise<object>} The updated, redacted node.
|
|
55
58
|
* @throws {SecurityError} If writing node/type is not permitted.
|
|
56
59
|
*/
|
|
57
|
-
async function setModerationState({ site: siteName, type, id, state }) {
|
|
60
|
+
async function setModerationState({ site: siteName, type, id, state, langcode }) {
|
|
58
61
|
if (!state) throw new Error("A moderation 'state' is required (e.g. 'draft', 'published').");
|
|
59
62
|
const site = getSiteConfig(siteName);
|
|
60
63
|
const sec = resolveSecurityConfig(site);
|
|
@@ -62,6 +65,23 @@ async function setModerationState({ site: siteName, type, id, state }) {
|
|
|
62
65
|
const attributes = { moderation_state: state };
|
|
63
66
|
assertPublishAllowed(sec, attributes);
|
|
64
67
|
const backend = await resolveBackend(site);
|
|
68
|
+
if (langcode) {
|
|
69
|
+
const targetLang = assertDraftLangcode(langcode);
|
|
70
|
+
let existing = null;
|
|
71
|
+
try {
|
|
72
|
+
existing = (await backend.getEntity({ entityType: "node", bundle: type, id })) ?? null;
|
|
73
|
+
} catch {
|
|
74
|
+
existing = null;
|
|
75
|
+
}
|
|
76
|
+
const patchTarget = await prepareGuardedPatch(backend, {
|
|
77
|
+
entityType: "node", bundle: type, id, existing, attributes, langcode: targetLang,
|
|
78
|
+
});
|
|
79
|
+
const patched = await updateEntityGuarded(backend, {
|
|
80
|
+
entityType: "node", bundle: type, id, attributes, langcode: targetLang,
|
|
81
|
+
...(patchTarget.draftRevision ? { draftRevision: patchTarget.draftRevision } : {}),
|
|
82
|
+
});
|
|
83
|
+
return redactCanonicalEntity(patched, sec, "node");
|
|
84
|
+
}
|
|
65
85
|
const entity = await backend.updateEntity({ entityType: "node", bundle: type, id, attributes });
|
|
66
86
|
return redactCanonicalEntity(entity, sec, "node");
|
|
67
87
|
}
|
|
@@ -76,12 +96,59 @@ async function setModerationState({ site: siteName, type, id, state }) {
|
|
|
76
96
|
*
|
|
77
97
|
* @param {object} args - { site?, type, state, limit?, offset? }.
|
|
78
98
|
*/
|
|
79
|
-
async function contentByModerationState({ site: siteName, type, state, limit = 20, offset = 0 }) {
|
|
99
|
+
async function contentByModerationState({ site: siteName, type, state, limit = 20, offset = 0, langcode }) {
|
|
80
100
|
const site = getSiteConfig(siteName);
|
|
81
101
|
const sec = resolveSecurityConfig(site);
|
|
82
102
|
assertReadAllowed(sec, "node", type);
|
|
83
103
|
const backend = await resolveBackend(site);
|
|
84
104
|
const sort = [{ field: "changed", dir: "desc" }];
|
|
105
|
+
const targetLang = langcode ? assertDraftLangcode(langcode) : null;
|
|
106
|
+
if (targetLang) {
|
|
107
|
+
if (typeof backend.rawQuery !== "function" || typeof backend.resourcePath !== "function") {
|
|
108
|
+
return {
|
|
109
|
+
type, state, langcode: targetLang, unavailable: true,
|
|
110
|
+
reason: "A langcode filter requires Sentinel's translation inventory.",
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
const scanned = await collectEntities(
|
|
114
|
+
backend,
|
|
115
|
+
{ entityType: "node", bundle: type, sort },
|
|
116
|
+
SAMPLE_CAP,
|
|
117
|
+
);
|
|
118
|
+
const matches = [];
|
|
119
|
+
for (const entity of scanned) {
|
|
120
|
+
try {
|
|
121
|
+
const inventory = await readTranslationInventory(backend, {
|
|
122
|
+
entityType: "node", bundle: type, id: entity.id,
|
|
123
|
+
});
|
|
124
|
+
const row = inventoryRowMatching(inventory, { langcode: targetLang, state });
|
|
125
|
+
if (row) {
|
|
126
|
+
matches.push({
|
|
127
|
+
...entity,
|
|
128
|
+
langcode: targetLang,
|
|
129
|
+
fields: { ...entity.fields, moderation_state: row.moderation_state },
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
} catch (error) {
|
|
133
|
+
if (/does not provide Sentinel's governed draft-translation endpoint/.test(String(error?.message))) {
|
|
134
|
+
return {
|
|
135
|
+
type, state, langcode: targetLang, unavailable: true,
|
|
136
|
+
reason: "A langcode filter requires Sentinel's translation inventory.",
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
throw error;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
const page = matches.slice(offset, offset + limit);
|
|
143
|
+
return {
|
|
144
|
+
type, state, langcode: targetLang, source: "inventory",
|
|
145
|
+
approximate: scanned.length >= SAMPLE_CAP,
|
|
146
|
+
scanned: scanned.length,
|
|
147
|
+
total: matches.length,
|
|
148
|
+
offset, nextOffset: offset + page.length,
|
|
149
|
+
nodes: page.map((e) => redactCanonicalEntity(e, sec, "node")),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
85
152
|
const canFilter = typeof backend.capabilities === "function"
|
|
86
153
|
? Boolean(backend.capabilities()?.filter)
|
|
87
154
|
: true;
|
|
@@ -167,28 +234,30 @@ async function listModerationStates({ site: siteName, type, sample = 50 }) {
|
|
|
167
234
|
export const definitions = [
|
|
168
235
|
{
|
|
169
236
|
name: "drupal_set_moderation_state",
|
|
170
|
-
description: "Transition a content node to a moderation state (content_moderation), e.g. 'draft', 'needs_review', 'published', 'archived'. Governed write.",
|
|
237
|
+
description: "Transition a content node to a moderation state (content_moderation), e.g. 'draft', 'needs_review', 'published', 'archived'. Governed write. Pass langcode to change one translation via Sentinel; omit it for the default-language / shared-state write. If moderation_state is not translatable, a langcode write is refused.",
|
|
171
238
|
inputSchema: {
|
|
172
239
|
type: "object", required: ["type", "id", "state"],
|
|
173
240
|
properties: {
|
|
174
|
-
site:
|
|
175
|
-
type:
|
|
176
|
-
id:
|
|
177
|
-
state:
|
|
241
|
+
site: { type: "string" },
|
|
242
|
+
type: { type: "string", description: "Content type machine name" },
|
|
243
|
+
id: { type: "string", description: "Node UUID" },
|
|
244
|
+
state: { type: "string", description: "Target moderation state machine name" },
|
|
245
|
+
langcode: { type: "string", description: "Target translation (e.g. 'es'). Omit for the default language. Requires Sentinel." },
|
|
178
246
|
},
|
|
179
247
|
},
|
|
180
248
|
},
|
|
181
249
|
{
|
|
182
250
|
name: "drupal_content_by_moderation_state",
|
|
183
|
-
description: "List nodes of a content type currently in a given moderation state (e.g. what is in 'draft' or 'needs_review'). Stock JSON:API cannot filter the computed moderation_state field; when the site rejects that filter the tool samples recent nodes client-side and marks the result approximate, instead of returning Drupal's 500.",
|
|
251
|
+
description: "List nodes of a content type currently in a given moderation state (e.g. what is in 'draft' or 'needs_review'). Pass langcode to match that translation via Sentinel inventory (the editorial work queue). Omit langcode for default-language JSON:API / sampled behavior. Stock JSON:API cannot filter the computed moderation_state field; when the site rejects that filter the tool samples recent nodes client-side and marks the result approximate, instead of returning Drupal's 500.",
|
|
184
252
|
inputSchema: {
|
|
185
253
|
type: "object", required: ["type", "state"],
|
|
186
254
|
properties: {
|
|
187
|
-
site:
|
|
188
|
-
type:
|
|
189
|
-
state:
|
|
190
|
-
|
|
191
|
-
|
|
255
|
+
site: { type: "string" },
|
|
256
|
+
type: { type: "string", description: "Content type machine name" },
|
|
257
|
+
state: { type: "string", description: "Moderation state machine name" },
|
|
258
|
+
langcode: { type: "string", description: "Match this translation (e.g. 'es'). Requires Sentinel. Omit for default-language listing." },
|
|
259
|
+
limit: { type: "number", default: 20 },
|
|
260
|
+
offset: { type: "number", default: 0 },
|
|
192
261
|
},
|
|
193
262
|
},
|
|
194
263
|
},
|