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:
parent
31e842929e
commit
8a6059b9a0
3 changed files with 207 additions and 0 deletions
94
src/scripts/git_push.py
Normal file
94
src/scripts/git_push.py
Normal 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)
|
||||
54
src/scripts/git_remote_add.py
Normal file
54
src/scripts/git_remote_add.py
Normal 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)
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue