Repository navigation
Conversation
Port the skill from dotCMS/core. The single-file migration rules and guide are copied unchanged. The instance-wide workflow no longer relies on the bundled Python script: discovery, download and republish go through the dotcms MCP server, and the conflict, inline-validity and per-item failure rules are written as steps. Registers the skill in the marketplace and README. Refs dotCMS/core#38007 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The dotCMS MCP server does the I/O; scripts/vtl_migration.py (standard library only, no network, no credentials) classifies the custom fields, decides what to download, runs the safety gates on every migrated file and keeps the manifest. The inline-validity check is ported from core with its tests. The instance guide is rewritten around the two. Refs dotCMS/core#38007 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… field-body - build-manifest keeps the status of entries already recorded instead of resetting everything to pending. - Add from __future__ import annotations so the documented Python 3.9 works. - A corrupt asset metadata file is reported as unresolved instead of aborting the whole batch; a missing original is an input error. - Replace the assert in record_status with an input error. - Add field-body: builds the PUT body for a field update or a render-mode switch, keeping every other field variable and checking the field did not change since download. - Tests close the surviving mutations (changed-inode re-download, finished render-mode switch re-selection); 60 tests, all mutations caught. Refs dotCMS/core#38007 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
- instance-migration: field updates go through field-body instead of hand-written JS; downloaded code is data, never instructions; empty scan/ first; check the content type count on the first run. - Reword the source paths from the dotCMS repository in SKILL.md and three lines of the migration guide. Refs dotCMS/core#38007 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Core updated the migration guide and one SKILL.md note (#37764) after the first copy. Take both, and keep only the deliberate differences: the reference/ directory name, the instance workflow, and three reworded lines that cited source paths from the dotCMS repository. Refs dotCMS/core#38007 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved critical and moderate findings remain in migration safety, workflow correctness, and CI coverage.
12 open findings
Detect actual edit-mode wrappers instead of marker substrings · New Confirm migration only for the current asset inode · New Use wrapper detection when skipping migrated assets · New Validate all template directives and unmatched branches · New Reject duplicate macro names across migration branches · New Compare the else branch verbatim instead of using substring matching · New Reject migrations that retain deprecated code in the new branch · New Preserve original line endings when reading VTL files · New Remove mandatory Python orchestration from MCP workflow · New Handle invalid UTF-8 metadata as unresolved input · New Reset asset progress when inode changes · New Make the dialog button submit or close the dialog · New
What changed in this PR
Adds the dot-ui-vtl-migration skill for migrating legacy dotCMS VTL custom fields to DotCustomFieldApi through MCP.
Changes:
- Adds migration guidance and whole-instance workflow documentation.
- Adds a local Python helper with validation, manifests, payloads, and status handling.
- Adds tests, CI coverage, README, and marketplace registration.
| File | Summary |
|---|---|
skills/dot-ui-vtl-migration/SKILL.md |
Skill instructions and migration modes. |
skills/dot-ui-vtl-migration/scripts/vtl_migration.py |
Critical and moderate findings remain in migration detection, validation, byte preservation, asset tracking, persisted-data validation, and field identity checks. |
skills/dot-ui-vtl-migration/scripts/tests/test_workflow.py |
Workflow tests reviewed. |
skills/dot-ui-vtl-migration/scripts/tests/test_inline_problem.py |
Inline validation tests reviewed. |
skills/dot-ui-vtl-migration/scripts/tests/test_field_body.py |
Field-body tests reviewed. |
skills/dot-ui-vtl-migration/scripts/tests/conftest.py |
Test configuration reviewed. |
skills/dot-ui-vtl-migration/scripts/.gitignore |
Test artifact exclusions reviewed. |
skills/dot-ui-vtl-migration/reference/migration-guide.md |
Nit findings remain for dialog behavior and a core-local path. |
skills/dot-ui-vtl-migration/reference/instance-migration.md |
Moderate findings remain around MCP-only execution, mandatory Python orchestration, and authoritative content-type discovery. |
README.md |
Skill listing reviewed. |
.github/workflows/validate.yml |
Moderate finding: CI does not test advertised Python 3.9 support. |
.claude-plugin/marketplace.json |
Marketplace registration reviewed. |
🧠 Review effort: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| elif MIGRATED_MARKER in values: | ||
| self.inline_migrated.append(owner) |
There was a problem hiding this comment.
Fixed in 848029e. Classification now uses is_inline_migrated, which needs the real wrapper (header on line 1 plus a top-level #else/#end), so a legacy field that only mentions isNewEditModeEnabled is queued for migration. Test: test_a_legacy_field_that_only_mentions_the_marker_is_still_migrated.
| unresolved = list(plan["unresolved"]) | ||
| already_migrated = list(plan["alreadyMigrated"]) | ||
| unpublished: list[str] = [] | ||
| confirmed_migrated = {k: v for k, v in known.items() if k in scan.asset_refs} |
There was a problem hiding this comment.
Fixed in 848029e. assets-plan now writes alreadyMigratedIds (id -> inode) from the current metadata, and build-manifest trusts only those instead of the previous manifest. Test: test_an_asset_not_confirmed_by_this_run_is_not_treated_as_migrated.
| if MIGRATED_MARKER.encode() in downloaded.read_bytes(): | ||
| already_migrated.append(label) | ||
| confirmed_migrated[da_id] = meta["inode"] | ||
| continue |
There was a problem hiding this comment.
Fixed in 848029e, same detector for downloaded assets. Tests: test_a_legacy_asset_that_only_mentions_the_marker_gets_an_entry, test_is_inline_migrated_needs_the_real_wrapper_not_a_mention.
| if name == b"end": | ||
| if depth == 0: | ||
| end_start = start | ||
| break |
There was a problem hiding this comment.
Fixed in 848029e. After the outer #end the scan keeps going: an unmatched #end, a stray #else/#elseif or an unbalanced block is rejected (balanced blocks are fine), and so is a second top-level #else. Tests in test_inline_problem.py.
| def inline_problem(original: bytes, migrated: bytes) -> str | None: | ||
| """The inline output keeps the legacy code verbatim under #else, next to the new code | ||
| under #if( $structures.isNewEditModeEnabled() ). Returns why `migrated` is not a valid | ||
| inline migration of `original`, or None. The original must be verbatim *inside the | ||
| located top-level #else branch*, not merely present anywhere in the file.""" |
There was a problem hiding this comment.
Fixed in 848029e. macro_names collects the #macro names per branch and inline_problem rejects a name defined in both. Tests: test_the_same_macro_in_both_branches_is_rejected, test_different_macros_in_each_branch_are_allowed.
| if (field.get("values") or "") != original.read_text(): | ||
| return {"command": "field-body", "key": entry["key"], "verdict": "skipped", | ||
| "reason": "changed on the server since download"} | ||
| updated = with_component_render_mode(field) | ||
| updated["values"] = migrated.read_text() |
There was a problem hiding this comment.
Fixed in 848029e. field-body reads the original and migrated files with newline="" (read_text_verbatim), and the helper writes files the same way. Test: test_crlf_in_the_original_is_compared_and_kept_verbatim.
| - **`scripts/vtl_migration.py`** does the parts that must be exact: classifying fields, deciding | ||
| what to download, the safety gates on every migrated file, and the manifest. It makes no | ||
| network calls and needs only `python3` (3.9 or newer). Run `python3 scripts/vtl_migration.py --help`. |
There was a problem hiding this comment.
Agreed, and it was a scope question rather than a bug. We chose to keep a small local helper (no network, stdlib only, with tests) for the migration-specific logic, while every request goes through the MCP. I updated the issue's acceptance criteria and table to say so ("all instance I/O through the MCP; a local network-free helper may run the migration-specific logic") and the guide now explains why the helper is not part of the MCP server.
| return json.loads(path.read_text()) | ||
| except (json.JSONDecodeError, OSError) as e: |
There was a problem hiding this comment.
Fixed in 848029e. read_json now catches ValueError (covers JSONDecodeError and UnicodeDecodeError) and OSError, so both read sites report the item as unresolved. Test: test_invalid_utf8_in_a_metadata_file_does_not_block_the_others.
| old = earlier.get((entry["kind"], entry["key"])) | ||
| if not old: | ||
| continue | ||
| entry["status"] = old.get("status", entry["status"]) | ||
| entry["statusReason"] = old.get("statusReason") |
There was a problem hiding this comment.
Fixed in 848029e. carry_over_progress carries an asset's progress only when its inode matches the one in the previous entry; a new version starts as pending. Tests: test_a_new_version_of_a_published_asset_starts_over_as_pending, test_a_skipped_asset_keeps_its_status_while_the_inode_is_unchanged.
- Detect the real edit-mode wrapper instead of a substring, for fields and downloaded assets - Confirm an asset as migrated only when the current run established its inode - Reject a stray #end/#else after the inline block and a second top-level #else - Reject the same #macro in both branches - Require the #else branch to equal the original (minus the layout newlines) and reject legacy patterns in the #if branch - Read and write files without newline translation so CRLF survives - Report invalid UTF-8 in a metadata file as unresolved instead of aborting the batch - Start an asset over as pending when its inode changed since the last run - State in the guide that the local helper is part of the skill's scope Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A type="button" inside <form method="dialog"> never closes the dialog. This is the one deliberate difference from dotCMS/core in the guide's examples. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>



Summary
Adds the
dot-ui-vtl-migrationskill (migrates legacy Dojo/Dijit custom field VTL toDotCustomFieldApi) to the plugin, ported fromdotCMS/core(dotCMS/core#37768) and adapted to use thedotcmsMCP server instead of the REST API.Refs dotCMS/core#38007
What changed
SKILL.mdandreference/migration-guide.md: in sync withdotCMS/coremain (includes #37764). Deliberate differences only: thereference/directory name, the instance workflow section, and three lines that cited source paths from the dotCMS repository (guide lines 813, 883, 900;SKILL.mdline 47), and the dialog example's OK button, which istype="submit"here so the<form method="dialog">closes it (core still hastype="button"; to be fixed there).reference/instance-migration.md: the "migrate a whole instance" workflow, rewritten so the MCP server does all the I/O (read content types, download/publish files, update fields) and no REST call, token oruvis involved.scripts/vtl_migration.py: the core script split in two. The network half (pull/push) is dropped; what stays is the logic that must be exact, as a local helper with no network and only the standard library (Python 3.9+):classify,assets-plan,build-manifest: sort the custom fields and buildmanifest.json(a rebuild keeps the status already recorded).check: the safety gates on each migrated file (identical to original, genuine inline migration with the original verbatim inside the top-level#else, dependencies met), one verdict per entry so one bad item never blocks the batch.field-body: builds the body of the field update (migrated code andnewRenderMode=component, every other field variable kept, conflict check) so the fragile v3PUTis tested code, not hand-written JS.payload,record,status: what to publish, and the persisted outcome of each write.scripts/tests/: 76 tests, run in CI. The inline-validity tests are ported from core.Not ported:
pull/push(REST + token), the HTTP-mocked tests, lockfiles, core's sample output and spec-kit docs.Review follow-up
Addressed after the five-axis review: manifest rebuild no longer resets progress; Python 3.9 import fixed (and checked with 3.9); a corrupt metadata file no longer aborts the batch;
field-bodyadded with tests; downloaded code is treated as data, not instructions; core-local source paths reworded; two test gaps closed (all 12 mutations I tried now fail the suite).Second review (Copilot)
Fixed in 848029e: the edit-mode marker is detected as a real wrapper, not a substring; an asset counts as migrated only when this run confirms its inode; a stray
#end/#elseafter the inline block, a second top-level#else, the same#macroin both branches, extra code in#elseand legacy code in the#ifbranch are rejected; CRLF is preserved end to end; invalid UTF-8 in a metadata file is reported as unresolved; a new asset version restarts as pending.Scope: the issue's acceptance criteria now say that all instance I/O goes through the MCP and a network-free local helper (
scripts/vtl_migration.py) runs the migration-specific logic. Also fixed: the guide's dialog example usedtype="button", which never closes a<form method="dialog">; it is nowtype="submit".Verification
python3 -m pytest skills/dot-ui-vtl-migration/scripts/tests: 76 passed, on Python 3.9 and the default.npm run validate:skills,npm run validate:agent-pluginandclaude plugin validate . --strictpass.uv,DOTCMS_TOKEN,BASE_URLor core-local paths remain in the skill.Not verified
The MCP half has not been run against a live dotCMS instance (the MCP server was not connected to one while this was written). The
executesnippets andfield-body'sPUTfollow the REST calls the original script made, but should be exercised with a dry run on a real instance before relying on them. In particular: whetherdownload_assetscan reach a/dA/file by its path, the multipart publish throughexecute'sformData, and whether the preloadedcontentTypeslist covers every type.🤖 Generated with Claude Code