diff --git a/src/scripts/bump_project_version.py b/src/scripts/bump_project_version.py new file mode 100644 index 0000000..08d7726 --- /dev/null +++ b/src/scripts/bump_project_version.py @@ -0,0 +1,107 @@ +import sys, scriptengine as script_engine, traceback + +# Bumps one part of the 4-part Project Information.version field of the +# primary project. Convention (per CODESYS / 3S / WAGO library practice): +# +# Major -- bump on incompatible API break. +# Minor -- bump on backward-compatible feature add. +# Revision -- bump on bug fix only (no API change). +# Build -- internal counter, often 0 for hand-released versions. +# +# Bumping a higher part resets all lower parts to 0 (e.g. bumping minor +# resets revision and build). +# +# The Version field is read/written as a property on the "Project +# Information" node (first child of the primary project). IronPython +# coerces strings like "1.2.3.4" to System.Version automatically; we +# stringify on the read side because str(System.Version) gives +# the dotted form back. None / empty are treated as "0.0.0.0". + +LEVEL = "{LEVEL}" # major | minor | revision | build + +VALID_LEVELS = ('major', 'minor', 'revision', 'build') + + +def parse_version(v): + """Parse a version-like value into a 4-tuple of ints, defaulting missing + parts to 0. Accepts None, '', '1.2', '1.2.3', '1.2.3.4', or a + System.Version. Raises ValueError on anything that can't be parsed.""" + if v is None: + return (0, 0, 0, 0) + s = str(v).strip() + if not s or s == 'None': + return (0, 0, 0, 0) + parts = s.split('.') + if len(parts) > 4: + raise ValueError("version '%s' has more than 4 parts" % s) + nums = [] + for p in parts: + try: + nums.append(int(p)) + except ValueError: + raise ValueError("version '%s' has non-integer part '%s'" % (s, p)) + while len(nums) < 4: + nums.append(0) + return tuple(nums) + + +def bump(parts, level): + major, minor, revision, build = parts + if level == 'major': + return (major + 1, 0, 0, 0) + if level == 'minor': + return (major, minor + 1, 0, 0) + if level == 'revision': + return (major, minor, revision + 1, 0) + if level == 'build': + return (major, minor, revision, build + 1) + raise ValueError("unknown bump level '%s' (must be one of %s)" % (level, ', '.join(VALID_LEVELS))) + + +try: + if LEVEL not in VALID_LEVELS: + raise ValueError("level must be one of %s, got '%s'" % (', '.join(VALID_LEVELS), LEVEL)) + + primary_project = ensure_project_open(PROJECT_FILE_PATH) + + pi = None + for child in primary_project.get_children(False): + try: + if child.get_name() == 'Project Information': + pi = child + break + except Exception: + pass + if pi is None: + raise RuntimeError( + "Project Information node not found at the project root. " + "Every CODESYS project should have one as its first child; " + "if missing, recreate it via Project menu -> Project Information.") + + before_raw = pi.version + before_str = str(before_raw) if before_raw is not None else None + before_parts = parse_version(before_raw) + after_parts = bump(before_parts, LEVEL) + after_str = '%d.%d.%d.%d' % after_parts + + print("DEBUG: bump_project_version: level=%s before=%s -> after=%s" % ( + LEVEL, before_str, after_str)) + + pi.version = after_str + + try: + primary_project.save() + print("DEBUG: project.save() succeeded after version bump.") + except Exception as save_e: + print("WARNING: project.save() raised %s -- bump applied in memory but may not persist across IDE close." % save_e) + + print("Project Information.Version: %s -> %s" % (before_str, after_str)) + print("SCRIPT_SUCCESS: bump_project_version complete.") + sys.exit(0) +except Exception as e: + detailed = traceback.format_exc() + msg = "Error in bump_project_version for project '%s': %s\n%s" % ( + PROJECT_FILE_PATH, e, detailed) + print(msg) + print("SCRIPT_ERROR: %s" % msg) + sys.exit(1) diff --git a/src/server.ts b/src/server.ts index 6cb5315..9cb9310 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1346,6 +1346,33 @@ export async function startMcpServer(config: ServerConfig): Promise { } ); + // ─── Project metadata ──────────────────────────────────────────────── + + s.tool( + 'bump_project_version', + "Bumps one part of the 4-part Project Information.Version field of the primary project (Major.Minor.Revision.Build) and saves the project. Convention: major = incompatible API break (rename FB / change public signature / remove method); minor = backward-compatible feature add (new FB / GVL / method); revision = bug fix only; build = internal counter, often 0 for hand-released versions. Bumping a higher part resets all lower parts to 0 (e.g. bumping minor resets revision and build to 0). Treats None / empty / unset version as '0.0.0.0'. The Version is exposed at runtime via the Project Information library's GetVersion() helper, which IEC code can call to surface the running version.", + { + projectFilePath: z.string().describe("Path to the project file."), + level: z.enum(['major', 'minor', 'revision', 'build']).describe("Which part of the 4-part version to bump. Major = incompatible API break. Minor = backward-compatible feature add. Revision = bug fix only. Build = internal / CI counter."), + }, + async (args: { projectFilePath: string; level: 'major' | 'minor' | 'revision' | 'build' }) => { + const escaped = resolvePath(args.projectFilePath, workspaceDir); + const script = scriptManager.prepareScriptWithHelpers( + 'bump_project_version', + { + PROJECT_FILE_PATH: escaped, + LEVEL: args.level, + }, + ['ensure_project_open'] + ); + const result = await executor.executeScript(script); + return formatToolResponse( + result, + `bump_project_version (${args.level}) complete for ${args.projectFilePath}.` + ); + } + ); + // ─── Filesystem mirror (Phase 1: read-only export) ──────────────────── s.tool(