0
0
Fork 0

feat(git_*): add git_remote_add and git_push wrappers

Two new MCP tools wrapping the remaining pieces needed for an
end-to-end "init -> commit -> push to GitLab" flow:

  git_remote_add   Wraps project.git.remote_add(name, url). Required:
                   remoteName + remoteUrl. Conventionally name='origin'
                   for the primary upstream.

  git_push         Wraps project.git.push(...). Three overloads handled:
                   - push() when no branch + no creds (relies on tracked
                     upstream + git config / Windows Credential Manager)
                   - push(branch) when only branch is provided
                   - push(branch, user, SecureString(token)) when both
                     credentials are provided; derives current branch via
                     branch_show_current() if branchName is omitted.

Credentials handling: when a token is supplied it is converted to
System.Security.SecureString before being handed to push(), per the
CODESYS Git scripting docs guidance ("Use SecureString passwords
whenever possible"). Tool description carries an honest security note
that the token is briefly templated into the IronPython source that the
watcher executes -- prefer cached credentials when feasible. server.ts
neutralises backslashes/double-quotes and rejects newlines in the
templated values up front.

Both tools share the same defensive checks as the existing git_* trio:
- hasattr(script_engine, 'git') / project.git is None handling so the
  user gets a "run git_init first" hint when the project is unbound.
- attribute probe + dir() dump if the API surface is missing the
  expected method.
- HasGitLicense detection in the catch-all that rewrites SCRIPT_ERROR
  into the clear "PDE subscription required" message.

API contract verified against:
- Stubs/scriptengine/GitScriptProject.pyi (local SP22 install) -- the
  remote_add and push overload set.
- helpme-codesys.com Git scripting page
  (https://content.helpme-codesys.com/en/CODESYS%20Git/_git_using_scripting.html)
  -- SecureString recommendation for password params.

Out of scope: pull, fetch, branch_set_upstream_to, push_delete,
clone (top-level script_engine.git, not project-bound). Can land
separately.
This commit is contained in:
Karstein Phobic Nyvold Kvistad 2026-04-25 20:24:15 +02:00
parent 31e842929e
commit 8a6059b9a0
3 changed files with 207 additions and 0 deletions

94
src/scripts/git_push.py Normal file
View file

@ -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', '<username>', <SecureString token>)" % 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)

View file

@ -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)

View file

@ -1115,6 +1115,65 @@ export async function startMcpServer(config: ServerConfig): Promise<void> {
}
);
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/<user>/<repo>.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(