drupal-mcp-connector 2.15.2 → 2.17.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-create-translation.md +3 -3
- package/.agents/commands/drupal-describe-fields.md +2 -2
- package/.agents/commands/drupal-get-media.md +4 -3
- package/.agents/commands/drupal-get-node.md +4 -3
- package/.agents/commands/drupal-get-taxonomy-term.md +4 -3
- package/.agents/commands/drupal-list-translations.md +3 -3
- 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-media.md +5 -4
- package/.agents/commands/drupal-update-menu-link.md +2 -1
- package/.agents/commands/drupal-update-taxonomy-term.md +4 -3
- package/CHANGELOG.md +57 -0
- package/README.md +3 -3
- package/bin/drupal-mcp-agent.js +2 -2
- package/package.json +3 -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/canonical.js +10 -5
- package/src/lib/config.js +18 -1
- package/src/lib/draft-write.js +5 -5
- package/src/lib/err-relationships.js +22 -0
- package/src/lib/mcp-server.js +1 -1
- package/src/lib/node-draft-inventory.js +3 -2
- package/src/lib/server-tools.js +2 -2
- package/src/lib/translation-rows.js +59 -0
- package/src/tools/codegen.js +150 -0
- package/src/tools/fields.js +12 -4
- package/src/tools/index.js +2 -1
- package/src/tools/media.js +65 -8
- package/src/tools/moderation.js +82 -13
- package/src/tools/nodes.js +56 -6
- package/src/tools/reports-content.js +117 -28
- package/src/tools/structure.js +6 -3
- package/src/tools/taxonomy.js +15 -6
- package/src/tools/translations.js +31 -27
|
@@ -32,6 +32,38 @@ const INTERNAL_ATTR_RE = /^drupal_internal__/;
|
|
|
32
32
|
const COUNT_PAGE_SIZE = 50;
|
|
33
33
|
const COUNT_MAX_RECORDS = 1000;
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* JSON:API language headers. Drupal negotiates Content-Language / Accept-Language
|
|
37
|
+
* when URL prefixes are not used on /jsonapi.
|
|
38
|
+
* @param {?string} langcode
|
|
39
|
+
* @returns {object}
|
|
40
|
+
*/
|
|
41
|
+
function languageFetchOptions(langcode) {
|
|
42
|
+
if (!langcode) return {};
|
|
43
|
+
return { headers: { "Content-Language": langcode, "Accept-Language": langcode } };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Fail if JSON:API served a different language than requested.
|
|
48
|
+
* @param {?object} entity
|
|
49
|
+
* @param {?string} langcode
|
|
50
|
+
*/
|
|
51
|
+
function assertServedLanguage(entity, langcode) {
|
|
52
|
+
if (!langcode || !entity) return;
|
|
53
|
+
if (!entity.langcode) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
`JSON:API did not report a language for requested langcode "${langcode}". ` +
|
|
56
|
+
"The translation may not exist, or this backend does not negotiate language.",
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
if (entity.langcode !== langcode) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
`JSON:API served language "${entity.langcode}" for requested langcode "${langcode}". ` +
|
|
62
|
+
"The translation may not exist, or this site does not negotiate JSON:API by language.",
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
35
67
|
/**
|
|
36
68
|
* Whether a JSON:API collection document advertises another page.
|
|
37
69
|
* `links.next` may be a string href or a `{ href }` link object.
|
|
@@ -358,14 +390,16 @@ export class JsonApiBackend extends Backend {
|
|
|
358
390
|
* @param {{entityType: string, bundle: string, id: string, resourceVersion?: string}} ref
|
|
359
391
|
* @returns {Promise<?import("../canonical.js").CanonicalEntity>} Entity, or null.
|
|
360
392
|
*/
|
|
361
|
-
async getEntity({ entityType, bundle, id, resourceVersion }) {
|
|
393
|
+
async getEntity({ entityType, bundle, id, resourceVersion, langcode }) {
|
|
362
394
|
validateUuid(id);
|
|
363
395
|
let path = `${this.resourcePath(entityType, bundle)}/${encodeURIComponent(id)}`;
|
|
364
396
|
if (resourceVersion) {
|
|
365
397
|
path += `?resourceVersion=${encodeURIComponent(resourceVersion)}`;
|
|
366
398
|
}
|
|
367
|
-
const data = await drupalFetch(this.site, path);
|
|
368
|
-
|
|
399
|
+
const data = await drupalFetch(this.site, path, languageFetchOptions(langcode));
|
|
400
|
+
const entity = data?.data ? this.toCanonical(data.data) : null;
|
|
401
|
+
assertServedLanguage(entity, langcode);
|
|
402
|
+
return entity;
|
|
369
403
|
}
|
|
370
404
|
|
|
371
405
|
/**
|
|
@@ -478,14 +512,15 @@ export class JsonApiBackend extends Backend {
|
|
|
478
512
|
* @param {object} attributes Entity attributes (may include `status`).
|
|
479
513
|
* @returns {Promise<object>} The JSON:API response body.
|
|
480
514
|
*/
|
|
481
|
-
async writeWithModerationFallback(path, method, buildPayload, attributes) {
|
|
515
|
+
async writeWithModerationFallback(path, method, buildPayload, attributes, langcode) {
|
|
516
|
+
const lang = languageFetchOptions(langcode);
|
|
482
517
|
try {
|
|
483
|
-
return await drupalFetch(this.site, path, { method, body: JSON.stringify(buildPayload(attributes)) });
|
|
518
|
+
return await drupalFetch(this.site, path, { method, body: JSON.stringify(buildPayload(attributes)), ...lang });
|
|
484
519
|
} catch (err) {
|
|
485
520
|
if (!isModeratedStatusError(err) || !("status" in attributes)) throw err;
|
|
486
521
|
const withoutStatus = { ...attributes };
|
|
487
522
|
delete withoutStatus.status;
|
|
488
|
-
return drupalFetch(this.site, path, { method, body: JSON.stringify(buildPayload(withoutStatus)) });
|
|
523
|
+
return drupalFetch(this.site, path, { method, body: JSON.stringify(buildPayload(withoutStatus)), ...lang });
|
|
489
524
|
}
|
|
490
525
|
}
|
|
491
526
|
|
|
@@ -515,7 +550,7 @@ export class JsonApiBackend extends Backend {
|
|
|
515
550
|
* canonical default (#166 / Drupal #2795279).
|
|
516
551
|
* @returns {Promise<import("../canonical.js").CanonicalEntity>} The updated entity.
|
|
517
552
|
*/
|
|
518
|
-
async updateEntity({ entityType, bundle, id, attributes = {}, relationships, resourceVersion }) {
|
|
553
|
+
async updateEntity({ entityType, bundle, id, attributes = {}, relationships, resourceVersion, langcode }) {
|
|
519
554
|
validateUuid(id);
|
|
520
555
|
const buildPayload = (attrs) => {
|
|
521
556
|
const payload = { data: { type: `${entityType}--${bundle}`, id, attributes: attrs } };
|
|
@@ -527,8 +562,10 @@ export class JsonApiBackend extends Backend {
|
|
|
527
562
|
if (resourceVersion) {
|
|
528
563
|
path += `?resourceVersion=${encodeURIComponent(resourceVersion)}`;
|
|
529
564
|
}
|
|
530
|
-
const data = await this.writeWithModerationFallback(path, "PATCH", buildPayload, attributes);
|
|
531
|
-
|
|
565
|
+
const data = await this.writeWithModerationFallback(path, "PATCH", buildPayload, attributes, langcode);
|
|
566
|
+
const entity = this.toCanonical(data.data);
|
|
567
|
+
assertServedLanguage(entity, langcode);
|
|
568
|
+
return entity;
|
|
532
569
|
}
|
|
533
570
|
|
|
534
571
|
/**
|
|
@@ -704,6 +741,45 @@ export class JsonApiBackend extends Backend {
|
|
|
704
741
|
* @param {{entityType: string, bundle: string, fieldName: string}} ref
|
|
705
742
|
* @returns {Promise<?{fieldName: string, fieldType: ?string, allowedFormats: string[]}>}
|
|
706
743
|
*/
|
|
744
|
+
/**
|
|
745
|
+
* Map of field machine name → translatable from JSON:API field_config.
|
|
746
|
+
* Base fields (title, moderation_state) are omitted unless exposed as
|
|
747
|
+
* base_field_override. Missing keys must not be treated as false.
|
|
748
|
+
* @param {string} entityType
|
|
749
|
+
* @param {string} bundle
|
|
750
|
+
* @returns {Promise<Object<string, boolean>>}
|
|
751
|
+
*/
|
|
752
|
+
async listFieldTranslatability(entityType, bundle) {
|
|
753
|
+
validateMachineName(entityType, "entityType");
|
|
754
|
+
validateMachineName(bundle, "bundle");
|
|
755
|
+
const map = new Map();
|
|
756
|
+
const params = new URLSearchParams();
|
|
757
|
+
params.set("filter[entity_type]", entityType);
|
|
758
|
+
params.set("filter[bundle]", bundle);
|
|
759
|
+
params.set("page[limit]", "50");
|
|
760
|
+
const ingest = (rows) => {
|
|
761
|
+
for (const row of rows) {
|
|
762
|
+
const name = row?.attributes?.field_name;
|
|
763
|
+
if (typeof name === "string" && typeof row.attributes?.translatable === "boolean") {
|
|
764
|
+
map.set(name, row.attributes.translatable);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
};
|
|
768
|
+
try {
|
|
769
|
+
const data = await drupalFetch(this.site, `/jsonapi/field_config/field_config?${params}`);
|
|
770
|
+
ingest(Array.isArray(data?.data) ? data.data : []);
|
|
771
|
+
} catch {
|
|
772
|
+
// field_config may be unexposed.
|
|
773
|
+
}
|
|
774
|
+
try {
|
|
775
|
+
const data = await drupalFetch(this.site, `/jsonapi/base_field_override/base_field_override?${params}`);
|
|
776
|
+
ingest(Array.isArray(data?.data) ? data.data : []);
|
|
777
|
+
} catch {
|
|
778
|
+
// base_field_override is often not JSON:API-exposed.
|
|
779
|
+
}
|
|
780
|
+
return Object.fromEntries(map);
|
|
781
|
+
}
|
|
782
|
+
|
|
707
783
|
async getFieldDefinition({ entityType, bundle, fieldName }) {
|
|
708
784
|
validateMachineName(entityType, "entityType");
|
|
709
785
|
validateMachineName(bundle, "bundle");
|
package/src/lib/canonical.js
CHANGED
|
@@ -65,14 +65,19 @@ export function normalizeRelationship(ref) {
|
|
|
65
65
|
// JSON:API encodes type as "entityType--bundle"; split into the two parts.
|
|
66
66
|
const [entityType = null, bundle = null] = (ref.type || "").split("--");
|
|
67
67
|
const out = { id: ref.id, entityType, bundle };
|
|
68
|
-
// ERR identifiers carry the revision id in JSON:API `meta` (#192).
|
|
69
|
-
//
|
|
70
|
-
//
|
|
68
|
+
// ERR identifiers carry the revision id in JSON:API `meta` (#192). Image
|
|
69
|
+
// fields carry alt/title the same way (#296). Dropping either made a
|
|
70
|
+
// canonical re-read look like a plain {id, type}.
|
|
71
71
|
if (ref.meta && typeof ref.meta === "object") {
|
|
72
|
-
const
|
|
72
|
+
const entries = new Map(Object.entries(ref.meta));
|
|
73
|
+
const meta = {};
|
|
74
|
+
const vid = entries.get("target_revision_id");
|
|
73
75
|
if (vid !== undefined && vid !== null && vid !== "") {
|
|
74
|
-
|
|
76
|
+
meta.target_revision_id = vid;
|
|
75
77
|
}
|
|
78
|
+
if (typeof entries.get("alt") === "string") meta.alt = entries.get("alt");
|
|
79
|
+
if (typeof entries.get("title") === "string") meta.title = entries.get("title");
|
|
80
|
+
if (Object.keys(meta).length > 0) out.meta = meta;
|
|
76
81
|
}
|
|
77
82
|
return out;
|
|
78
83
|
}
|
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
|
}
|
package/src/lib/draft-write.js
CHANGED
|
@@ -46,7 +46,7 @@ function requireWorkingPair(draftRevision) {
|
|
|
46
46
|
const live = String(draftRevision?.liveVid ?? "");
|
|
47
47
|
const working = String(draftRevision?.workingVid ?? "");
|
|
48
48
|
if (!/^[1-9]\d*$/.test(live) || !/^[1-9]\d*$/.test(working) || live === working) {
|
|
49
|
-
throw new Error("Draft continuation requires distinct, verified live and working
|
|
49
|
+
throw new Error("Draft continuation requires distinct, verified live and working revision IDs.");
|
|
50
50
|
}
|
|
51
51
|
return { live, working };
|
|
52
52
|
}
|
|
@@ -59,8 +59,8 @@ function requireWorkingPair(draftRevision) {
|
|
|
59
59
|
* @returns {string}
|
|
60
60
|
*/
|
|
61
61
|
function draftResource(backend, entityType, bundle, id) {
|
|
62
|
-
if (entityType !== "node" && entityType !== "paragraph") {
|
|
63
|
-
throw new Error("Governed draft translation is implemented for nodes and
|
|
62
|
+
if (entityType !== "node" && entityType !== "paragraph" && entityType !== "media") {
|
|
63
|
+
throw new Error("Governed draft translation is implemented for nodes, paragraphs, and media.");
|
|
64
64
|
}
|
|
65
65
|
if (typeof backend.rawQuery !== "function" || typeof backend.resourcePath !== "function") {
|
|
66
66
|
throw new Error("This backend does not support governed draft continuation.");
|
|
@@ -150,8 +150,8 @@ export async function createTranslationDraft(backend, input, preflight = false)
|
|
|
150
150
|
const working = workingRaw === undefined || workingRaw === null || workingRaw === ""
|
|
151
151
|
? ""
|
|
152
152
|
: String(workingRaw);
|
|
153
|
-
if (entityType !== "node" || !/^[1-9]\d*$/.test(live)) {
|
|
154
|
-
throw new Error("Translation create requires a verified live
|
|
153
|
+
if ((entityType !== "node" && entityType !== "media") || !/^[1-9]\d*$/.test(live)) {
|
|
154
|
+
throw new Error("Translation create requires a verified live revision ID.");
|
|
155
155
|
}
|
|
156
156
|
if (working && (!/^[1-9]\d*$/.test(working) || working === live)) {
|
|
157
157
|
throw new Error("Translation create requires distinct live and working revision IDs when a working copy exists.");
|
|
@@ -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).
|
|
@@ -9,7 +9,8 @@ import { readTranslationInventory } from "./draft-write.js";
|
|
|
9
9
|
* @returns {Promise<object|null>}
|
|
10
10
|
*/
|
|
11
11
|
export async function readNodeDraftInventory(backend, ref) {
|
|
12
|
-
if (ref.entityType !== "node"
|
|
12
|
+
if ((ref.entityType !== "node" && ref.entityType !== "media")
|
|
13
|
+
|| typeof backend.rawQuery !== "function"
|
|
13
14
|
|| typeof backend.resourcePath !== "function") return null;
|
|
14
15
|
let inventory;
|
|
15
16
|
try {
|
|
@@ -24,7 +25,7 @@ export async function readNodeDraftInventory(backend, ref) {
|
|
|
24
25
|
|| !Array.isArray(inventory.working.translations)
|
|
25
26
|
|| inventory.working.translations.some((row) => !row || typeof row.langcode !== "string"
|
|
26
27
|
|| typeof row.status !== "boolean")))) {
|
|
27
|
-
throw new Error("Sentinel returned an invalid
|
|
28
|
+
throw new Error("Sentinel returned an invalid revision inventory. Re-read before updating.");
|
|
28
29
|
}
|
|
29
30
|
return inventory;
|
|
30
31
|
}
|
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
|
+
}
|
|
@@ -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/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
|