diff --git a/src/scripts/git_push.py b/src/scripts/git_push.py new file mode 100644 index 0000000..36ffdad --- /dev/null +++ b/src/scripts/git_push.py @@ -0,0 +1,94 @@ +import sys, scriptengine as script_engine, traceback + +# Empty strings mean "not provided" -- the script picks the lightest +# overload that matches what's available, falling back to git config / +# Windows Credential Manager / cached creds when no explicit auth is given. +BRANCH_NAME = "{BRANCH_NAME}" +USERNAME = "{USERNAME}" +TOKEN = "{TOKEN}" + +try: + print("DEBUG: git_push: Project='%s', Branch='%s', UsernameProvided=%s, TokenProvided=%s" % ( + PROJECT_FILE_PATH, BRANCH_NAME, bool(USERNAME), bool(TOKEN))) + + primary_project = ensure_project_open(PROJECT_FILE_PATH) + + git = getattr(primary_project, 'git', None) + if git is None: + raise RuntimeError( + "Project '%s' is not bound to a Git repository (primary_project.git " + "is None). Run git_init first." % PROJECT_FILE_PATH) + + if not hasattr(git, 'push'): + attrs = sorted([a for a in dir(git) if not a.startswith('_')]) + raise AttributeError( + "project.git does not expose push(). Available: %s" % attrs) + + use_credentials = bool(USERNAME) and bool(TOKEN) + + if use_credentials: + # CODESYS Git docs recommend SecureString for password params on push/ + # fetch/pull/clone. Convert here so the plain-text TOKEN doesn't sit in + # IronPython memory longer than the call needs it. + from System.Security import SecureString + sec_token = SecureString() + for c in TOKEN: + sec_token.AppendChar(c) + + # The 3-arg push(branchName, username, password) overload requires a + # branch name. If caller didn't pass one, derive the current branch. + if not BRANCH_NAME: + if hasattr(git, 'branch_show_current'): + try: + BRANCH_NAME = str(git.branch_show_current()) + print("DEBUG: derived current branch -> '%s'" % BRANCH_NAME) + except Exception as e: + raise RuntimeError( + "git_push: credentials supplied but no branchName " + "given, and could not derive the current branch: %s" % e) + + print("DEBUG: calling git.push('%s', '', )" % BRANCH_NAME) + git.push(BRANCH_NAME, USERNAME, sec_token) + else: + if BRANCH_NAME: + print("DEBUG: calling git.push('%s')" % BRANCH_NAME) + git.push(BRANCH_NAME) + else: + print("DEBUG: calling git.push() with no args -- relies on tracked upstream + cached creds") + git.push() + + print("DEBUG: push returned without exception.") + + branch = "?" + if hasattr(git, 'branch_show_current'): + try: + branch = str(git.branch_show_current()) + except Exception: + pass + + if BRANCH_NAME: + print("Pushed branch: %s" % BRANCH_NAME) + else: + print("Pushed (current branch: %s)" % branch) + print("SCRIPT_SUCCESS: git_push complete.") + sys.exit(0) +except Exception as e: + detailed = traceback.format_exc() + raw = "%s" % e + if 'HasGitLicense' in raw or 'HasGitLicense' in detailed: + msg = ( + "CODESYS Git scripting requires an active CODESYS Professional " + "Developer Edition subscription license. The plug-in is installed " + "but the runtime 'HasGitLicense' rule returned False, so every " + "project.git.* operation (init/commit/status/...) is gated. " + "Activate a Professional Developer Edition subscription on this " + "CODESYS install (see https://store.codesys.com/en/codesys-git.html, " + "sections 'Additional Requirements' and 'Licensing'). Underlying " + "error from CODESYS: %s" % e + ) + else: + msg = "Error in git_push 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/scripts/git_remote_add.py b/src/scripts/git_remote_add.py new file mode 100644 index 0000000..f4b6031 --- /dev/null +++ b/src/scripts/git_remote_add.py @@ -0,0 +1,54 @@ +import sys, scriptengine as script_engine, traceback + +REMOTE_NAME = "{REMOTE_NAME}" +REMOTE_URL = "{REMOTE_URL}" + +try: + print("DEBUG: git_remote_add: Project='%s', Name='%s', URL='%s'" % ( + PROJECT_FILE_PATH, REMOTE_NAME, REMOTE_URL)) + + if not REMOTE_NAME.strip(): + raise ValueError("Remote name is empty.") + if not REMOTE_URL.strip(): + raise ValueError("Remote URL is empty.") + + primary_project = ensure_project_open(PROJECT_FILE_PATH) + + git = getattr(primary_project, 'git', None) + if git is None: + raise RuntimeError( + "Project '%s' is not bound to a Git repository (primary_project.git " + "is None). Run git_init first." % PROJECT_FILE_PATH) + + if not hasattr(git, 'remote_add'): + attrs = sorted([a for a in dir(git) if not a.startswith('_')]) + raise AttributeError( + "project.git does not expose remote_add(). Available: %s" % attrs) + + print("DEBUG: calling git.remote_add('%s', '%s')" % (REMOTE_NAME, REMOTE_URL)) + git.remote_add(REMOTE_NAME, REMOTE_URL) + print("DEBUG: remote_add returned without exception.") + + print("Added remote '%s' -> %s" % (REMOTE_NAME, REMOTE_URL)) + print("SCRIPT_SUCCESS: git_remote_add complete.") + sys.exit(0) +except Exception as e: + detailed = traceback.format_exc() + raw = "%s" % e + if 'HasGitLicense' in raw or 'HasGitLicense' in detailed: + msg = ( + "CODESYS Git scripting requires an active CODESYS Professional " + "Developer Edition subscription license. The plug-in is installed " + "but the runtime 'HasGitLicense' rule returned False, so every " + "project.git.* operation (init/commit/status/...) is gated. " + "Activate a Professional Developer Edition subscription on this " + "CODESYS install (see https://store.codesys.com/en/codesys-git.html, " + "sections 'Additional Requirements' and 'Licensing'). Underlying " + "error from CODESYS: %s" % e + ) + else: + msg = "Error in git_remote_add 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 708f9df..70f7d04 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1115,6 +1115,65 @@ export async function startMcpServer(config: ServerConfig): Promise { } ); + s.tool( + 'git_remote_add', + "Adds a named git remote to the project's repository via project.git.remote_add(name, url). One-shot. Pair with git_push afterwards. Requires the project to already be bound to a git repo (run git_init first if needed) AND an active CODESYS Professional Developer Edition subscription license -- without the subscription, the tool fails fast with a clear PDE-required message (the runtime 'HasGitLicense' rule gates every project.git.* call).", + { + projectFilePath: z.string().describe("Path to the project file."), + remoteName: z.string().min(1).describe("Remote name. Conventionally 'origin' for the primary upstream."), + remoteUrl: z.string().min(1).describe("Remote URL. HTTPS recommended (e.g. https://gitlab.usv.no//.git); SSH also accepted if the CODESYS process has key access."), + }, + async (args: { projectFilePath: string; remoteName: string; remoteUrl: string }) => { + const escaped = resolvePath(args.projectFilePath, workspaceDir); + const script = scriptManager.prepareScriptWithHelpers( + 'git_remote_add', + { + PROJECT_FILE_PATH: escaped, + REMOTE_NAME: args.remoteName, + REMOTE_URL: args.remoteUrl, + }, + ['ensure_project_open'] + ); + const result = await executor.executeScript(script); + return formatToolResponse(result, `git_remote_add complete for ${args.projectFilePath}.`); + } + ); + + s.tool( + 'git_push', + "Pushes the local branch to a configured remote via project.git.push(). If username + token are both provided, uses the 3-arg overload push(branch, user, SecureString(token)) and derives the current branch when branchName is omitted; otherwise calls push(branch) or push() and relies on git config / Windows Credential Manager / cached credentials. SECURITY NOTE: when token is supplied, it is templated into the IronPython script that the watcher executes -- briefly resident in the watcher's command file on disk. Prefer cached credentials (omit token) when feasible. Requires an existing git binding on the project, a configured remote (use git_remote_add), and an active CODESYS Professional Developer Edition subscription license -- without the subscription, the tool fails fast with a clear PDE-required message.", + { + projectFilePath: z.string().describe("Path to the project file."), + branchName: z.string().optional().describe("Branch to push. Optional; derived from the current branch when token is supplied, or left to push()'s default upstream resolution otherwise."), + username: z.string().optional().describe("Optional HTTPS username. For GitLab personal access tokens any non-empty username works (commonly 'oauth2' or the GitLab username). Pair with token."), + token: z.string().optional().describe("Optional HTTPS password / personal access token. Sensitive -- prefer cached credentials when possible. If supplied, converted to System.Security.SecureString before being handed to project.git.push."), + }, + async (args: { projectFilePath: string; branchName?: string; username?: string; token?: string }) => { + const escaped = resolvePath(args.projectFilePath, workspaceDir); + // The script wraps these values inside double-quoted Python strings, + // so we need to neutralise backslashes and double-quotes. Newlines + // would also break the string -- reject them up front rather than + // attempting to escape, since legitimate auth values never contain them. + const sanitiseForPyDouble = (s: string | undefined, label: string): string => { + const v = s || ''; + if (/\r|\n/.test(v)) throw new Error(`${label} must not contain newlines`); + return v.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + }; + const script = scriptManager.prepareScriptWithHelpers( + 'git_push', + { + PROJECT_FILE_PATH: escaped, + BRANCH_NAME: sanitiseForPyDouble(args.branchName, 'branchName'), + USERNAME: sanitiseForPyDouble(args.username, 'username'), + TOKEN: sanitiseForPyDouble(args.token, 'token'), + }, + ['ensure_project_open'] + ); + const result = await executor.executeScript(script); + return formatToolResponse(result, `git_push complete for ${args.projectFilePath}.`); + } + ); + // ─── Library Management Tools ───────────────────────────────────────── s.tool(