drupal-mcp-connector 2.24.2 → 2.26.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-entity-update.md +1 -1
- package/.agents/commands/drupal-list-menu-links.md +2 -2
- package/.agents/commands/drupal-schedule-publish.md +5 -3
- package/.agents/commands/drupal-update-menu-link.md +3 -3
- package/.agents/commands/drupal-update-node.md +1 -1
- package/CHANGELOG.md +87 -0
- package/README.md +57 -1
- package/bin/drupal-mcp-doctor.js +16 -0
- package/bin/drupal-mcp-wizard.js +17 -0
- package/package.json +10 -5
- package/src/index.js +19 -19
- package/src/lib/backends/jsonapi.js +71 -8
- package/src/lib/dispatch.js +22 -0
- package/src/lib/doctor.js +645 -0
- package/src/lib/module-tools.js +33 -2
- package/src/lib/operations.js +1 -1
- package/src/lib/patch-preflight.js +32 -32
- package/src/lib/principal.js +13 -5
- package/src/lib/security.js +42 -44
- package/src/lib/sentinel-draft.js +59 -3
- package/src/lib/server-tools.js +3 -3
- package/src/lib/transports.js +153 -0
- package/src/lib/version-skew.js +193 -0
- package/src/lib/wizard.js +902 -0
- package/src/lib/workflow-prompts.js +5 -5
- package/src/tools/codegen.js +2 -2
- package/src/tools/config.js +11 -6
- package/src/tools/entities.js +1 -1
- package/src/tools/index.js +1 -1
- package/src/tools/media.js +2 -2
- package/src/tools/nodes.js +5 -3
- package/src/tools/reports-config.js +3 -3
- package/src/tools/reports-content.js +4 -2
- package/src/tools/revisions.js +2 -1
- package/src/tools/scheduler.js +97 -10
- package/src/tools/structure.js +4 -4
- package/src/tools/translations.js +7 -1
|
@@ -110,7 +110,7 @@ export function normalizeWorkflow(raw) {
|
|
|
110
110
|
* @param {string} namespace
|
|
111
111
|
* @returns {string}
|
|
112
112
|
*/
|
|
113
|
-
|
|
113
|
+
function resolvedName(def, namespace) {
|
|
114
114
|
return def.name || workflowPromptName(namespace, def.id);
|
|
115
115
|
}
|
|
116
116
|
|
|
@@ -141,7 +141,7 @@ export function publicToolName(namespace, alias, tools) {
|
|
|
141
141
|
* @param {{builtin?: boolean}} [opts]
|
|
142
142
|
* @returns {string[]|null} Public names, or null when any tool is missing.
|
|
143
143
|
*/
|
|
144
|
-
|
|
144
|
+
function resolveToolNames(def, namespace, tools, opts = {}) {
|
|
145
145
|
const visible = new Set((tools ?? []).map((tool) => tool.name));
|
|
146
146
|
const names = [];
|
|
147
147
|
for (const alias of def.tools) {
|
|
@@ -270,7 +270,7 @@ export function renderWorkflowMessages(workflow, args = {}) {
|
|
|
270
270
|
* @param {string} instructions
|
|
271
271
|
* @returns {string[]}
|
|
272
272
|
*/
|
|
273
|
-
|
|
273
|
+
function toolAliasesInInstructions(instructions) {
|
|
274
274
|
const aliases = [];
|
|
275
275
|
const seen = new Set();
|
|
276
276
|
for (const match of String(instructions ?? "").matchAll(/\{tool:([a-z][a-z0-9_]{0,47})\}/g)) {
|
|
@@ -287,7 +287,7 @@ export function toolAliasesInInstructions(instructions) {
|
|
|
287
287
|
* @param {object} got
|
|
288
288
|
* @returns {string}
|
|
289
289
|
*/
|
|
290
|
-
|
|
290
|
+
function instructionTextFromDrupalPrompt(got) {
|
|
291
291
|
const messages = Array.isArray(got?.messages) ? got.messages : [];
|
|
292
292
|
const parts = [];
|
|
293
293
|
for (const message of messages) {
|
|
@@ -313,7 +313,7 @@ export function instructionTextFromDrupalPrompt(got) {
|
|
|
313
313
|
* @param {object} toolsMap `serverTools.modules.tools`
|
|
314
314
|
* @returns {boolean}
|
|
315
315
|
*/
|
|
316
|
-
|
|
316
|
+
function inferReadOnly(aliases, toolsMap) {
|
|
317
317
|
for (const alias of aliases) {
|
|
318
318
|
const operation = toolsMap?.[alias]?.operation;
|
|
319
319
|
if (operation === "write" || operation === "delete") return false;
|
package/src/tools/codegen.js
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
import { getSiteConfig } from "../lib/config.js";
|
|
19
19
|
import { sshDrush } from "./drush.js";
|
|
20
20
|
import { callBoundModuleTool, toolResultData } from "../lib/server-tools.js";
|
|
21
|
-
import { SecurityError } from "../lib/security.js";
|
|
21
|
+
import { SecurityError, configReadBindingScope } from "../lib/security.js";
|
|
22
22
|
import { validateMachineName } from "../lib/validate.js";
|
|
23
23
|
|
|
24
24
|
const INSPECT = "graphql-compose-codegen:inspect";
|
|
@@ -83,7 +83,7 @@ async function runGqcc(
|
|
|
83
83
|
]).get(subcommand);
|
|
84
84
|
const result = toolResultData(await callBoundModuleTool(site, binding, {
|
|
85
85
|
bundles: names, skip_fields: skip,
|
|
86
|
-
}, { operation: "read", scope:
|
|
86
|
+
}, { operation: "read", scope: configReadBindingScope(site), capabilities: ["configRead"] }));
|
|
87
87
|
if (result?.success !== true || !result.data || typeof result.data !== "object" || Array.isArray(result.data)) {
|
|
88
88
|
throw new SecurityError("Codegen module returned an invalid result. No SSH fallback was attempted.");
|
|
89
89
|
}
|
package/src/tools/config.js
CHANGED
|
@@ -24,6 +24,8 @@ import {
|
|
|
24
24
|
assertCoreExtensionValueKeepsProtected,
|
|
25
25
|
isCoreExtensionConfig,
|
|
26
26
|
hasScope,
|
|
27
|
+
hasConfigReadScope,
|
|
28
|
+
configReadBindingScope,
|
|
27
29
|
CORE_EXTENSION_CONFIG,
|
|
28
30
|
SecurityError,
|
|
29
31
|
} from "../lib/security.js";
|
|
@@ -36,8 +38,9 @@ import { callGovernedServerTool, callBoundModuleTool, toolResultData } from "../
|
|
|
36
38
|
*/
|
|
37
39
|
async function configTool(site, binding, args, operation, capability) {
|
|
38
40
|
if (site.serverTools?.bindings !== undefined) {
|
|
41
|
+
const scope = operation === "read" ? configReadBindingScope(site) : "mcp_config";
|
|
39
42
|
return callBoundModuleTool(site, binding, args, {
|
|
40
|
-
operation, scope
|
|
43
|
+
operation, scope, capabilities: [capability],
|
|
41
44
|
});
|
|
42
45
|
}
|
|
43
46
|
return callGovernedServerTool(site, binding, args);
|
|
@@ -144,7 +147,7 @@ async function assertCoreExtensionWriteAllowed(site, sec, value) {
|
|
|
144
147
|
async function configSet({ site: siteName, name, value }) {
|
|
145
148
|
const site = getSiteConfig(siteName);
|
|
146
149
|
const sec = resolveSecurityConfig(site);
|
|
147
|
-
assertConfigScope(site, `config:set ${name}
|
|
150
|
+
assertConfigScope(site, `config:set ${name}`, { write: true });
|
|
148
151
|
assertNotReadOnly(sec, `config:set ${name}`);
|
|
149
152
|
assertConfigWriteAllowed(sec);
|
|
150
153
|
if (isCoreExtensionConfig(name)) await assertCoreExtensionWriteAllowed(site, sec, value);
|
|
@@ -188,11 +191,13 @@ async function whoami({ site: siteName, _resolvedSource }) {
|
|
|
188
191
|
const summary = getSecuritySummary(site);
|
|
189
192
|
// Effective capability = connector preset AND the scope the server demands.
|
|
190
193
|
// Reporting the preset alone over-states what the token can do — e.g. the
|
|
191
|
-
// content-editor preset allows config reads locally, but
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
+
// content-editor preset allows config reads locally, but config_* tools are
|
|
195
|
+
// gated server-side on mcp_config (writes) or mcp_config / mcp_config_read
|
|
196
|
+
// (reads). The content tier holds neither. When no OAuth scopes are
|
|
197
|
+
// configured, hasScope() is a no-op (preset-only).
|
|
194
198
|
const canWrite = !sec.readOnly && hasScope(site, "mcp_write");
|
|
195
199
|
const canConfig = hasScope(site, "mcp_config");
|
|
200
|
+
const canConfigRead = hasConfigReadScope(site);
|
|
196
201
|
const identity = getRequestIdentity();
|
|
197
202
|
// Dispatch may inject `site` after a default/grant resolution. Prefer the
|
|
198
203
|
// authoritative source so `target` and `_target` cannot disagree (#167).
|
|
@@ -212,7 +217,7 @@ async function whoami({ site: siteName, _resolvedSource }) {
|
|
|
212
217
|
read: hasScope(site, "mcp_read"),
|
|
213
218
|
write: canWrite,
|
|
214
219
|
delete: sec.allowDestructive && canWrite,
|
|
215
|
-
configRead: sec.allowConfigRead &&
|
|
220
|
+
configRead: sec.allowConfigRead && canConfigRead,
|
|
216
221
|
configWrite: sec.allowConfigWrite && !sec.readOnly && canConfig,
|
|
217
222
|
// Local publish policy (allowPublish), symmetric with delete/config caps.
|
|
218
223
|
// Defaults false in every preset except `development`; the remote Drupal's
|
package/src/tools/entities.js
CHANGED
|
@@ -308,7 +308,7 @@ export const definitions = [
|
|
|
308
308
|
langcode: { type: "string", description: "Target language for an unpublished working translation (nodes). Continues that translation via Sentinel." },
|
|
309
309
|
attributes: { type: "object" },
|
|
310
310
|
relationships: { type: "object" },
|
|
311
|
-
dryRun: { type: "boolean", default: false, description: "Validate, resolve ERR identifiers, run the server-side preflight when one applies, and return a preview without the real write. The result's `checks` block says what was checked; `caveat` names what was not. Only an existing node draft (or a langcode translation draft) is checked with the real payload: Sentinel's non-saving draft endpoint applies the submitted fields through field access and validates the entity (`serverPreflight: sentinel_draft`). On other moderated targets an id-mismatch core PATCH with no fields checks entity update access and core's working-copy guard only; field access and entity validation are NOT checked (`core_patch_guard`), so the real write can still fail with a field-access 403 or a 422. Unmoderated targets get no server-side check at all (`none`). A
|
|
311
|
+
dryRun: { type: "boolean", default: false, description: "Validate, resolve ERR identifiers, run the server-side preflight when one applies, and return a preview without the real write. The result's `checks` block says what was checked; `caveat` names what was not. Only an existing node draft (or a langcode translation draft) is checked with the real payload: Sentinel's non-saving draft endpoint applies the submitted fields through field access and validates the entity (`serverPreflight: sentinel_draft`). On other moderated targets an id-mismatch core PATCH with no fields checks entity update access and core's working-copy guard only; field access and entity validation are NOT checked (`core_patch_guard`), so the real write can still fail with a field-access 403 or a 422. Unmoderated targets get no server-side check at all (`none`). A changed/revision_timestamp gap on the default revision (possiblyPatchBlocked) is a warning on list_revisions, not a local dryRun refusal — the write proceeds to the core probe / Sentinel (#405). An actual Sentinel stale-copy refusal is still rewritten. Any refusal fails the dryRun." },
|
|
312
312
|
returning: RETURNING_SCHEMA,
|
|
313
313
|
},
|
|
314
314
|
},
|
package/src/tools/index.js
CHANGED
|
@@ -41,7 +41,7 @@ import * as config from "./config.js";
|
|
|
41
41
|
import * as codegen from "./codegen.js";
|
|
42
42
|
import { SITE_PARAM } from "../lib/site-target.js";
|
|
43
43
|
|
|
44
|
-
|
|
44
|
+
const allModules = [nodes, taxonomy, users, media, graphql, site, entities, reports, drush,
|
|
45
45
|
revisions, moderation, scheduler, fields, references, bulk, translations, paragraphs, structure, redirects, search, reportsExtra,
|
|
46
46
|
reportsLinks, reportsConfig, reportsContent, auditComposite, config, codegen];
|
|
47
47
|
|
package/src/tools/media.js
CHANGED
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
assertReadAllowed, assertWriteAllowed, assertDeleteAllowed, assertPublishAllowed,
|
|
16
16
|
} from "../lib/security.js";
|
|
17
17
|
import {
|
|
18
|
-
assertDraftLangcode, readDraftTranslation, readTranslationInventory,
|
|
18
|
+
assertDraftLangcode, readDraftTranslation, readTranslationInventory, isUnpublishedWorkingDraft,
|
|
19
19
|
} from "../lib/sentinel-draft.js";
|
|
20
20
|
import { prepareGuardedPatch, updateEntityGuarded } from "../lib/patch-preflight.js";
|
|
21
21
|
import { entityRevisionId } from "../lib/write-revision.js";
|
|
@@ -69,7 +69,7 @@ async function getMedia({ site: siteName, type, id, langcode }) {
|
|
|
69
69
|
const targetLang = assertDraftLangcode(langcode);
|
|
70
70
|
const inventory = await readTranslationInventory(backend, { entityType: "media", bundle: type, id });
|
|
71
71
|
const workingRow = (inventory.working?.translations ?? []).find((row) => row.langcode === targetLang);
|
|
72
|
-
if (workingRow && workingRow
|
|
72
|
+
if (workingRow && isUnpublishedWorkingDraft(workingRow) && inventory.live?.vid && inventory.working?.vid) {
|
|
73
73
|
const entity = await readDraftTranslation(backend, {
|
|
74
74
|
entityType: "media", bundle: type, id, langcode: targetLang,
|
|
75
75
|
draftRevision: { liveVid: inventory.live.vid, workingVid: inventory.working.vid },
|
package/src/tools/nodes.js
CHANGED
|
@@ -19,7 +19,9 @@ import { resolveErrRelationships, relationshipsWereSent, paragraphPinsFromEntity
|
|
|
19
19
|
import { attachWrittenRevisionPair, readWrittenRevision } from "../lib/write-revision.js";
|
|
20
20
|
import { prepareGuardedPatch, updateEntityGuarded } from "../lib/patch-preflight.js";
|
|
21
21
|
import { dryRunChecks, PREFLIGHT_NONE } from "../lib/dry-run-checks.js";
|
|
22
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
assertDraftLangcode, readDraftTranslation, readTranslationInventory, isUnpublishedWorkingDraft,
|
|
24
|
+
} from "../lib/sentinel-draft.js";
|
|
23
25
|
import { paragraphResourceVersion } from "./paragraphs.js";
|
|
24
26
|
import { assertBodySummaryWritable, attachSummaryDeprecation } from "../lib/body-summary.js";
|
|
25
27
|
import { buildRedirectAttributes, REDIRECT_ENTITY_TYPE } from "./redirects.js";
|
|
@@ -294,7 +296,7 @@ async function getNode({ site: siteName, type, id, langcode, resourceVersion, in
|
|
|
294
296
|
const targetLang = assertDraftLangcode(langcode);
|
|
295
297
|
const inventory = await readTranslationInventory(backend, { entityType: "node", bundle: type, id });
|
|
296
298
|
const workingRow = (inventory.working?.translations ?? []).find((row) => row.langcode === targetLang);
|
|
297
|
-
if (workingRow && workingRow
|
|
299
|
+
if (workingRow && isUnpublishedWorkingDraft(workingRow) && inventory.live?.vid && inventory.working?.vid) {
|
|
298
300
|
const entity = await readDraftTranslation(backend, {
|
|
299
301
|
entityType: "node", bundle: type, id, langcode: targetLang,
|
|
300
302
|
draftRevision: { liveVid: inventory.live.vid, workingVid: inventory.working.vid },
|
|
@@ -664,7 +666,7 @@ export const definitions = [
|
|
|
664
666
|
langcode: { type: "string", description: "Target language for an unpublished working translation (e.g. 'es'). Continues that translation via Sentinel; does not create a missing translation and does not PATCH canonical langcode." },
|
|
665
667
|
fields: { type: "object", description: "Scalar/attribute field values keyed by machine name. Formatted text: a string or { value, format?, summary? }. format must be in the field's allowed_formats; a single allowed format is used when omitted. Entity-reference fields go in `relationships`, not here." },
|
|
666
668
|
relationships: { type: "object", description: "Entity-reference fields as JSON:API relationships, keyed by field machine name. Single-value uses { data: { type, id } }; multi-value uses { data: [{ type, id }, …] }. Paragraph / ERR items must carry meta.target_revision_id — the connector injects it when missing, and fails the write if it cannot. Image alt on a translation uses the existing file UUID plus meta.alt; replacing the file is refused." },
|
|
667
|
-
dryRun: { type: "boolean", default: false, description: "Validate, resolve ERR identifiers, run the server-side preflight when one applies, and return a preview without the real write. The result's `checks` block says what was checked; `caveat` names what was not. Only an existing node draft (or a langcode translation draft) is checked with the real payload: Sentinel's non-saving draft endpoint applies the submitted fields through field access and validates the entity (`serverPreflight: sentinel_draft`). On other moderated targets an id-mismatch core PATCH with no fields checks entity update access and core's working-copy guard only; Drupal does not check field access or entity validation on that probe (`core_patch_guard`). Text format is resolved before the preview returns, including on that path: a known allowed_formats list is enforced, and an unknown list reuses the format stored on the field. Other fields can still fail the real write with a field-access 403 or a validation 422. Unmoderated targets get no server-side check at all (`none`). A
|
|
669
|
+
dryRun: { type: "boolean", default: false, description: "Validate, resolve ERR identifiers, run the server-side preflight when one applies, and return a preview without the real write. The result's `checks` block says what was checked; `caveat` names what was not. Only an existing node draft (or a langcode translation draft) is checked with the real payload: Sentinel's non-saving draft endpoint applies the submitted fields through field access and validates the entity (`serverPreflight: sentinel_draft`). On other moderated targets an id-mismatch core PATCH with no fields checks entity update access and core's working-copy guard only; Drupal does not check field access or entity validation on that probe (`core_patch_guard`). Text format is resolved before the preview returns, including on that path: a known allowed_formats list is enforced, and an unknown list reuses the format stored on the field. Other fields can still fail the real write with a field-access 403 or a validation 422. Unmoderated targets get no server-side check at all (`none`). A changed/revision_timestamp gap on the default revision (possiblyPatchBlocked) is a warning on list_revisions, not a local dryRun refusal — the write proceeds to the core probe / Sentinel (#405). An actual Sentinel stale-copy refusal is still rewritten. Any refusal fails the dryRun." },
|
|
668
670
|
returning: RETURNING_SCHEMA,
|
|
669
671
|
},
|
|
670
672
|
},
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { getSiteConfig } from "../lib/config.js";
|
|
21
|
-
import { resolveSecurityConfig, assertConfigReadAllowed } from "../lib/security.js";
|
|
21
|
+
import { resolveSecurityConfig, assertConfigReadAllowed, configReadBindingScope } from "../lib/security.js";
|
|
22
22
|
import { gatedReport } from "../lib/reports-support.js";
|
|
23
23
|
import { callGovernedServerTool, callBoundModuleTool, toolResultData } from "../lib/server-tools.js";
|
|
24
24
|
import { runPrivileged, serverToolsConfigured, drushConfigured } from "../lib/audit-sources.js";
|
|
@@ -67,7 +67,7 @@ function configSourceAvailable(site) {
|
|
|
67
67
|
async function readConfig(site, name) {
|
|
68
68
|
if (site.serverTools?.bindings !== undefined) {
|
|
69
69
|
return toolResultData(await callBoundModuleTool(site, "configGet", { name }, {
|
|
70
|
-
operation: "read", scope:
|
|
70
|
+
operation: "read", scope: configReadBindingScope(site), capabilities: ["configRead"],
|
|
71
71
|
}));
|
|
72
72
|
}
|
|
73
73
|
if (serverToolsConfigured(site)) {
|
|
@@ -89,7 +89,7 @@ async function readConfig(site, name) {
|
|
|
89
89
|
async function listConfigNames(site, prefix) {
|
|
90
90
|
if (site.serverTools?.bindings !== undefined) {
|
|
91
91
|
return pickConfigNames(toolResultData(await callBoundModuleTool(site, "configList", { prefix }, {
|
|
92
|
-
operation: "read", scope:
|
|
92
|
+
operation: "read", scope: configReadBindingScope(site), capabilities: ["configRead"],
|
|
93
93
|
})));
|
|
94
94
|
}
|
|
95
95
|
if (serverToolsConfigured(site)) {
|
|
@@ -308,9 +308,11 @@ async function scheduledContent({ site: siteName, type, sampleSize = 200 }) {
|
|
|
308
308
|
const overdue = [];
|
|
309
309
|
for (const n of nodes) {
|
|
310
310
|
for (const [field, action] of [["publish_on", "publish"], ["unpublish_on", "unpublish"]]) {
|
|
311
|
-
|
|
311
|
+
// Presence of the key (even null) means Scheduler is exposed (#403).
|
|
312
|
+
const { present, value } = fieldPresence(n, field);
|
|
313
|
+
if (present) sawField = true;
|
|
314
|
+
const raw = value && typeof value === "object" && "value" in value ? value.value : value;
|
|
312
315
|
if (raw === undefined || raw === null || raw === "" || raw === 0) continue;
|
|
313
|
-
sawField = true;
|
|
314
316
|
const ms = toMillis(raw);
|
|
315
317
|
if (ms === null) continue;
|
|
316
318
|
const rec = { id: n.id, title: n.title, action, when: new Date(ms).toISOString(), path: n.url };
|
package/src/tools/revisions.js
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
} from "../lib/security.js";
|
|
26
26
|
import {
|
|
27
27
|
assertDraftLangcode, readDraftTranslation, readNodeDraftInventory, readTranslationInventory,
|
|
28
|
+
isUnpublishedWorkingDraft,
|
|
28
29
|
} from "../lib/sentinel-draft.js";
|
|
29
30
|
import { omitLiveComputedMetatag } from "../lib/entity-response.js";
|
|
30
31
|
import { changedAheadOfRevision, prepareGuardedPatch, updateEntityGuarded } from "../lib/patch-preflight.js";
|
|
@@ -221,7 +222,7 @@ async function getRevision({ site: siteName, type, id, version, langcode }) {
|
|
|
221
222
|
const targetLang = assertDraftLangcode(langcode);
|
|
222
223
|
const inventory = await readTranslationInventory(backend, { entityType: "node", bundle: type, id });
|
|
223
224
|
const workingRow = (inventory.working?.translations ?? []).find((row) => row.langcode === targetLang);
|
|
224
|
-
if (!inventory.live?.vid || !inventory.working?.vid || !workingRow || workingRow
|
|
225
|
+
if (!inventory.live?.vid || !inventory.working?.vid || !workingRow || !isUnpublishedWorkingDraft(workingRow)) {
|
|
225
226
|
throw new Error("No unpublished working translation is addressable for this revision.");
|
|
226
227
|
}
|
|
227
228
|
const entity = await readDraftTranslation(backend, {
|
package/src/tools/scheduler.js
CHANGED
|
@@ -6,8 +6,12 @@
|
|
|
6
6
|
* the schedule on two entity fields — `publish_on` and `unpublish_on` — which the
|
|
7
7
|
* module adds to a content type when scheduling is enabled for that bundle.
|
|
8
8
|
*
|
|
9
|
+
* On a bundle under Content Moderation, Scheduler Content Moderation
|
|
10
|
+
* Integration also needs `publish_state` / `unpublish_state`. Dates without
|
|
11
|
+
* those states are accepted by Drupal and then fail at cron (#402).
|
|
12
|
+
*
|
|
9
13
|
* This is a thin write over the canonical backend's updateEntity: the supplied
|
|
10
|
-
* timestamps are passed straight through as those
|
|
14
|
+
* timestamps are passed straight through as those attributes. Scheduler
|
|
11
15
|
* accepts an ISO 8601 datetime string or a Unix epoch; we accept either form and
|
|
12
16
|
* forward it unchanged (the JSON:API backend coerces datetime fields as needed).
|
|
13
17
|
*
|
|
@@ -20,6 +24,8 @@
|
|
|
20
24
|
import { getSiteConfig } from "../lib/config.js";
|
|
21
25
|
import { resolveBackend } from "../lib/backends/index.js";
|
|
22
26
|
import { resolveSecurityConfig, assertWriteAllowed, redactCanonicalEntity } from "../lib/security.js";
|
|
27
|
+
import { entityLooksModerated } from "../lib/moderation-default.js";
|
|
28
|
+
import { fieldPresence } from "../lib/reports-support.js";
|
|
23
29
|
|
|
24
30
|
/**
|
|
25
31
|
* Heuristic: does this backend error look like a missing/unknown field? Scheduler
|
|
@@ -31,7 +37,19 @@ import { resolveSecurityConfig, assertWriteAllowed, redactCanonicalEntity } from
|
|
|
31
37
|
*/
|
|
32
38
|
export function looksLikeUnknownField(err) {
|
|
33
39
|
const msg = String(err?.message || err || "");
|
|
34
|
-
return /unknown|not exist|no field|invalid field|unrecognized|publish_on|unpublish_on/i.test(msg);
|
|
40
|
+
return /unknown|not exist|no field|invalid field|unrecognized|publish_on|unpublish_on|publish_state|unpublish_state/i.test(msg);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Whether a schema or entity exposes a Scheduler attribute.
|
|
45
|
+
* @param {?object} entity Canonical entity.
|
|
46
|
+
* @param {?object} schema Bundle schema from getEntitySchema.
|
|
47
|
+
* @param {string} field Field machine name.
|
|
48
|
+
* @returns {boolean}
|
|
49
|
+
*/
|
|
50
|
+
function schedulerFieldPresent(entity, schema, field) {
|
|
51
|
+
if (entity && fieldPresence(entity, field).present) return true;
|
|
52
|
+
return Boolean(schema?.attributes && Object.prototype.hasOwnProperty.call(schema.attributes, field));
|
|
35
53
|
}
|
|
36
54
|
|
|
37
55
|
/**
|
|
@@ -41,12 +59,18 @@ export function looksLikeUnknownField(err) {
|
|
|
41
59
|
* canonical updateEntity. At least one of publishOn / unpublishOn must be given.
|
|
42
60
|
* Each value is forwarded unchanged (ISO 8601 string or epoch integer).
|
|
43
61
|
*
|
|
44
|
-
*
|
|
62
|
+
* On a moderated bundle, `publishState` / `unpublishState` are required when
|
|
63
|
+
* the matching date is set and the Scheduler CM state field exists (#402).
|
|
64
|
+
*
|
|
65
|
+
* @param {object} args - { site?, type, id, publishOn?, unpublishOn?, publishState?, unpublishState? }.
|
|
45
66
|
* @returns {Promise<object>} The redacted, updated node descriptor.
|
|
46
67
|
* @throws {Error} If neither timestamp is supplied, if the policy forbids the
|
|
47
|
-
* write,
|
|
68
|
+
* write, if a moderated schedule is missing its target state, or — degraded —
|
|
69
|
+
* if the Scheduler fields are unknown on the bundle.
|
|
48
70
|
*/
|
|
49
|
-
async function schedulePublish({
|
|
71
|
+
async function schedulePublish({
|
|
72
|
+
site: siteName, type, id, publishOn, unpublishOn, publishState, unpublishState,
|
|
73
|
+
}) {
|
|
50
74
|
if (publishOn === undefined && unpublishOn === undefined) {
|
|
51
75
|
throw new Error("Provide at least one of publishOn or unpublishOn to schedule the node.");
|
|
52
76
|
}
|
|
@@ -55,9 +79,65 @@ async function schedulePublish({ site: siteName, type, id, publishOn, unpublishO
|
|
|
55
79
|
assertWriteAllowed(sec, "update", "node", type);
|
|
56
80
|
const backend = await resolveBackend(site);
|
|
57
81
|
|
|
82
|
+
let existing = null;
|
|
83
|
+
if (typeof backend.getEntity === "function") {
|
|
84
|
+
try {
|
|
85
|
+
existing = await backend.getEntity({ entityType: "node", bundle: type, id }) ?? null;
|
|
86
|
+
} catch {
|
|
87
|
+
existing = null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
let schema = null;
|
|
91
|
+
if (existing && typeof backend.getEntitySchema === "function") {
|
|
92
|
+
try {
|
|
93
|
+
schema = await backend.getEntitySchema("node", type);
|
|
94
|
+
} catch {
|
|
95
|
+
schema = null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const moderated = entityLooksModerated(existing);
|
|
100
|
+
if (moderated) {
|
|
101
|
+
if (publishOn !== undefined) {
|
|
102
|
+
const hasStateField = schedulerFieldPresent(existing, schema, "publish_state");
|
|
103
|
+
if (!hasStateField) {
|
|
104
|
+
throw new Error(
|
|
105
|
+
`Could not schedule publish on moderated '${type}': the publish_state field is not available. ` +
|
|
106
|
+
"Moderated bundles need the Scheduler Content Moderation Integration module, and a publishState " +
|
|
107
|
+
"(e.g. 'published') whenever publishOn is set. Without that state, cron logs 'Publishing failed' " +
|
|
108
|
+
"and the node never goes live. See connector #402."
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
if (publishState === undefined) {
|
|
112
|
+
throw new Error(
|
|
113
|
+
"Moderated content requires publishState (the workflow state to enter at publishOn, e.g. 'published'). " +
|
|
114
|
+
"Scheduler dates without a target state are accepted by Drupal and then fail at cron. See connector #402."
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (unpublishOn !== undefined) {
|
|
119
|
+
const hasStateField = schedulerFieldPresent(existing, schema, "unpublish_state");
|
|
120
|
+
if (!hasStateField) {
|
|
121
|
+
throw new Error(
|
|
122
|
+
`Could not schedule unpublish on moderated '${type}': the unpublish_state field is not available. ` +
|
|
123
|
+
"Moderated bundles need the Scheduler Content Moderation Integration module, and an unpublishState " +
|
|
124
|
+
"(e.g. 'draft') whenever unpublishOn is set. See connector #402."
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
if (unpublishState === undefined) {
|
|
128
|
+
throw new Error(
|
|
129
|
+
"Moderated content requires unpublishState (the workflow state to enter at unpublishOn, e.g. 'draft'). " +
|
|
130
|
+
"Scheduler dates without a target state are accepted by Drupal and then fail at cron. See connector #402."
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
58
136
|
const attributes = {};
|
|
59
137
|
if (publishOn !== undefined) attributes.publish_on = publishOn;
|
|
60
138
|
if (unpublishOn !== undefined) attributes.unpublish_on = unpublishOn;
|
|
139
|
+
if (publishState !== undefined) attributes.publish_state = publishState;
|
|
140
|
+
if (unpublishState !== undefined) attributes.unpublish_state = unpublishState;
|
|
61
141
|
|
|
62
142
|
let updated;
|
|
63
143
|
try {
|
|
@@ -67,6 +147,7 @@ async function schedulePublish({ site: siteName, type, id, publishOn, unpublishO
|
|
|
67
147
|
throw new Error(
|
|
68
148
|
`Could not set Scheduler dates on '${type}': the publish_on / unpublish_on fields are not available. ` +
|
|
69
149
|
"This tool requires the Drupal Scheduler module to be installed and enabled for this content type. " +
|
|
150
|
+
"Moderated bundles also need Scheduler Content Moderation Integration (publish_state / unpublish_state). " +
|
|
70
151
|
`Backend error: ${err?.message || err}`,
|
|
71
152
|
);
|
|
72
153
|
}
|
|
@@ -85,18 +166,24 @@ export const definitions = [
|
|
|
85
166
|
description:
|
|
86
167
|
"Schedule a content node to publish and/or unpublish at a future time using the Drupal Scheduler module. " +
|
|
87
168
|
"Sets the publish_on and unpublish_on fields on the node. " +
|
|
169
|
+
"On a bundle under Content Moderation, also set publishState / unpublishState " +
|
|
170
|
+
"(written as publish_state / unpublish_state) — dates alone never go live. " +
|
|
88
171
|
"Requires the Scheduler module to be installed and enabled for the content type, with the publish_on / " +
|
|
89
172
|
"unpublish_on fields present on the bundle — otherwise the call fails with a clear capability error. " +
|
|
173
|
+
"Moderated bundles additionally need Scheduler Content Moderation Integration; when those state fields " +
|
|
174
|
+
"exist, the matching state is required whenever a date is set. " +
|
|
90
175
|
"Timestamps accept ISO 8601 (e.g. '2026-07-01T12:00:00Z') or a Unix epoch and are passed through unchanged. " +
|
|
91
176
|
"Provide at least one of publishOn or unpublishOn.",
|
|
92
177
|
inputSchema: {
|
|
93
178
|
type: "object", required: ["type", "id"],
|
|
94
179
|
properties: {
|
|
95
|
-
site:
|
|
96
|
-
type:
|
|
97
|
-
id:
|
|
98
|
-
publishOn:
|
|
99
|
-
unpublishOn:
|
|
180
|
+
site: { type: "string", description: "Named site (omit for default)" },
|
|
181
|
+
type: { type: "string", description: "Content type machine name, e.g. 'article'" },
|
|
182
|
+
id: { type: "string", description: "Node UUID" },
|
|
183
|
+
publishOn: { type: ["string", "number"], description: "When to publish — ISO 8601 datetime or Unix epoch. Sets the Scheduler publish_on field." },
|
|
184
|
+
unpublishOn: { type: ["string", "number"], description: "When to unpublish — ISO 8601 datetime or Unix epoch. Sets the Scheduler unpublish_on field." },
|
|
185
|
+
publishState: { type: "string", description: "Moderation state to enter at publishOn (e.g. 'published'). Required on moderated bundles when publishOn is set." },
|
|
186
|
+
unpublishState: { type: "string", description: "Moderation state to enter at unpublishOn (e.g. 'draft'). Required on moderated bundles when unpublishOn is set." },
|
|
100
187
|
},
|
|
101
188
|
},
|
|
102
189
|
},
|
package/src/tools/structure.js
CHANGED
|
@@ -156,7 +156,7 @@ async function createMenuLink({ site: siteName, title, link, menu, weight, paren
|
|
|
156
156
|
* site — so an unrelated edit (rename, re-weight, re-parent) can never silently
|
|
157
157
|
* regress a live link to disabled. Set `parent` to re-nest a link.
|
|
158
158
|
*
|
|
159
|
-
* @param {object} args - { site?, id, title?, link?, menu?, weight?, parent?, enabled? }.
|
|
159
|
+
* @param {object} args - { site?, id, title?, link?, menu?, weight?, parent?, enabled?, langcode? }.
|
|
160
160
|
* @returns {Promise<object>} The updated menu-link descriptor from the backend.
|
|
161
161
|
* @throws {Error} If id is missing.
|
|
162
162
|
* @throws {SecurityError} If updating menu_link_content is not permitted.
|
|
@@ -243,7 +243,7 @@ async function createBlock({ site: siteName, type, info, body }) {
|
|
|
243
243
|
export const definitions = [
|
|
244
244
|
{
|
|
245
245
|
name: "drupal_list_menu_links",
|
|
246
|
-
description: "List custom (content) menu links, optionally scoped to a single menu (e.g. 'main', 'footer'). Returns each link's title, target URI, menu, and
|
|
246
|
+
description: "List custom (content) menu links, optionally scoped to a single menu (e.g. 'main', 'footer'). Returns each link's title, target URI, menu, weight, and fields.drupal_internal__id (numeric id for /admin/structure/menu/item/{id}/edit). Note: this lists editable menu_link_content entities, not code-defined static links.",
|
|
247
247
|
inputSchema: {
|
|
248
248
|
type: "object",
|
|
249
249
|
properties: {
|
|
@@ -273,7 +273,7 @@ export const definitions = [
|
|
|
273
273
|
},
|
|
274
274
|
{
|
|
275
275
|
name: "drupal_update_menu_link",
|
|
276
|
-
description: "Update a custom menu link by UUID (rename, re-weight, re-target, re-parent, enable/disable). Only the fields you pass change. The link's enabled state is preserved across edits — an unrelated change will not disable a live link — unless you pass 'enabled' explicitly. Checked against the site security config.",
|
|
276
|
+
description: "Update a custom menu link by UUID (rename, re-weight, re-target, re-parent, enable/disable). Only the fields you pass change. The link's enabled state is preserved across edits — an unrelated change will not disable a live link — unless you pass 'enabled' explicitly. Pass langcode to update an existing translation via JSON:API language negotiation (URL prefix, then language headers). Does not create a missing translation. Responses include fields.drupal_internal__id. Checked against the site security config.",
|
|
277
277
|
inputSchema: {
|
|
278
278
|
type: "object", required: ["id"],
|
|
279
279
|
properties: {
|
|
@@ -285,7 +285,7 @@ export const definitions = [
|
|
|
285
285
|
weight: { type: "number", description: "New ordering weight. Omit to leave unchanged." },
|
|
286
286
|
parent: { type: "string", description: "New parent link plugin id (e.g. 'menu_link_content:<uuid>'), or '' for top level. Omit to leave unchanged." },
|
|
287
287
|
enabled: { type: "boolean", description: "Enable/disable the link. Omit to preserve the current state." },
|
|
288
|
-
langcode: { type: "string", description: "Existing translation to update (e.g. 'es'). Omit for the default language. Does not create a missing translation." },
|
|
288
|
+
langcode: { type: "string", description: "Existing translation to update (e.g. 'es'). Uses /{langcode}/jsonapi/… when the site prefixes JSON:API, then language headers. Omit for the default language. Does not create a missing translation." },
|
|
289
289
|
},
|
|
290
290
|
},
|
|
291
291
|
},
|
|
@@ -45,6 +45,9 @@ import {
|
|
|
45
45
|
supportsRevisePublishedTranslation,
|
|
46
46
|
supportsReviseOverWorkingCopy,
|
|
47
47
|
supportsSentinelDraft,
|
|
48
|
+
isUnpublishedWorkingDraft,
|
|
49
|
+
isCarriedPublishedLanguage,
|
|
50
|
+
carriedPublishedLanguageError,
|
|
48
51
|
} from "../lib/sentinel-draft.js";
|
|
49
52
|
import { mapTranslationRow } from "../lib/translation-rows.js";
|
|
50
53
|
import { dryRunChecks, PREFLIGHT_SENTINEL_DRAFT } from "../lib/dry-run-checks.js";
|
|
@@ -179,13 +182,16 @@ async function createTranslation({
|
|
|
179
182
|
if (hasWorking) {
|
|
180
183
|
const workingRow = (draftRevision.inventory?.working?.translations ?? [])
|
|
181
184
|
.find((row) => row?.langcode === targetLang);
|
|
182
|
-
if (workingRow && workingRow
|
|
185
|
+
if (workingRow && isUnpublishedWorkingDraft(workingRow)) {
|
|
183
186
|
const continuer = entityType === "media" ? "drupal_update_media" : "drupal_update_node";
|
|
184
187
|
throw new Error(
|
|
185
188
|
"This language is already an unpublished draft on the working copy. " +
|
|
186
189
|
`Continue it with ${continuer} and langcode. Revise was not attempted.`,
|
|
187
190
|
);
|
|
188
191
|
}
|
|
192
|
+
if (workingRow && isCarriedPublishedLanguage(workingRow)) {
|
|
193
|
+
throw carriedPublishedLanguageError(targetLang);
|
|
194
|
+
}
|
|
189
195
|
// Sentinel 2.25.0+ builds the draft on the named working copy; the
|
|
190
196
|
// pair is sent as If-Match "live:working" by createTranslationDraft.
|
|
191
197
|
if (!supportsReviseOverWorkingCopy(draftRevision)) {
|