feat: add update_all_libraries MCP tool to bulk-rewrite library versions
Walks the project's Library Manager and rewrites every library reference's
version to a single target value (default '*' = always-newest installed).
The immediate motivating case: X33 has a StringUtils 3.5.18.0 pin that
no longer resolves on SP21/SP22 boxes, plus several other version-pinned
references that should track the locally-installed copies.
- src/scripts/update_all_libraries.py:
Defensive multi-pattern enumeration -- ScriptLibraryManager exposes
different ref-listing methods across SP versions (get_all_libraries
/ get_libraries / get_references / iteration). Same for the per-ref
version-mutation API: set_version / update_to_version / update /
set_resolution. Falls back to remove + re-add if no in-place setter
is exposed.
Skips refs flagged is_system unless INCLUDE_SYSTEM=True (system
pins are usually device-tied and changing them breaks the project).
Emits a per-library before/after table; a single failure makes the
whole tool report SCRIPT_ERROR so partial-update states are loud.
- src/server.ts:
Wires as 'update_all_libraries' with optional targetVersion (default
'*') and includeSystem (default false). Uses ensure_project_open so
the tool works against any project on disk, not just the currently
loaded one.
Companion 'set_library_version' (single-library surgical change) coming
in the next commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
79c83fd4c3
commit
9a4e96f11a
2 changed files with 209 additions and 0 deletions
180
src/scripts/update_all_libraries.py
Normal file
180
src/scripts/update_all_libraries.py
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
import sys, scriptengine as script_engine, os, traceback
|
||||
|
||||
TARGET_VERSION = "{TARGET_VERSION}"
|
||||
INCLUDE_SYSTEM = {INCLUDE_SYSTEM}
|
||||
|
||||
try:
|
||||
print("DEBUG: update_all_libraries: TargetVersion='%s', IncludeSystem=%s, Project='%s'" % (
|
||||
TARGET_VERSION, INCLUDE_SYSTEM, PROJECT_FILE_PATH))
|
||||
primary_project = ensure_project_open(PROJECT_FILE_PATH)
|
||||
if not TARGET_VERSION:
|
||||
raise ValueError("Target version is empty.")
|
||||
|
||||
# Locate Library Manager (same pattern as add_library.py).
|
||||
lib_manager = None
|
||||
try:
|
||||
found = primary_project.find("Library Manager", True)
|
||||
if found:
|
||||
lib_manager = found[0]
|
||||
except Exception as e:
|
||||
print("DEBUG: find('Library Manager') failed: %s" % e)
|
||||
if lib_manager is None:
|
||||
try:
|
||||
for child in primary_project.get_children(True):
|
||||
nm = getattr(child, 'get_name', lambda: '')()
|
||||
if 'library' in nm.lower() and 'manager' in nm.lower():
|
||||
lib_manager = child
|
||||
break
|
||||
except Exception as e:
|
||||
print("DEBUG: child search for Library Manager failed: %s" % e)
|
||||
if lib_manager is None:
|
||||
raise RuntimeError("Library Manager not found in project.")
|
||||
|
||||
# Enumerate library references. The exact method name varies across SP
|
||||
# versions, so probe several.
|
||||
refs = []
|
||||
for method_name in ('get_all_libraries', 'get_libraries', 'get_references', 'libraries', 'references'):
|
||||
attr = getattr(lib_manager, method_name, None)
|
||||
if attr is None:
|
||||
continue
|
||||
try:
|
||||
value = attr() if callable(attr) else attr
|
||||
refs = list(value)
|
||||
print("DEBUG: enumerated via %s -> %d refs" % (method_name, len(refs)))
|
||||
break
|
||||
except Exception as e:
|
||||
print("DEBUG: %s() failed: %s" % (method_name, e))
|
||||
if not refs:
|
||||
try:
|
||||
refs = list(lib_manager)
|
||||
print("DEBUG: enumerated via iter(lib_manager) -> %d refs" % len(refs))
|
||||
except Exception:
|
||||
pass
|
||||
if not refs:
|
||||
raise RuntimeError("Could not enumerate library references via any known API.")
|
||||
|
||||
def _attr(obj, name, default=''):
|
||||
v = getattr(obj, name, None)
|
||||
if v is None:
|
||||
return default
|
||||
try:
|
||||
return v() if callable(v) else v
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
def _is_system(ref):
|
||||
v = getattr(ref, 'is_system', None)
|
||||
if v is None:
|
||||
return False
|
||||
try:
|
||||
return bool(v() if callable(v) else v)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
results = []
|
||||
ok = 0
|
||||
skipped = 0
|
||||
failed = 0
|
||||
|
||||
for ref in refs:
|
||||
try:
|
||||
name = _attr(ref, 'get_name', '?')
|
||||
if name == '?':
|
||||
name = _attr(ref, 'name', '?')
|
||||
old_version = str(_attr(ref, 'get_version', '?'))
|
||||
if old_version == '?':
|
||||
old_version = str(_attr(ref, 'version', '?'))
|
||||
namespace = _attr(ref, 'get_namespace', '')
|
||||
if not namespace:
|
||||
namespace = _attr(ref, 'namespace', '')
|
||||
|
||||
if _is_system(ref) and not INCLUDE_SYSTEM:
|
||||
results.append((name, namespace, old_version, old_version, "skipped (system)"))
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
# Try in-place update methods first.
|
||||
updated = False
|
||||
last_err = None
|
||||
for method_name in ('set_version', 'update_to_version', 'update', 'set_resolution'):
|
||||
if not hasattr(ref, method_name):
|
||||
continue
|
||||
try:
|
||||
getattr(ref, method_name)(TARGET_VERSION)
|
||||
updated = True
|
||||
print("DEBUG: %s.%s('%s') OK" % (name, method_name, TARGET_VERSION))
|
||||
break
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
print("DEBUG: %s.%s('%s') failed: %s" % (name, method_name, TARGET_VERSION, e))
|
||||
|
||||
# Fallback: remove + re-add at the new version.
|
||||
if not updated:
|
||||
try:
|
||||
if hasattr(lib_manager, 'remove_library'):
|
||||
lib_manager.remove_library(ref)
|
||||
elif hasattr(lib_manager, 'remove'):
|
||||
lib_manager.remove(ref)
|
||||
else:
|
||||
raise RuntimeError("no remove_library / remove method")
|
||||
|
||||
added = False
|
||||
for add_name in ('add_placeholder_library', 'add_library', 'insert_library'):
|
||||
if not hasattr(lib_manager, add_name):
|
||||
continue
|
||||
try:
|
||||
getattr(lib_manager, add_name)(name, TARGET_VERSION)
|
||||
added = True
|
||||
break
|
||||
except TypeError:
|
||||
try:
|
||||
getattr(lib_manager, add_name)(name)
|
||||
added = True
|
||||
break
|
||||
except Exception as e2:
|
||||
last_err = e2
|
||||
except Exception as e2:
|
||||
last_err = e2
|
||||
if added:
|
||||
updated = True
|
||||
print("DEBUG: remove + re-add for %s OK" % name)
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
print("DEBUG: remove+add failed for %s: %s" % (name, e))
|
||||
|
||||
if updated:
|
||||
results.append((name, namespace, old_version, TARGET_VERSION, "ok"))
|
||||
ok += 1
|
||||
else:
|
||||
results.append((name, namespace, old_version, old_version, "failed: %s" % last_err))
|
||||
failed += 1
|
||||
except Exception as e:
|
||||
results.append(("?", "?", "?", "?", "error: %s" % e))
|
||||
failed += 1
|
||||
|
||||
try:
|
||||
primary_project.save()
|
||||
except Exception as save_err:
|
||||
print("ERROR: save failed: %s" % save_err)
|
||||
print("SCRIPT_ERROR: save failed after updates: %s" % save_err)
|
||||
sys.exit(1)
|
||||
|
||||
print("Library update summary:")
|
||||
print(" total=%d updated=%d skipped=%d failed=%d" % (len(refs), ok, skipped, failed))
|
||||
print(" target version: %s" % TARGET_VERSION)
|
||||
print("Per-library:")
|
||||
for name, ns, old, new, status in results:
|
||||
prefix = "%s.%s" % (ns, name) if ns else name
|
||||
print(" %-50s %s -> %s [%s]" % (prefix, old, new, status))
|
||||
|
||||
if failed > 0:
|
||||
print("SCRIPT_ERROR: %d libraries failed to update; see lines above" % failed)
|
||||
sys.exit(1)
|
||||
print("SCRIPT_SUCCESS: %d libraries updated, %d skipped" % (ok, skipped))
|
||||
sys.exit(0)
|
||||
except Exception as e:
|
||||
detailed = traceback.format_exc()
|
||||
msg = "Error updating libraries in '%s': %s\n%s" % (PROJECT_FILE_PATH, e, detailed)
|
||||
print(msg)
|
||||
print("SCRIPT_ERROR: %s" % msg)
|
||||
sys.exit(1)
|
||||
|
|
@ -1180,6 +1180,35 @@ export async function startMcpServer(config: ServerConfig): Promise<void> {
|
|||
}
|
||||
);
|
||||
|
||||
s.tool(
|
||||
'update_all_libraries',
|
||||
"Updates every library reference in a project's Library Manager to a target version (default '*' = always-newest installed). By default skips system-pinned references; set includeSystem=true to also rewrite those (risky for device-tied projects). Saves the project on success.",
|
||||
{
|
||||
projectFilePath: z.string().describe("Path to the project file."),
|
||||
targetVersion: z.string().optional().describe("Version to set for each library reference. '*' (default) means always-newest installed; otherwise an exact version string like '3.5.20.0'."),
|
||||
includeSystem: z.boolean().optional().describe("If true, also update libraries flagged as system. Default: false. Be careful -- changing system library versions can break device-tied projects."),
|
||||
},
|
||||
async (args: { projectFilePath: string; targetVersion?: string; includeSystem?: boolean }) => {
|
||||
const escaped = resolvePath(args.projectFilePath, workspaceDir);
|
||||
const target = (args.targetVersion ?? '*').trim();
|
||||
const include = args.includeSystem === true;
|
||||
const script = scriptManager.prepareScriptWithHelpers(
|
||||
'update_all_libraries',
|
||||
{
|
||||
PROJECT_FILE_PATH: escaped,
|
||||
TARGET_VERSION: target,
|
||||
INCLUDE_SYSTEM: include ? 'True' : 'False',
|
||||
},
|
||||
['ensure_project_open']
|
||||
);
|
||||
const result = await executor.executeScript(script);
|
||||
return formatToolResponse(
|
||||
result,
|
||||
`Library references updated to '${target}' in ${args.projectFilePath}. Project saved.`
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
s.tool(
|
||||
'install_library_from_url',
|
||||
"Downloads a .library file from a URL (HTTPS supported, follows redirects) and installs it into the CODESYS Library Repository. Companion to install_library_file for fully-automated bring-up from internal shares, vendor download URLs, or GitHub release assets. Does not need a project to be open.",
|
||||
|
|
|
|||
Loading…
Reference in a new issue