@mokoconsulting/mokogit-mcp 1.13.3 → 1.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.mokogit/workflows/auto-release.yml +4 -3
- package/CHANGELOG.md +4 -1
- package/CODE_OF_CONDUCT.md +1 -1
- package/CONTRIBUTING.md +1 -1
- package/Makefile +1 -1
- package/config.example.json +4 -2
- package/dist/index.js +85 -7
- package/package.json +1 -1
- package/source/config.ts +129 -6
- package/source/index.ts +93 -7
- package/source/mokocli.ts +328 -0
- package/source/tokenReader.ts +231 -0
- package/source/types.ts +6 -0
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
# INGROUP: MokoCLI.Release
|
|
8
8
|
# REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Generic
|
|
9
9
|
# PATH: /.mokogit/workflows/auto-release.yml
|
|
10
|
-
# VERSION: 07.
|
|
11
|
-
# BRIEF: NEW (07.23.00): "Vendor PHP dependencies (build-time)" added to BOTH publish legs (promote-rc + stable) — this workflow had NO build-time composer vendoring at all, so any component declaring runtime PHP deps via a sub-package composer.json (vendor-dir admin/vendor, e.g. mPDF in MokonyxCRM/MokonyxCRMDCM) shipped its rc/stable zip WITHOUT admin/vendor while the component manifest lists <folder>vendor</folder> — JInstaller then aborts the ENTIRE package install on-site ("File does not exist [ROOT][TMP]/.../admin/vendor" → "Failed to copy administrator files"). The step mirrors pre-release.yml 07.15.00: no-op without a sub-package composer.json; provisions ext-gd when missing (the release runners' php-cli lacks it, which failed mpdf dependency RESOLUTION outright); verifies the CONFIGURED vendor-dir (composer config vendor-dir) materialized autoload.php; FAILS the build loud when vendoring fails for a package whose manifest ships <folder>vendor</folder> (a green build with an install-bricking artifact is strictly worse than a red build); fail-soft warning for packages whose manifests do not reference vendor/. Runs before `moko publish` (release_publish.php → release_package.php zips the plain working tree; vendor/ was never excluded — the tree simply never had it in CI). PREVIOUS (07.22.00): #498 — the #469 SRC_DIR gate no longer self-traps, and the fast-merge guard is MINOR-identity aware (fixes stable version never advancing; MokoCLI stuck re-publishing v10.20.00). (1) The gate read SRC_DIR with grep|sed, capturing the LITERAL unexpanded `$(shell …)` from the fleet-standard `SRC_DIR := $(shell [ -d src ] && echo src || echo source)` — an unmatchable directory that forced should_bump=false on EVERY push even when source changed. It now resolves SRC_DIR via `make --eval` expansion, falls back to the grep|sed read only if make is unavailable, nulls out a still-unexpanded `$(...)`/backtick value, and treats a declared-but-NONEXISTENT SRC_DIR (repos with code in cli/lib/bin, not src/source) as the non-source denylist fallback instead of an unmatchable INCLUSION prefix. (2) The "Guarantee monotonic release version (fast-merge bump guard)" compared the FULL version, so dev's per-merge PATCH bumps (10.20.00→10.20.04) fooled it into "normal path, no heal" and it re-published the already-released vMAJOR.MINOR.00. It now compares the MINOR identity (MAJOR.MINOR), so a fast dev→stable merge with no promote-rc cuts a new MINOR. Fleet-wide release-semantics change — validate on one repo before syncing. PREVIOUS (07.21.00): #469 — gate the version BUMP on whether the push touched the Makefile SRC_DIR. The former non-code paths-ignore denylist on the push trigger is REMOVED: a docs/workflow/sample push no longer skips the pipeline, it now RUNS in no-bump "update the existing stable release in place" mode (re-stamps the UNCHANGED current version via the existing `moko publish --bump none --no-commit`, refreshes notes/assets, no version advance, no new vNN tag). A new early step "Resolve SRC_DIR bump gate (#469)" (id: srcgate) decides should_bump: (1) ELECTRON (file-detected) or opt-out vars.RELEASE_SRCDIR_GATE=off -> BYPASS, always bump as today (electron has NO source dir, so a naive SRC_DIR test would never bump — a release-breaking trap); (2) else SRC_DIR is REQUIRED — a non-electron repo with no resolvable SRC_DIR FAILS LOUD (::error:: + exit 1), never silently never-bump; (3) else diff the push range (github.event.before..GITHUB_SHA, with a HEAD^1 / merge-base fallback) intersected with ${SRC_DIR}/ — source hit -> should_bump=true, none -> should_bump=false. workflow_dispatch always bumps. The self-heal monotonic bump + the new vNN semver tag are now gated on should_bump!='false' so no-bump mode keeps the version PUT; the publish + "Update release notes and reconcile changelog" (channel-pointer resolution) run on BOTH paths to refresh the current release. Loop-safety is unchanged: the job if: still re-checks [skip ci]/[skip sync], so the workflow's own commits never re-trigger. Fleet-wide release-semantics change — validate on one repo before syncing. PREVIOUS (07.20.00): the dev/promote bump + release steps now name the per-run token env var GITHUB_TOKEN (was the misleadingly-named MOKOGIT_TOKEN; the VALUE was always ${{ github.token }}). All token use in this workflow is SAME-REPO (release API, the release_bump_dev dev base bump, pushes to its own repo) — the org MOKOGIT_TOKEN secret is not referenced here. Naming-only; no behavior change. Mirrors the pre-release.yml fix (MokoGIT#1721). PREVIOUS: NEW (07.19.00): #1621 Phase 6 / .mokogit-private#76 — resolve the CURRENT stable release by its CHANNEL POINTER, not by a fixed tag_name=stable. Under version-primary releases (a release is born with the immutable tag_name=vNN and `stable`/`rc` attached as movable is_channel tags), GET /releases/tags/stable no longer resolves the current stable release, which would break two steps that read "the last released version". Both now call GET /repos/{owner}/{repo}/releases/channel/stable (#1702 — resolves whichever release the `stable` channel points at via ReleaseStreamMap) with a GRACEFUL FALLBACK to the legacy GET /releases/tags/stable for forges predating #1702 (the channel route 404s pre-rollout and the rolling `stable` tag still exists). (1) The monotonic-version guard ("Guarantee monotonic release version") resolves ROLLING_LAST by parsing the same `VERSION: NN.NN.NN` out of the channel-resolved release NAME. (2) "Update release notes and reconcile changelog" resolves the stable RELEASE_ID + VERSION the same channel-first way. The #386 integrity gate is UNCHANGED (it already keys off the immutable vNN version tag). SEQUENCING unchanged: do NOT enable in prod until #1702 server support is on stable/prod; works pre-#1702 via the fallback. PREVIOUS (07.18.00): #1621 Phase 6 — version/* BRANCHES retired in favor of dual-tagged version RELEASES. REMOVED three now-redundant steps: "Step 12: Create version branch from main", "Step 13: Prune superseded-major version branches", and "Advance rolling channel tag via git". A release's permanent version identity is now the git tag v<version>, attached by the publish as a MOVABLE channel tag on the dual-tagged release (MokoCLI release_publish -> release_create channel_tags[]/update_stream; the forge attaches/moves the tag at the release commit via AttachChannelTag server-side) — so a new cut is born dual-tagged (rolling `stable`/`release-candidate` primary tag + permanent v<version> channel tag) and the channel tag MOVES onto the new release instead of a new version/* branch being created. The rolling `stable` tag is still pinned by release_create forceTagRef() (#808), so the "Advance rolling channel tag" force-push was redundant. KEPT UNCHANGED: "Create semver tag" (v<version>), the #386 integrity gate (still resolves tags/stable to the built commit), and the monotonic guard (still reads the stable release name VERSION: NN.NN.NN). Pruning EXISTING version/* branches is a SEPARATE one-time manual reconcile — not done here. SEQUENCING: do NOT enable in prod until the #1621 SERVER channel_tags support is on stable/prod. PREVIOUS (07.17.00): Setup MokoCLI tools no longer installs a STALE mokocli during the registry publish-lag window (#1671). After the fresh registry install, the step now compares the installed /tmp/mokocli PROJECT_VERSION against the /opt/mokocli failsafe and PREFERS whichever is NEWER (sort -V), and the opportunistic /opt refresh is gated to only move FORWARD (never downgrade /opt when it is the newer, just-deployed copy). Backward-compatible: runners with no /opt/mokocli use the registry copy exactly as before. PREVIOUS (07.16.00): release-integrity + dev-channel fixes. (1) A new pre-publish "no stale (pre-merge) tree" guard (#428/MokoCLI#901) hard-FAILS the release if the triggering GITHUB_SHA is an ANCESTOR of the current branch tip (a concurrent merge landed mid-run) -- otherwise a green, version-bumped release silently ships old code missing that merge (reproduced: built ef8c1a5d while tip was 2740342). Fail-open on any unresolved/replica-lagged tip so a good release is never falsely blocked. Complements the mokocli engine assertion (packaged-tree == GITHUB_SHA), which cannot see that GITHUB_SHA itself trails the tip. (2) The "Post-release: Reset dev version" safety-net (#309/MokoCLI#900) no longer swallows failure: a failed dev-advance now emits ::error:: + a job-summary block (dev-channel would trail stable), and a SUCCESSFUL advance dispatches pre-release.yml (development) to actually publish the leading dev build -- the advance commit is [skip ci]+paths-ignored and never auto-fires. Stays green (continue-on-error) since stable already published. NEW (07.15.00): the rolling stable channel tag is now advanced via a git force-push BEFORE the #386 integrity gate. MokoGIT's tags API 405s on the release-attached `stable` tag, so release_publish.php could not move it ("Could not pin tag stable ... HTTP 405") and every Joomla/electron stable cut left the tag lagging the freshly-built commit -- red-failing a correct release on the integrity gate. A new step git-force-pushes refs/tags/stable to the built HEAD (force-push allowlist -- the same escape hatch the rc BRANCH pin already uses), so the gate passes and no manual tag repoint is needed. PREVIOUS (07.14.00): integrity gate #386 no longer false-fails. It compared the published tag against GITHUB_SHA (the triggering commit), but the publish step commits the CHANGELOG entry ("build NN.NN.NN [skip ci]") and mokocli pins the channel tag + semver tag to that POST-PUBLISH HEAD -- so every cut whose CHANGELOG wasn't pre-seeded on dev (e.g. a version set straight on the release branch) diverged by one commit and red-failed a correct release. The gate now compares against the actual built/published HEAD (git rev-parse HEAD, falling back to GITHUB_SHA), which equals what the tag is pinned to in both the commit and no-commit cases while still catching a tag pinned to a commit we did NOT build; the semver tag is pinned to the same built HEAD for consistency. PREVIOUS (07.13.00): RC is ON-DEMAND ONLY. The `pull_request` trigger (RC-preview on a PR to main/stable) is REMOVED and the promote-rc job now runs solely on workflow_dispatch action=promote-rc. Only dev + stable release automatically (dev via pre-release.yml push->dev; stable via the push->main release job here); alpha/beta/rc never auto-cut. PREVIOUS (07.12.00): electron repos create NO release on ANY channel (RC enabled=false + stable publish/tag/notes/integrity guarded on is_electron; opt back in with vars.ELECTRON_CI_BUILD=true) and build NO installer in CI — always built locally + uploaded manually; the dev/promote version BUMP still runs. Template-Generic #390. NEW (07.11.00): the electron installer build/attach steps (electron-builder via Wine/Xvfb) are gated to MANUAL workflow_dispatch only (`&& github.event_name == 'workflow_dispatch'`). An automatic push no longer auto-builds electron installers (heavy ~90m, runner-fragile — OOM/Wine-zombie; migrating to mokocli ElectronPackager, MokoCLI #756/#758). The version bump, CHANGELOG, tag and npm publish STILL run on push — only the electron installer is skipped and pushed MANUALLY via dispatch. Non-electron platforms (joomla/go/npm) unaffected. Template-Generic #390. Universal build & release - BUMP-ON-DEV: base version + CHANGELOG promoted on dev pre-promotion (promote-rc), main publishes --bump none --no-commit so it never diverges from dev. MokoCLI build agent (moko publish). NEW (07.06.00): npm stable publishing ABSORBED here from Template-NPM's retired npm-publish.yml as npm-gated dual-registry steps (MokoGIT registry via per-run token + npmjs.org best-effort), replacing the former npm-publish.yml dispatch. No-Makefile repos SKIP the release job gracefully (green) instead of failing. Conflict-marker scan reads git-tracked files only (git ls-files | xargs grep) so vendored/generated trees can't false-match. NEW (07.07.00): npm stamp normalizes the zero-padded Makefile PROJECT_VERSION (NN.NN.NN) to strict semver via awk before `npm version`, and an unstampable version now HARD-FAILS instead of silently keeping the old package.json version (#382). NEW (07.00.00): `moko assets:optimize` artifact-time step added before publish (graceful-skip on non-asset repos); Setup MokoCLI tools makes the fresh `moko` executable + first on PATH to fix exit-126 (mokocli #650).
|
|
10
|
+
# VERSION: 07.24.00
|
|
11
|
+
# BRIEF: NEW (07.24.00): npmjs.org publish now FAILS LOUD (::error:: + exit $rc) on an AUTHORIZATION error (404 "not in this registry" / 401 / 403 / ENEEDAUTH / EOTP) instead of swallowing EVERY failure as a non-failing ::warning:: — a missing-scope / unauthorized / revoked / expired NPM_TOKEN previously hid behind a green release fleet-wide (the "secondary registry, not failing the run" catch masked a 404 PUT, which is exactly how npm reports an unauthorized scoped write; CI had in fact NEVER successfully published any MCP package to npmjs while every run reported success). Transient/5xx/network failures still stay a non-failing ::warning:: (npmjs is the SECONDARY mirror; the MokoGIT registry is primary and already hard-fails), and an unset NPM_TOKEN still skips cleanly — so repos that do not publish to npmjs are 100% unaffected. PREVIOUS (07.23.00): "Vendor PHP dependencies (build-time)" added to BOTH publish legs (promote-rc + stable) — this workflow had NO build-time composer vendoring at all, so any component declaring runtime PHP deps via a sub-package composer.json (vendor-dir admin/vendor, e.g. mPDF in MokonyxCRM/MokonyxCRMDCM) shipped its rc/stable zip WITHOUT admin/vendor while the component manifest lists <folder>vendor</folder> — JInstaller then aborts the ENTIRE package install on-site ("File does not exist [ROOT][TMP]/.../admin/vendor" → "Failed to copy administrator files"). The step mirrors pre-release.yml 07.15.00: no-op without a sub-package composer.json; provisions ext-gd when missing (the release runners' php-cli lacks it, which failed mpdf dependency RESOLUTION outright); verifies the CONFIGURED vendor-dir (composer config vendor-dir) materialized autoload.php; FAILS the build loud when vendoring fails for a package whose manifest ships <folder>vendor</folder> (a green build with an install-bricking artifact is strictly worse than a red build); fail-soft warning for packages whose manifests do not reference vendor/. Runs before `moko publish` (release_publish.php → release_package.php zips the plain working tree; vendor/ was never excluded — the tree simply never had it in CI). PREVIOUS (07.22.00): #498 — the #469 SRC_DIR gate no longer self-traps, and the fast-merge guard is MINOR-identity aware (fixes stable version never advancing; MokoCLI stuck re-publishing v10.20.00). (1) The gate read SRC_DIR with grep|sed, capturing the LITERAL unexpanded `$(shell …)` from the fleet-standard `SRC_DIR := $(shell [ -d src ] && echo src || echo source)` — an unmatchable directory that forced should_bump=false on EVERY push even when source changed. It now resolves SRC_DIR via `make --eval` expansion, falls back to the grep|sed read only if make is unavailable, nulls out a still-unexpanded `$(...)`/backtick value, and treats a declared-but-NONEXISTENT SRC_DIR (repos with code in cli/lib/bin, not src/source) as the non-source denylist fallback instead of an unmatchable INCLUSION prefix. (2) The "Guarantee monotonic release version (fast-merge bump guard)" compared the FULL version, so dev's per-merge PATCH bumps (10.20.00→10.20.04) fooled it into "normal path, no heal" and it re-published the already-released vMAJOR.MINOR.00. It now compares the MINOR identity (MAJOR.MINOR), so a fast dev→stable merge with no promote-rc cuts a new MINOR. Fleet-wide release-semantics change — validate on one repo before syncing. PREVIOUS (07.21.00): #469 — gate the version BUMP on whether the push touched the Makefile SRC_DIR. The former non-code paths-ignore denylist on the push trigger is REMOVED: a docs/workflow/sample push no longer skips the pipeline, it now RUNS in no-bump "update the existing stable release in place" mode (re-stamps the UNCHANGED current version via the existing `moko publish --bump none --no-commit`, refreshes notes/assets, no version advance, no new vNN tag). A new early step "Resolve SRC_DIR bump gate (#469)" (id: srcgate) decides should_bump: (1) ELECTRON (file-detected) or opt-out vars.RELEASE_SRCDIR_GATE=off -> BYPASS, always bump as today (electron has NO source dir, so a naive SRC_DIR test would never bump — a release-breaking trap); (2) else SRC_DIR is REQUIRED — a non-electron repo with no resolvable SRC_DIR FAILS LOUD (::error:: + exit 1), never silently never-bump; (3) else diff the push range (github.event.before..GITHUB_SHA, with a HEAD^1 / merge-base fallback) intersected with ${SRC_DIR}/ — source hit -> should_bump=true, none -> should_bump=false. workflow_dispatch always bumps. The self-heal monotonic bump + the new vNN semver tag are now gated on should_bump!='false' so no-bump mode keeps the version PUT; the publish + "Update release notes and reconcile changelog" (channel-pointer resolution) run on BOTH paths to refresh the current release. Loop-safety is unchanged: the job if: still re-checks [skip ci]/[skip sync], so the workflow's own commits never re-trigger. Fleet-wide release-semantics change — validate on one repo before syncing. PREVIOUS (07.20.00): the dev/promote bump + release steps now name the per-run token env var GITHUB_TOKEN (was the misleadingly-named MOKOGIT_TOKEN; the VALUE was always ${{ github.token }}). All token use in this workflow is SAME-REPO (release API, the release_bump_dev dev base bump, pushes to its own repo) — the org MOKOGIT_TOKEN secret is not referenced here. Naming-only; no behavior change. Mirrors the pre-release.yml fix (MokoGIT#1721). PREVIOUS: NEW (07.19.00): #1621 Phase 6 / .mokogit-private#76 — resolve the CURRENT stable release by its CHANNEL POINTER, not by a fixed tag_name=stable. Under version-primary releases (a release is born with the immutable tag_name=vNN and `stable`/`rc` attached as movable is_channel tags), GET /releases/tags/stable no longer resolves the current stable release, which would break two steps that read "the last released version". Both now call GET /repos/{owner}/{repo}/releases/channel/stable (#1702 — resolves whichever release the `stable` channel points at via ReleaseStreamMap) with a GRACEFUL FALLBACK to the legacy GET /releases/tags/stable for forges predating #1702 (the channel route 404s pre-rollout and the rolling `stable` tag still exists). (1) The monotonic-version guard ("Guarantee monotonic release version") resolves ROLLING_LAST by parsing the same `VERSION: NN.NN.NN` out of the channel-resolved release NAME. (2) "Update release notes and reconcile changelog" resolves the stable RELEASE_ID + VERSION the same channel-first way. The #386 integrity gate is UNCHANGED (it already keys off the immutable vNN version tag). SEQUENCING unchanged: do NOT enable in prod until #1702 server support is on stable/prod; works pre-#1702 via the fallback. PREVIOUS (07.18.00): #1621 Phase 6 — version/* BRANCHES retired in favor of dual-tagged version RELEASES. REMOVED three now-redundant steps: "Step 12: Create version branch from main", "Step 13: Prune superseded-major version branches", and "Advance rolling channel tag via git". A release's permanent version identity is now the git tag v<version>, attached by the publish as a MOVABLE channel tag on the dual-tagged release (MokoCLI release_publish -> release_create channel_tags[]/update_stream; the forge attaches/moves the tag at the release commit via AttachChannelTag server-side) — so a new cut is born dual-tagged (rolling `stable`/`release-candidate` primary tag + permanent v<version> channel tag) and the channel tag MOVES onto the new release instead of a new version/* branch being created. The rolling `stable` tag is still pinned by release_create forceTagRef() (#808), so the "Advance rolling channel tag" force-push was redundant. KEPT UNCHANGED: "Create semver tag" (v<version>), the #386 integrity gate (still resolves tags/stable to the built commit), and the monotonic guard (still reads the stable release name VERSION: NN.NN.NN). Pruning EXISTING version/* branches is a SEPARATE one-time manual reconcile — not done here. SEQUENCING: do NOT enable in prod until the #1621 SERVER channel_tags support is on stable/prod. PREVIOUS (07.17.00): Setup MokoCLI tools no longer installs a STALE mokocli during the registry publish-lag window (#1671). After the fresh registry install, the step now compares the installed /tmp/mokocli PROJECT_VERSION against the /opt/mokocli failsafe and PREFERS whichever is NEWER (sort -V), and the opportunistic /opt refresh is gated to only move FORWARD (never downgrade /opt when it is the newer, just-deployed copy). Backward-compatible: runners with no /opt/mokocli use the registry copy exactly as before. PREVIOUS (07.16.00): release-integrity + dev-channel fixes. (1) A new pre-publish "no stale (pre-merge) tree" guard (#428/MokoCLI#901) hard-FAILS the release if the triggering GITHUB_SHA is an ANCESTOR of the current branch tip (a concurrent merge landed mid-run) -- otherwise a green, version-bumped release silently ships old code missing that merge (reproduced: built ef8c1a5d while tip was 2740342). Fail-open on any unresolved/replica-lagged tip so a good release is never falsely blocked. Complements the mokocli engine assertion (packaged-tree == GITHUB_SHA), which cannot see that GITHUB_SHA itself trails the tip. (2) The "Post-release: Reset dev version" safety-net (#309/MokoCLI#900) no longer swallows failure: a failed dev-advance now emits ::error:: + a job-summary block (dev-channel would trail stable), and a SUCCESSFUL advance dispatches pre-release.yml (development) to actually publish the leading dev build -- the advance commit is [skip ci]+paths-ignored and never auto-fires. Stays green (continue-on-error) since stable already published. NEW (07.15.00): the rolling stable channel tag is now advanced via a git force-push BEFORE the #386 integrity gate. MokoGIT's tags API 405s on the release-attached `stable` tag, so release_publish.php could not move it ("Could not pin tag stable ... HTTP 405") and every Joomla/electron stable cut left the tag lagging the freshly-built commit -- red-failing a correct release on the integrity gate. A new step git-force-pushes refs/tags/stable to the built HEAD (force-push allowlist -- the same escape hatch the rc BRANCH pin already uses), so the gate passes and no manual tag repoint is needed. PREVIOUS (07.14.00): integrity gate #386 no longer false-fails. It compared the published tag against GITHUB_SHA (the triggering commit), but the publish step commits the CHANGELOG entry ("build NN.NN.NN [skip ci]") and mokocli pins the channel tag + semver tag to that POST-PUBLISH HEAD -- so every cut whose CHANGELOG wasn't pre-seeded on dev (e.g. a version set straight on the release branch) diverged by one commit and red-failed a correct release. The gate now compares against the actual built/published HEAD (git rev-parse HEAD, falling back to GITHUB_SHA), which equals what the tag is pinned to in both the commit and no-commit cases while still catching a tag pinned to a commit we did NOT build; the semver tag is pinned to the same built HEAD for consistency. PREVIOUS (07.13.00): RC is ON-DEMAND ONLY. The `pull_request` trigger (RC-preview on a PR to main/stable) is REMOVED and the promote-rc job now runs solely on workflow_dispatch action=promote-rc. Only dev + stable release automatically (dev via pre-release.yml push->dev; stable via the push->main release job here); alpha/beta/rc never auto-cut. PREVIOUS (07.12.00): electron repos create NO release on ANY channel (RC enabled=false + stable publish/tag/notes/integrity guarded on is_electron; opt back in with vars.ELECTRON_CI_BUILD=true) and build NO installer in CI — always built locally + uploaded manually; the dev/promote version BUMP still runs. Template-Generic #390. NEW (07.11.00): the electron installer build/attach steps (electron-builder via Wine/Xvfb) are gated to MANUAL workflow_dispatch only (`&& github.event_name == 'workflow_dispatch'`). An automatic push no longer auto-builds electron installers (heavy ~90m, runner-fragile — OOM/Wine-zombie; migrating to mokocli ElectronPackager, MokoCLI #756/#758). The version bump, CHANGELOG, tag and npm publish STILL run on push — only the electron installer is skipped and pushed MANUALLY via dispatch. Non-electron platforms (joomla/go/npm) unaffected. Template-Generic #390. Universal build & release - BUMP-ON-DEV: base version + CHANGELOG promoted on dev pre-promotion (promote-rc), main publishes --bump none --no-commit so it never diverges from dev. MokoCLI build agent (moko publish). NEW (07.06.00): npm stable publishing ABSORBED here from Template-NPM's retired npm-publish.yml as npm-gated dual-registry steps (MokoGIT registry via per-run token + npmjs.org best-effort), replacing the former npm-publish.yml dispatch. No-Makefile repos SKIP the release job gracefully (green) instead of failing. Conflict-marker scan reads git-tracked files only (git ls-files | xargs grep) so vendored/generated trees can't false-match. NEW (07.07.00): npm stamp normalizes the zero-padded Makefile PROJECT_VERSION (NN.NN.NN) to strict semver via awk before `npm version`, and an unstampable version now HARD-FAILS instead of silently keeping the old package.json version (#382). NEW (07.00.00): `moko assets:optimize` artifact-time step added before publish (graceful-skip on non-asset repos); Setup MokoCLI tools makes the fresh `moko` executable + first on PATH to fix exit-126 (mokocli #650).
|
|
12
12
|
#
|
|
13
13
|
# +=======================================================================+
|
|
14
14
|
# | UNIVERSAL BUILD & RELEASE PIPELINE |
|
|
@@ -1676,7 +1676,8 @@ jobs:
|
|
|
1676
1676
|
rm -f .npmrc; echo "$out"
|
|
1677
1677
|
if [ "$rc" -eq 0 ]; then echo "Published to npmjs.org."
|
|
1678
1678
|
elif printf '%s' "$out" | grep -qiE 'already exists|cannot publish over|EPUBLISHCONFLICT|409 Conflict'; then echo "::notice::npmjs.org already has this version - no-op."
|
|
1679
|
-
|
|
1679
|
+
elif printf '%s' "$out" | grep -qiE 'not in this registry|ENEEDAUTH|EOTP|need auth|E401|E403|401 Unauthorized|403 Forbidden|404 Not Found|Forbidden'; then echo "::error::npmjs.org publish FAILED — authorization error (npm exit ${rc}). NPM_TOKEN lacks publish/create rights on the @mokoconsulting scope (new-scoped-name first publish, an unauthorized token, or a revoked/expired token). Failing loud so a broken publish token cannot hide behind a green release — a 404 PUT is how npm masks an unauthorized scoped write."; exit "$rc"
|
|
1680
|
+
else echo "::warning::npmjs.org publish failed (npm exit ${rc}) — transient/5xx/network issue; npmjs is the secondary mirror (MokoGIT registry is primary), not failing the run."; fi
|
|
1680
1681
|
|
|
1681
1682
|
# GitHub mirroring (release assets + main branch) moved to an out-of-band
|
|
1682
1683
|
# OAuth push-mirror (Gitea repo setting) -- former Steps 9 & 10 removed.
|
package/CHANGELOG.md
CHANGED
|
@@ -21,7 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
21
21
|
### Changed
|
|
22
22
|
- **`git_org_sync_profile_apply` now polls to completion by default (#264).** The generated tool fired the `POST .../sync-profiles/{id}/sync` once and returned. The server applies in **batches**, so on a large profile (observed: 79 targets / 209 pending changes) a single call only advanced part way (209→54) and the operator had to manually re-invoke apply and watch `git_org_sync_profile_status` until it hit 0 — and a big batch could exceed the client's 30s timeout, surfacing as a hard failure while the server kept working in the background. A hand-written override (registered ahead of the generated registration, so the existing collision guard skips the generated name) now **apply → status → repeat** until `in_sync`, a stall (pending stops decreasing across 2 rounds), or a 10-round safety cap. An apply-round **timeout is treated as "still running"** — swallowed, then status is polled to observe real progress and the loop continues. Returns a structured result: per-round `pending_trajectory` (e.g. `[209,54,0]`), `rounds`, final `{in_sync, pending_changes, target_count}`, the merged `applied_target_repo_ids`, and a `stop_reason`. New optional **`wait`** param (default `true` = polling); `wait:false` restores the old single-shot apply. Backward compatible — existing callers get the completion behavior automatically.
|
|
23
23
|
|
|
24
|
-
## [1.13.0] --- 2026-10-
|
|
24
|
+
## [1.13.0] --- 2026-10-29
|
|
25
25
|
|
|
26
26
|
### Added
|
|
27
27
|
- **Channel-name tag/branch collision guardrail on ref-resolving read tools (#236).** MokoGIT's release model uses channel-named rolling tags (`stable`, `dev`, `rc`, …) that are force-advanced to each build, so on channel-named-branch repos a git **tag and branch can share a name**. Git's bare-ref precedence resolves the name to the **tag before the branch**, so a tool handed a bare channel name silently returned the tag's content. Twelve ref-taking read tools (`file_get`, `dir_get`, `tree_get`, `commits_list`, `compare`, `ref_statuses_list`, `status_combined_get`, `repo_raw_get`, `repo_media_get`, `contents_ext_get`, `contents_list`, `file_contents_get`) now probe, **in parallel with the real request**, whether the ref name exists as both `refs/heads/<name>` and `refs/tags/<name>` at **different** commits and, if so, prepend a non-fatal `⚠` warning naming both targets, which one git resolves to, and how to qualify explicitly. The guard is **fail-open** (any probe error returns no warning, never breaking the real call), skips already-qualified refs (`refs/…`, `heads/…`, `tags/…`) and SHA-looking inputs, and uses an **exact** `ref` match so Gitea's prefix matching can't false-positive. Verified end-to-end against the live `stable` collision on this repo.
|
|
@@ -35,6 +35,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
35
35
|
|
|
36
36
|
- **Rapid-merge guard on `git_pull_merge` (#288).** Merging several PRs into the same base back-to-back fired one `pre-release.yml` per merge, and those runs raced to bump the base version files via the Contents API — the loser got HTTP 422 "sha does not match" on its `bot/version-advance/<base>-*` temp branch (0 files stamped, red run, duplicate/under-bumped versions; observed laddering 4 MokonyxInsight PRs into `dev`). The root cause is merge concurrency, so the guard sits at the merge layer: a new `source/rapid-merge-guard.ts` module (1) **serializes** merges to the same `(repo, base)` through an in-process async lock/queue so two `pull_merge` calls can't overlap; (2) **settle-waits** — detects an in-flight `pre-release.yml` (or any run writing `bot/version-advance/<base>-*`) on the base and holds until it completes (bounded by `max_wait`) before releasing the next merge, or applies a fixed cooldown; and (3) **reports** the wait + reason in the merge result (e.g. `held 42s for in-flight pre-release run #NNN on dev before merging`). New optional `git_pull_merge` params: `rapid_merge_guard` (`settle` (default) | `cooldown:<sec>` | `off`), `max_wait` (seconds, default 180), and `force` (bypass). Falls back to the `MOKOGIT_RAPID_MERGE_GUARD` / `MOKOGIT_RAPID_MERGE_MAX_WAIT` env vars then the defaults. **Fail-open**: a PR/runs lookup error merges unguarded rather than blocking. Complements #287 (pre-merge CI red gate), which edits the same handler — this prevents merges from *creating* the race; the deeper server-side retry-on-422 fix is tracked under MokoGIT #1721.
|
|
37
37
|
|
|
38
|
+
- **`git_user_token_create` can now scope a token to specific repositories (#286).** The forge side (MokoGIT #1952, prod 02.85.00) extended `CreateAccessTokenOption` with `all_repos` and `repo_ids`, letting a personal access token be restricted to a chosen set of repositories instead of the implicit "all repos incl. future". The tool gains three optional params: **`all_repos`** (boolean — token covers all repos incl. future, forge default `true`), **`repo_ids`** (`number[]` — when `all_repos=false`, restrict to these repository IDs), and **`repos`** (`string[]` — convenience: repo full-names `owner/name` resolved to IDs client-side via `GET /repos/{owner}/{repo}` and merged into `repo_ids`). The forge field names sent on the body are `all_repos` and `repo_ids`. Backward compatible — omitting all three preserves the prior all-repos behavior. `git_user_tokens_list` is a raw passthrough and already surfaces the new response fields.
|
|
39
|
+
- **MCP server version is now read from `package.json` instead of a hardcoded literal.** The `McpServer({ version })` constructor was pinned at `1.5.0`, long stale versus the real package version. It now resolves `package.json`'s `version` at runtime (`createRequire(import.meta.url)`), so the advertised server version tracks the Makefile `PROJECT_VERSION` / `package.json` source of truth automatically.
|
|
40
|
+
|
|
38
41
|
## [1.12.3] --- 2026-09-11
|
|
39
42
|
|
|
40
43
|
### Deprecated
|
package/CODE_OF_CONDUCT.md
CHANGED
package/CONTRIBUTING.md
CHANGED
package/Makefile
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
# Identity (auto-filled by workflow sync — the Makefile is the version source of truth)
|
|
11
11
|
# ==============================================================================
|
|
12
12
|
PROJECT_NAME := mcp-mokogit
|
|
13
|
-
PROJECT_VERSION :=
|
|
13
|
+
PROJECT_VERSION := 01.14.00
|
|
14
14
|
PLATFORM := npm
|
|
15
15
|
|
|
16
16
|
# ==============================================================================
|
package/config.example.json
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
{
|
|
2
|
+
"//": "NO INLINE SECRETS. Point each connection at a token FILE via tokenFile. An inline \"token\" is legacy and is auto-migrated to ~/.ssh/tokens/<host>.token.json on first run (the config is rewritten and a .bak is kept). The token file may hold a raw PAT or a JSON blob {token|access_token, expires_*, scope(s), permissions}; Google service-account/OAuth creds are detected and refused (no token exchange).",
|
|
2
3
|
"defaultConnection": "moko",
|
|
3
4
|
"connections": {
|
|
4
5
|
"moko": {
|
|
5
6
|
"baseUrl": "https://git.mokoconsulting.tech",
|
|
6
|
-
"
|
|
7
|
+
"tokenFile": "~/.ssh/tokens/git.mokoconsulting.tech.jmiller.token"
|
|
7
8
|
},
|
|
8
9
|
"github-mirror": {
|
|
9
10
|
"baseUrl": "https://git.example.com",
|
|
10
|
-
"
|
|
11
|
+
"tokenFile": "~/.ssh/tokens/git.example.com.token",
|
|
12
|
+
"//token": "legacy: an inline \"token\": \"...\" here still works but is auto-migrated to a tokenFile on first run (no-inline-secrets policy)"
|
|
11
13
|
}
|
|
12
14
|
}
|
|
13
15
|
}
|
package/dist/index.js
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import * as https from 'node:https';
|
|
17
17
|
import * as http from 'node:http';
|
|
18
|
+
import { createRequire } from 'node:module';
|
|
18
19
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
19
20
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
20
21
|
import { z } from 'zod';
|
|
@@ -23,6 +24,7 @@ import { GitClient } from './client.js';
|
|
|
23
24
|
import { setGeneratedConfig, setGeneratedServer, setRegisteredNames, } from './generated-runtime.js';
|
|
24
25
|
import { registerGeneratedToolsV2 } from './tools.generated.js';
|
|
25
26
|
import { resolveGuardConfig, runGuardedMerge } from './rapid-merge-guard.js';
|
|
27
|
+
import { probeBackends, warnBackendStatus, runMokoCli, cliRunEnabled, assertSafeArgs } from './mokocli.js';
|
|
26
28
|
import { investigatePremergeCi, evaluatePremergeGate, renderPremergeReport, } from './premerge-ci.js';
|
|
27
29
|
let config;
|
|
28
30
|
function clientFor(connection) {
|
|
@@ -198,9 +200,13 @@ const OwnerRepo = {
|
|
|
198
200
|
owner: z.string().describe('Repository owner (user or org)'),
|
|
199
201
|
repo: z.string().describe('Repository name'),
|
|
200
202
|
};
|
|
203
|
+
// Resolve the real package version from package.json (single source of truth,
|
|
204
|
+
// kept in sync with the Makefile PROJECT_VERSION) rather than a hardcoded string.
|
|
205
|
+
const pkg = createRequire(import.meta.url)('../package.json');
|
|
206
|
+
const SERVER_VERSION = pkg.version ?? '0.0.0';
|
|
201
207
|
const server = new McpServer({
|
|
202
208
|
name: 'mokogit-mcp',
|
|
203
|
-
version:
|
|
209
|
+
version: SERVER_VERSION,
|
|
204
210
|
});
|
|
205
211
|
// ── Tool registration wrapper (Git → MokoGIT brand normalization) ────
|
|
206
212
|
//
|
|
@@ -247,6 +253,44 @@ function registerTool(name, description, paramsSchema, cb) {
|
|
|
247
253
|
}
|
|
248
254
|
// ── User / Auth ─────────────────────────────────────────────────────────
|
|
249
255
|
registerTool('git_me', 'Get the authenticated user info', { ...ConnectionParam }, async ({ connection }) => formatResponse(await clientFor(connection).get('/user')));
|
|
256
|
+
// ── Operation backends: mokogit-cli (MokoCLI) — precedence api > mokogit-cli > git ──
|
|
257
|
+
//
|
|
258
|
+
// The REST API is the PRIMARY path: every other tool here calls it directly, and
|
|
259
|
+
// that is what you should use for anything the API covers. The mokogit-cli binary
|
|
260
|
+
// is a FALLBACK backend for CLI-only operations (or when the API is unavailable);
|
|
261
|
+
// plain git is the last resort. These two tools expose that layer.
|
|
262
|
+
registerTool('cli_status', 'Report the MokoGIT MCP operation backends and their precedence (api > mokogit-cli > git): which are available, the resolved mokogit-cli/git binary paths and versions, and the active backend. The REST API is always primary; mokogit-cli and git are fallbacks. Use this to check whether CLI-assisted operations are available on this host.', {}, async () => {
|
|
263
|
+
const status = await probeBackends();
|
|
264
|
+
return { content: [{ type: 'text', text: JSON.stringify(status, null, 2) }] };
|
|
265
|
+
});
|
|
266
|
+
registerTool('cli_run', 'Run a mokogit-cli (MokoCLI) subcommand as a FALLBACK backend and return its exit code, stdout and stderr. DISABLED by default — an operator must opt in with $MOKOGIT_ENABLE_CLI_RUN=1 (arbitrary CLI execution). Precedence is api > mokogit-cli > git — prefer the REST API tools for anything they cover; use this only for CLI-only operations or when the API is unavailable. Arguments are passed as argv (no shell); option-like args (leading "-") are rejected unless allowlisted or $MOKOGIT_CLI_ALLOW_FLAGS=1; any cwd must be inside $MOKOGIT_CLI_WORKDIR. Refuses with a clear message when disabled or when mokogit-cli is not installed (check cli_status).', {
|
|
267
|
+
args: z.array(z.string()).describe('mokogit-cli arguments as an argv array, e.g. ["version"] or ["repo","list"]. Passed directly to the binary (no shell).'),
|
|
268
|
+
cwd: z.string().optional().describe('Working directory — must resolve inside $MOKOGIT_CLI_WORKDIR (optional).'),
|
|
269
|
+
timeout_seconds: z.number().int().positive().max(600).optional().describe('Max run time in seconds (default 60).'),
|
|
270
|
+
input: z.string().optional().describe('Optional data to write to the CLI’s stdin.'),
|
|
271
|
+
}, async ({ args, cwd, timeout_seconds, input }) => {
|
|
272
|
+
if (!cliRunEnabled()) {
|
|
273
|
+
return { content: [{ type: 'text', text: 'cli_run is disabled. Arbitrary mokogit-cli execution is an operator opt-in — set $MOKOGIT_ENABLE_CLI_RUN=1 to enable it. The REST API tools are the primary path (precedence api > mokogit-cli > git); use cli_status to see backend availability.' }] };
|
|
274
|
+
}
|
|
275
|
+
try {
|
|
276
|
+
assertSafeArgs(args);
|
|
277
|
+
const r = await runMokoCli(args, {
|
|
278
|
+
cwd,
|
|
279
|
+
timeoutMs: timeout_seconds ? timeout_seconds * 1000 : undefined,
|
|
280
|
+
input,
|
|
281
|
+
});
|
|
282
|
+
const text = [
|
|
283
|
+
`$ ${r.command}`,
|
|
284
|
+
`exit: ${r.code}${r.ok ? '' : ' (non-zero)'}`,
|
|
285
|
+
r.stdout ? `\n--- stdout ---\n${r.stdout.trimEnd()}` : '',
|
|
286
|
+
r.stderr ? `\n--- stderr ---\n${r.stderr.trimEnd()}` : '',
|
|
287
|
+
].filter(Boolean).join('\n');
|
|
288
|
+
return { content: [{ type: 'text', text }] };
|
|
289
|
+
}
|
|
290
|
+
catch (err) {
|
|
291
|
+
return { content: [{ type: 'text', text: err instanceof Error ? err.message : String(err) }] };
|
|
292
|
+
}
|
|
293
|
+
});
|
|
250
294
|
registerTool('git_user_orgs', 'List organizations the authenticated user belongs to', { ...PaginationParams, ...ConnectionParam }, async ({ page, limit, connection }) => formatResponse(await clientFor(connection).get('/user/orgs', pageQuery({ page, limit }))));
|
|
251
295
|
registerTool('git_user_repos', 'List repositories owned by the authenticated user', { ...PaginationParams, ...ConnectionParam }, async ({ page, limit, connection }) => formatResponse(await clientFor(connection).get('/user/repos', pageQuery({ page, limit }))));
|
|
252
296
|
// ── Repository CRUD ─────────────────────────────────────────────────────
|
|
@@ -1432,7 +1476,7 @@ registerTool('git_repo_mirror_setup_github_backup', 'One-step GitHub backup mirr
|
|
|
1432
1476
|
const client = clientFor(connection);
|
|
1433
1477
|
const results = [];
|
|
1434
1478
|
// 1. Try to create the GitHub repo via GitHub API
|
|
1435
|
-
const ghRes = await fetch(`https://api.github.com/orgs/${ghOrg}/repos`, {
|
|
1479
|
+
const ghRes = await fetch(`https://api.github.com/orgs/${enc(ghOrg)}/repos`, {
|
|
1436
1480
|
method: 'POST',
|
|
1437
1481
|
headers: {
|
|
1438
1482
|
'Authorization': `Bearer ${ghToken}`,
|
|
@@ -1498,7 +1542,7 @@ registerTool('git_repo_mirror_setup_github_backup_full', 'Full GitHub backup: mi
|
|
|
1498
1542
|
const syncInterval = interval ?? '8h0m0s';
|
|
1499
1543
|
const results = [];
|
|
1500
1544
|
// 1. Create GitHub repo (if needed)
|
|
1501
|
-
const ghRes = await fetch(`https://api.github.com/orgs/${ghOrg}/repos`, {
|
|
1545
|
+
const ghRes = await fetch(`https://api.github.com/orgs/${enc(ghOrg)}/repos`, {
|
|
1502
1546
|
method: 'POST',
|
|
1503
1547
|
headers: {
|
|
1504
1548
|
'Authorization': `Bearer ${ghToken}`,
|
|
@@ -1526,7 +1570,7 @@ registerTool('git_repo_mirror_setup_github_backup_full', 'Full GitHub backup: mi
|
|
|
1526
1570
|
return { content: [{ type: 'text', text: `GitHub repo creation failed (${ghRes.status}): ${JSON.stringify(ghData)}` }] };
|
|
1527
1571
|
}
|
|
1528
1572
|
// 2. Enable wiki on GitHub repo (in case it was disabled)
|
|
1529
|
-
await fetch(`https://api.github.com/repos/${ghOrg}/${repo}`, {
|
|
1573
|
+
await fetch(`https://api.github.com/repos/${enc(ghOrg)}/${enc(repo)}`, {
|
|
1530
1574
|
method: 'PATCH',
|
|
1531
1575
|
headers: {
|
|
1532
1576
|
'Authorization': `Bearer ${ghToken}`,
|
|
@@ -1601,7 +1645,7 @@ registerTool('git_admin_cron_list', 'List cron tasks and their last run time (ad
|
|
|
1601
1645
|
registerTool('git_admin_cron_run', 'Trigger a cron task (admin only)', {
|
|
1602
1646
|
task: z.string().describe('Cron task name (e.g. "repo_health_check", "resync_all_hooks", "repo_archive_cleanup")'),
|
|
1603
1647
|
...ConnectionParam,
|
|
1604
|
-
}, async ({ task, connection }) => formatResponse(await clientFor(connection).post(`/admin/cron/${task}`, {})));
|
|
1648
|
+
}, async ({ task, connection }) => formatResponse(await clientFor(connection).post(`/admin/cron/${enc(task)}`, {})));
|
|
1605
1649
|
// ── Generic API Call ────────────────────────────────────────────────────
|
|
1606
1650
|
registerTool('git_api_request', 'Make a raw API request to any Git v1 endpoint', {
|
|
1607
1651
|
method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']).describe('HTTP method'),
|
|
@@ -2270,12 +2314,37 @@ registerTool('git_user_token_create', 'Create an access token for a user', {
|
|
|
2270
2314
|
username: z.string().describe('Owner of the token (username in the path)'),
|
|
2271
2315
|
name: z.string().describe('Name of the token'),
|
|
2272
2316
|
scopes: z.array(z.string()).optional().describe('Token scopes (e.g. ["read:user", "write:repository"])'),
|
|
2317
|
+
all_repos: z.boolean().optional().describe('Token covers all repos incl. future (default true)'),
|
|
2318
|
+
repo_ids: z.array(z.number()).optional().describe('When all_repos=false, restrict to these repository IDs'),
|
|
2319
|
+
repos: z.array(z.string()).optional().describe('Convenience: repo full-names (owner/name) resolved to IDs client-side'),
|
|
2273
2320
|
...ConnectionParam,
|
|
2274
|
-
}, async ({ username, name, scopes, connection }) => {
|
|
2321
|
+
}, async ({ username, name, scopes, all_repos, repo_ids, repos, connection }) => {
|
|
2322
|
+
const client = clientFor(connection);
|
|
2275
2323
|
const body = { name };
|
|
2276
2324
|
if (scopes)
|
|
2277
2325
|
body.scopes = scopes;
|
|
2278
|
-
|
|
2326
|
+
if (all_repos !== undefined)
|
|
2327
|
+
body.all_repos = all_repos;
|
|
2328
|
+
// Resolve any repo full-names (owner/name) to numeric IDs and merge with
|
|
2329
|
+
// any explicit repo_ids. The forge expects the field name `repo_ids`.
|
|
2330
|
+
const resolvedIds = Array.isArray(repo_ids) ? [...repo_ids] : [];
|
|
2331
|
+
if (repos?.length) {
|
|
2332
|
+
for (const full of repos) {
|
|
2333
|
+
const slash = full.indexOf('/');
|
|
2334
|
+
if (slash < 0)
|
|
2335
|
+
throw new Error(`repos entries must be "owner/name": got "${full}"`);
|
|
2336
|
+
const ownerPart = full.slice(0, slash);
|
|
2337
|
+
const repoPart = full.slice(slash + 1);
|
|
2338
|
+
const res = await client.get(`/repos/${enc(ownerPart)}/${enc(repoPart)}`);
|
|
2339
|
+
const id = res.data?.id;
|
|
2340
|
+
if (typeof id !== 'number')
|
|
2341
|
+
throw new Error(`Could not resolve repository id for "${full}" (status ${res.status})`);
|
|
2342
|
+
resolvedIds.push(id);
|
|
2343
|
+
}
|
|
2344
|
+
}
|
|
2345
|
+
if (resolvedIds.length)
|
|
2346
|
+
body.repo_ids = resolvedIds;
|
|
2347
|
+
return formatResponse(await client.post(`/users/${enc(username)}/tokens`, body));
|
|
2279
2348
|
});
|
|
2280
2349
|
registerTool('git_user_token_delete', 'Delete an access token for a user', { username: z.string().describe('Owner of the token (username in the path)'), token: z.string().describe('Token id or name to delete'), ...ConnectionParam }, async ({ username, token, connection }) => formatResponse(await clientFor(connection).delete(`/users/${enc(username)}/tokens/${token}`)));
|
|
2281
2350
|
registerTool('git_oauth2_apps_list', 'List OAuth2 applications for the authenticated user', { ...PaginationParams, ...ConnectionParam }, async ({ page, limit, connection }) => formatResponse(await clientFor(connection).get('/user/applications/oauth2', pageQuery({ page, limit }))));
|
|
@@ -4370,6 +4439,15 @@ async function main() {
|
|
|
4370
4439
|
setGeneratedServer(server);
|
|
4371
4440
|
setRegisteredNames(registeredNames);
|
|
4372
4441
|
registerGeneratedToolsV2();
|
|
4442
|
+
// Detect the operation backends (API > mokogit-cli > git) and emit a one-time,
|
|
4443
|
+
// non-fatal status line. The REST API is the primary path; mokogit-cli/git are
|
|
4444
|
+
// fallbacks. Detection must never block startup, so swallow any probe error.
|
|
4445
|
+
try {
|
|
4446
|
+
warnBackendStatus(await probeBackends());
|
|
4447
|
+
}
|
|
4448
|
+
catch {
|
|
4449
|
+
/* detection is best-effort; the server runs API-only regardless */
|
|
4450
|
+
}
|
|
4373
4451
|
const transport = new StdioServerTransport();
|
|
4374
4452
|
await server.connect(transport);
|
|
4375
4453
|
}
|
package/package.json
CHANGED
package/source/config.ts
CHANGED
|
@@ -13,10 +13,11 @@
|
|
|
13
13
|
* BRIEF: Configuration loader for MokoGIT API MCP connections
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import { readFile, access } from 'node:fs/promises';
|
|
16
|
+
import { readFile, writeFile, rename, access } from 'node:fs/promises';
|
|
17
17
|
import { resolve } from 'node:path';
|
|
18
18
|
import { homedir } from 'node:os';
|
|
19
19
|
import type { GitConfig, GitConnection } from './types.js';
|
|
20
|
+
import { resolveTokenValue, readTokenFile, migrateInlineSecret, warn } from './tokenReader.js';
|
|
20
21
|
|
|
21
22
|
// Canonical config filename. Older installs may still use a pre-rename name, which
|
|
22
23
|
// is read as a fallback so they keep working after the Gitea → MokoGIT rename.
|
|
@@ -60,6 +61,42 @@ async function resolveConfigPath(): Promise<string> {
|
|
|
60
61
|
return primary;
|
|
61
62
|
}
|
|
62
63
|
|
|
64
|
+
/** Extract a hostname from a baseUrl, or '' when it cannot be parsed. */
|
|
65
|
+
function hostOf(baseUrl: string | undefined): string {
|
|
66
|
+
if (!baseUrl) return '';
|
|
67
|
+
try {
|
|
68
|
+
return new URL(baseUrl).host;
|
|
69
|
+
} catch {
|
|
70
|
+
return '';
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Atomically rewrite the config file after a migration, preserving a leading BOM
|
|
76
|
+
* if the original had one and backing up to <config>.bak once. mode 0600.
|
|
77
|
+
* Throws on I/O failure so the caller can fall back to warn-only.
|
|
78
|
+
*/
|
|
79
|
+
async function rewriteConfigAtomic(configPath: string, hadBom: boolean, data: Record<string, unknown>): Promise<void> {
|
|
80
|
+
// One-time backup of the original on-disk file.
|
|
81
|
+
const bak = `${configPath}.bak`;
|
|
82
|
+
try {
|
|
83
|
+
await access(bak);
|
|
84
|
+
// .bak already exists → leave it (first-migration snapshot is the one to keep).
|
|
85
|
+
} catch {
|
|
86
|
+
try {
|
|
87
|
+
const original = await readFile(configPath, 'utf-8');
|
|
88
|
+
await writeFile(bak, original, { mode: 0o600 });
|
|
89
|
+
} catch {
|
|
90
|
+
/* best-effort; absence of a backup must not block the fix */
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const body = (hadBom ? '' : '') + JSON.stringify(data, null, '\t') + '\n';
|
|
95
|
+
const tmp = `${configPath}.tmp-${process.pid}-${Date.now()}`;
|
|
96
|
+
await writeFile(tmp, body, { mode: 0o600 });
|
|
97
|
+
await rename(tmp, configPath);
|
|
98
|
+
}
|
|
99
|
+
|
|
63
100
|
export async function loadConfig(): Promise<GitConfig> {
|
|
64
101
|
const config_path = await resolveConfigPath();
|
|
65
102
|
|
|
@@ -67,8 +104,12 @@ export async function loadConfig(): Promise<GitConfig> {
|
|
|
67
104
|
const raw = await readFile(config_path, 'utf-8');
|
|
68
105
|
// #192: a config file saved with a UTF-8 BOM makes JSON.parse throw
|
|
69
106
|
// "Unexpected token" and the MCP fails to start. Strip a leading BOM first.
|
|
70
|
-
const
|
|
71
|
-
const
|
|
107
|
+
const hadBom = raw.charCodeAt(0) === 0xfeff;
|
|
108
|
+
const clean = hadBom ? raw.slice(1) : raw;
|
|
109
|
+
// Keep the full parsed document (not just the typed view) so a migration
|
|
110
|
+
// rewrite preserves every field we do not touch.
|
|
111
|
+
const parsedFull = JSON.parse(clean) as Record<string, unknown>;
|
|
112
|
+
const parsed = parsedFull as Partial<GitConfig> & Record<string, unknown>;
|
|
72
113
|
|
|
73
114
|
if (!parsed.connections || Object.keys(parsed.connections).length === 0) {
|
|
74
115
|
throw new Error('No connections defined in config');
|
|
@@ -77,9 +118,91 @@ export async function loadConfig(): Promise<GitConfig> {
|
|
|
77
118
|
// A token injected via env is the single source of truth — apply it over
|
|
78
119
|
// whatever the config file holds so a rotated token cannot leave it stale.
|
|
79
120
|
const envToken = envTokenOverride();
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
121
|
+
|
|
122
|
+
// Resolve each connection's credential through the MokoCLI #1043 JSON-aware
|
|
123
|
+
// token ladder, enforcing the no-inline-secrets policy (pass 2).
|
|
124
|
+
//
|
|
125
|
+
// Precedence per connection:
|
|
126
|
+
// 1. env override (MOKOGIT_TOKEN/MOKOGITEA_TOKEN/GITEA_TOKEN) — single source
|
|
127
|
+
// of truth. If the value is an inline literal we WARN only (there is no
|
|
128
|
+
// config line to rewrite) and still use it.
|
|
129
|
+
// 2. conn.tokenFile — file-backed; read with §1b path-safety guards.
|
|
130
|
+
// 3. inline conn.token — WARN + AUTO-MIGRATE to ~/.ssh/tokens/<slug>.token.json,
|
|
131
|
+
// rewrite this config to a tokenFile ref, delete the inline token, and use
|
|
132
|
+
// the migrated file this run. On any migration error, fall back to warn-only
|
|
133
|
+
// and keep running with the inline value (never crash, never lose the cred).
|
|
134
|
+
//
|
|
135
|
+
// A bare PAT round-trips unchanged (back-compat). A Google creds JSON is detected,
|
|
136
|
+
// warned on stderr, and left unusable (empty bearer). A needs-exchange / empty /
|
|
137
|
+
// skipped bearer is allowed to surface at use time (401), not crash at load, so one
|
|
138
|
+
// bad connection cannot take the whole server down. Secrets are never logged.
|
|
139
|
+
//
|
|
140
|
+
// mokogit-mcp sources its config only from $HOME (~/.mcp_mokogit.json) or an
|
|
141
|
+
// explicit $MOKOGIT_MCP_CONFIG / $GITEA_API_MCP_CONFIG override — both TRUSTED
|
|
142
|
+
// sources (§1b). There is NO cwd `.env` loader here, so every tokenFile reference
|
|
143
|
+
// is trusted; we pass trusted=true explicitly.
|
|
144
|
+
// Deep clone of the on-disk document that we mutate for the rewrite ONLY. The
|
|
145
|
+
// runtime objects (parsed.connections[*]) get their `.token` set to the resolved
|
|
146
|
+
// bearer for the HTTP header; the on-disk copy must instead carry a tokenFile ref
|
|
147
|
+
// and NO inline token. Keeping them separate avoids re-leaking the bearer we just
|
|
148
|
+
// assigned to the live object back into the file.
|
|
149
|
+
const rewriteDoc = JSON.parse(JSON.stringify(parsedFull)) as Record<string, unknown>;
|
|
150
|
+
const rewriteConns = rewriteDoc.connections as Record<string, Record<string, unknown>>;
|
|
151
|
+
|
|
152
|
+
let configMutated = false;
|
|
153
|
+
for (const [name, conn] of Object.entries(parsed.connections)) {
|
|
154
|
+
if (envToken) {
|
|
155
|
+
if (typeof conn.token === 'string' && conn.token.trim() && !conn.tokenFile) {
|
|
156
|
+
warn(`connection "${name}" has an inline token in the config AND an env override is set; the env token is used. The inline token is still a policy violation — move it to a tokenFile or remove it.`);
|
|
157
|
+
}
|
|
158
|
+
conn.token = resolveTokenValue(envToken, `env (${name})`).bearer;
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (conn.tokenFile) {
|
|
163
|
+
conn.token = (await readTokenFile(conn.tokenFile, `${name}.tokenFile`, true)).bearer;
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Inline literal → warn + migrate.
|
|
168
|
+
const inline = typeof conn.token === 'string' ? conn.token : '';
|
|
169
|
+
if (!inline.trim()) {
|
|
170
|
+
// Empty/missing inline token: surface the empty-credential error as before.
|
|
171
|
+
conn.token = resolveTokenValue(inline, `${name}.token`).bearer;
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const host = hostOf(conn.baseUrl);
|
|
176
|
+
const slug = host || name;
|
|
177
|
+
try {
|
|
178
|
+
const { tokenFile, created } = await migrateInlineSecret(inline, slug, host, 'mokogit');
|
|
179
|
+
if (created) {
|
|
180
|
+
warn(`connection "${name}": inline "token" migrated to a file (no-inline-secrets policy); config rewritten to tokenFile and the inline token removed.`);
|
|
181
|
+
} else {
|
|
182
|
+
warn(`connection "${name}": inline "token" found but ${tokenFile} already exists; reusing it (not overwritten) and rewriting config to a tokenFile ref.`);
|
|
183
|
+
}
|
|
184
|
+
// Rewrite THIS connection in the on-disk clone: set tokenFile, drop inline token.
|
|
185
|
+
const connRewrite = rewriteConns[name];
|
|
186
|
+
connRewrite.tokenFile = tokenFile;
|
|
187
|
+
delete connRewrite.token;
|
|
188
|
+
conn.tokenFile = tokenFile;
|
|
189
|
+
configMutated = true;
|
|
190
|
+
// Use the migrated file this run (path-safety guards apply).
|
|
191
|
+
conn.token = (await readTokenFile(tokenFile, `${name}.tokenFile`, true)).bearer;
|
|
192
|
+
} catch (err) {
|
|
193
|
+
// Migration failed (read-only FS, perms, …): warn-only, keep running inline.
|
|
194
|
+
warn(`connection "${name}": could not migrate inline token to a file (${err instanceof Error ? err.message : String(err)}); continuing with the inline value for this run (fix manually — no-inline-secrets policy).`);
|
|
195
|
+
conn.token = resolveTokenValue(inline, `${name}.token`).bearer;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (configMutated) {
|
|
200
|
+
try {
|
|
201
|
+
await rewriteConfigAtomic(config_path, hadBom, rewriteDoc);
|
|
202
|
+
} catch (err) {
|
|
203
|
+
// The migrated token files are written; only the config rewrite failed.
|
|
204
|
+
// Warn and keep running — the resolved bearers are already in memory.
|
|
205
|
+
warn(`migrated inline token(s) to files but could not rewrite ${config_path} (${err instanceof Error ? err.message : String(err)}); the inline token(s) remain in the config — fix the file manually.`);
|
|
83
206
|
}
|
|
84
207
|
}
|
|
85
208
|
|
package/source/index.ts
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
import * as https from 'node:https';
|
|
18
18
|
import * as http from 'node:http';
|
|
19
|
+
import { createRequire } from 'node:module';
|
|
19
20
|
import { McpServer, type ToolCallback } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
20
21
|
import type { ZodRawShapeCompat } from '@modelcontextprotocol/sdk/server/zod-compat.js';
|
|
21
22
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
@@ -30,6 +31,7 @@ import {
|
|
|
30
31
|
} from './generated-runtime.js';
|
|
31
32
|
import { registerGeneratedToolsV2 } from './tools.generated.js';
|
|
32
33
|
import { resolveGuardConfig, runGuardedMerge } from './rapid-merge-guard.js';
|
|
34
|
+
import { probeBackends, warnBackendStatus, runMokoCli, cliRunEnabled, assertSafeArgs } from './mokocli.js';
|
|
33
35
|
import {
|
|
34
36
|
investigatePremergeCi,
|
|
35
37
|
evaluatePremergeGate,
|
|
@@ -227,9 +229,14 @@ const OwnerRepo = {
|
|
|
227
229
|
repo: z.string().describe('Repository name'),
|
|
228
230
|
};
|
|
229
231
|
|
|
232
|
+
// Resolve the real package version from package.json (single source of truth,
|
|
233
|
+
// kept in sync with the Makefile PROJECT_VERSION) rather than a hardcoded string.
|
|
234
|
+
const pkg = createRequire(import.meta.url)('../package.json') as { version?: string };
|
|
235
|
+
const SERVER_VERSION = pkg.version ?? '0.0.0';
|
|
236
|
+
|
|
230
237
|
const server = new McpServer({
|
|
231
238
|
name: 'mokogit-mcp',
|
|
232
|
-
version:
|
|
239
|
+
version: SERVER_VERSION,
|
|
233
240
|
});
|
|
234
241
|
|
|
235
242
|
// ── Tool registration wrapper (Git → MokoGIT brand normalization) ────
|
|
@@ -291,6 +298,56 @@ registerTool(
|
|
|
291
298
|
async ({ connection }) => formatResponse(await clientFor(connection).get('/user')),
|
|
292
299
|
);
|
|
293
300
|
|
|
301
|
+
// ── Operation backends: mokogit-cli (MokoCLI) — precedence api > mokogit-cli > git ──
|
|
302
|
+
//
|
|
303
|
+
// The REST API is the PRIMARY path: every other tool here calls it directly, and
|
|
304
|
+
// that is what you should use for anything the API covers. The mokogit-cli binary
|
|
305
|
+
// is a FALLBACK backend for CLI-only operations (or when the API is unavailable);
|
|
306
|
+
// plain git is the last resort. These two tools expose that layer.
|
|
307
|
+
|
|
308
|
+
registerTool(
|
|
309
|
+
'cli_status',
|
|
310
|
+
'Report the MokoGIT MCP operation backends and their precedence (api > mokogit-cli > git): which are available, the resolved mokogit-cli/git binary paths and versions, and the active backend. The REST API is always primary; mokogit-cli and git are fallbacks. Use this to check whether CLI-assisted operations are available on this host.',
|
|
311
|
+
{},
|
|
312
|
+
async () => {
|
|
313
|
+
const status = await probeBackends();
|
|
314
|
+
return { content: [{ type: 'text' as const, text: JSON.stringify(status, null, 2) }] };
|
|
315
|
+
},
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
registerTool(
|
|
319
|
+
'cli_run',
|
|
320
|
+
'Run a mokogit-cli (MokoCLI) subcommand as a FALLBACK backend and return its exit code, stdout and stderr. DISABLED by default — an operator must opt in with $MOKOGIT_ENABLE_CLI_RUN=1 (arbitrary CLI execution). Precedence is api > mokogit-cli > git — prefer the REST API tools for anything they cover; use this only for CLI-only operations or when the API is unavailable. Arguments are passed as argv (no shell); option-like args (leading "-") are rejected unless allowlisted or $MOKOGIT_CLI_ALLOW_FLAGS=1; any cwd must be inside $MOKOGIT_CLI_WORKDIR. Refuses with a clear message when disabled or when mokogit-cli is not installed (check cli_status).',
|
|
321
|
+
{
|
|
322
|
+
args: z.array(z.string()).describe('mokogit-cli arguments as an argv array, e.g. ["version"] or ["repo","list"]. Passed directly to the binary (no shell).'),
|
|
323
|
+
cwd: z.string().optional().describe('Working directory — must resolve inside $MOKOGIT_CLI_WORKDIR (optional).'),
|
|
324
|
+
timeout_seconds: z.number().int().positive().max(600).optional().describe('Max run time in seconds (default 60).'),
|
|
325
|
+
input: z.string().optional().describe('Optional data to write to the CLI’s stdin.'),
|
|
326
|
+
},
|
|
327
|
+
async ({ args, cwd, timeout_seconds, input }) => {
|
|
328
|
+
if (!cliRunEnabled()) {
|
|
329
|
+
return { content: [{ type: 'text' as const, text: 'cli_run is disabled. Arbitrary mokogit-cli execution is an operator opt-in — set $MOKOGIT_ENABLE_CLI_RUN=1 to enable it. The REST API tools are the primary path (precedence api > mokogit-cli > git); use cli_status to see backend availability.' }] };
|
|
330
|
+
}
|
|
331
|
+
try {
|
|
332
|
+
assertSafeArgs(args);
|
|
333
|
+
const r = await runMokoCli(args, {
|
|
334
|
+
cwd,
|
|
335
|
+
timeoutMs: timeout_seconds ? timeout_seconds * 1000 : undefined,
|
|
336
|
+
input,
|
|
337
|
+
});
|
|
338
|
+
const text = [
|
|
339
|
+
`$ ${r.command}`,
|
|
340
|
+
`exit: ${r.code}${r.ok ? '' : ' (non-zero)'}`,
|
|
341
|
+
r.stdout ? `\n--- stdout ---\n${r.stdout.trimEnd()}` : '',
|
|
342
|
+
r.stderr ? `\n--- stderr ---\n${r.stderr.trimEnd()}` : '',
|
|
343
|
+
].filter(Boolean).join('\n');
|
|
344
|
+
return { content: [{ type: 'text' as const, text }] };
|
|
345
|
+
} catch (err) {
|
|
346
|
+
return { content: [{ type: 'text' as const, text: err instanceof Error ? err.message : String(err) }] };
|
|
347
|
+
}
|
|
348
|
+
},
|
|
349
|
+
);
|
|
350
|
+
|
|
294
351
|
registerTool(
|
|
295
352
|
'git_user_orgs',
|
|
296
353
|
'List organizations the authenticated user belongs to',
|
|
@@ -2042,7 +2099,7 @@ registerTool(
|
|
|
2042
2099
|
const results: string[] = [];
|
|
2043
2100
|
|
|
2044
2101
|
// 1. Try to create the GitHub repo via GitHub API
|
|
2045
|
-
const ghRes = await fetch(`https://api.github.com/orgs/${ghOrg}/repos`, {
|
|
2102
|
+
const ghRes = await fetch(`https://api.github.com/orgs/${enc(ghOrg)}/repos`, {
|
|
2046
2103
|
method: 'POST',
|
|
2047
2104
|
headers: {
|
|
2048
2105
|
'Authorization': `Bearer ${ghToken}`,
|
|
@@ -2114,7 +2171,7 @@ registerTool(
|
|
|
2114
2171
|
const results: string[] = [];
|
|
2115
2172
|
|
|
2116
2173
|
// 1. Create GitHub repo (if needed)
|
|
2117
|
-
const ghRes = await fetch(`https://api.github.com/orgs/${ghOrg}/repos`, {
|
|
2174
|
+
const ghRes = await fetch(`https://api.github.com/orgs/${enc(ghOrg)}/repos`, {
|
|
2118
2175
|
method: 'POST',
|
|
2119
2176
|
headers: {
|
|
2120
2177
|
'Authorization': `Bearer ${ghToken}`,
|
|
@@ -2141,7 +2198,7 @@ registerTool(
|
|
|
2141
2198
|
}
|
|
2142
2199
|
|
|
2143
2200
|
// 2. Enable wiki on GitHub repo (in case it was disabled)
|
|
2144
|
-
await fetch(`https://api.github.com/repos/${ghOrg}/${repo}`, {
|
|
2201
|
+
await fetch(`https://api.github.com/repos/${enc(ghOrg)}/${enc(repo)}`, {
|
|
2145
2202
|
method: 'PATCH',
|
|
2146
2203
|
headers: {
|
|
2147
2204
|
'Authorization': `Bearer ${ghToken}`,
|
|
@@ -2269,7 +2326,7 @@ registerTool(
|
|
|
2269
2326
|
task: z.string().describe('Cron task name (e.g. "repo_health_check", "resync_all_hooks", "repo_archive_cleanup")'),
|
|
2270
2327
|
...ConnectionParam,
|
|
2271
2328
|
},
|
|
2272
|
-
async ({ task, connection }) => formatResponse(await clientFor(connection).post(`/admin/cron/${task}`, {})),
|
|
2329
|
+
async ({ task, connection }) => formatResponse(await clientFor(connection).post(`/admin/cron/${enc(task)}`, {})),
|
|
2273
2330
|
);
|
|
2274
2331
|
|
|
2275
2332
|
// ── Generic API Call ────────────────────────────────────────────────────
|
|
@@ -4053,12 +4110,33 @@ registerTool(
|
|
|
4053
4110
|
username: z.string().describe('Owner of the token (username in the path)'),
|
|
4054
4111
|
name: z.string().describe('Name of the token'),
|
|
4055
4112
|
scopes: z.array(z.string()).optional().describe('Token scopes (e.g. ["read:user", "write:repository"])'),
|
|
4113
|
+
all_repos: z.boolean().optional().describe('Token covers all repos incl. future (default true)'),
|
|
4114
|
+
repo_ids: z.array(z.number()).optional().describe('When all_repos=false, restrict to these repository IDs'),
|
|
4115
|
+
repos: z.array(z.string()).optional().describe('Convenience: repo full-names (owner/name) resolved to IDs client-side'),
|
|
4056
4116
|
...ConnectionParam,
|
|
4057
4117
|
},
|
|
4058
|
-
async ({ username, name, scopes, connection }) => {
|
|
4118
|
+
async ({ username, name, scopes, all_repos, repo_ids, repos, connection }) => {
|
|
4119
|
+
const client = clientFor(connection);
|
|
4059
4120
|
const body: Record<string, unknown> = { name };
|
|
4060
4121
|
if (scopes) body.scopes = scopes;
|
|
4061
|
-
|
|
4122
|
+
if (all_repos !== undefined) body.all_repos = all_repos;
|
|
4123
|
+
// Resolve any repo full-names (owner/name) to numeric IDs and merge with
|
|
4124
|
+
// any explicit repo_ids. The forge expects the field name `repo_ids`.
|
|
4125
|
+
const resolvedIds: number[] = Array.isArray(repo_ids) ? [...repo_ids] : [];
|
|
4126
|
+
if (repos?.length) {
|
|
4127
|
+
for (const full of repos) {
|
|
4128
|
+
const slash = full.indexOf('/');
|
|
4129
|
+
if (slash < 0) throw new Error(`repos entries must be "owner/name": got "${full}"`);
|
|
4130
|
+
const ownerPart = full.slice(0, slash);
|
|
4131
|
+
const repoPart = full.slice(slash + 1);
|
|
4132
|
+
const res = await client.get(`/repos/${enc(ownerPart)}/${enc(repoPart)}`);
|
|
4133
|
+
const id = (res.data as { id?: number } | null)?.id;
|
|
4134
|
+
if (typeof id !== 'number') throw new Error(`Could not resolve repository id for "${full}" (status ${res.status})`);
|
|
4135
|
+
resolvedIds.push(id);
|
|
4136
|
+
}
|
|
4137
|
+
}
|
|
4138
|
+
if (resolvedIds.length) body.repo_ids = resolvedIds;
|
|
4139
|
+
return formatResponse(await client.post(`/users/${enc(username)}/tokens`, body));
|
|
4062
4140
|
},
|
|
4063
4141
|
);
|
|
4064
4142
|
|
|
@@ -7151,6 +7229,14 @@ async function main(): Promise<void> {
|
|
|
7151
7229
|
setGeneratedServer(server);
|
|
7152
7230
|
setRegisteredNames(registeredNames);
|
|
7153
7231
|
registerGeneratedToolsV2();
|
|
7232
|
+
// Detect the operation backends (API > mokogit-cli > git) and emit a one-time,
|
|
7233
|
+
// non-fatal status line. The REST API is the primary path; mokogit-cli/git are
|
|
7234
|
+
// fallbacks. Detection must never block startup, so swallow any probe error.
|
|
7235
|
+
try {
|
|
7236
|
+
warnBackendStatus(await probeBackends());
|
|
7237
|
+
} catch {
|
|
7238
|
+
/* detection is best-effort; the server runs API-only regardless */
|
|
7239
|
+
}
|
|
7154
7240
|
const transport = new StdioServerTransport();
|
|
7155
7241
|
await server.connect(transport);
|
|
7156
7242
|
}
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
|
2
|
+
*
|
|
3
|
+
* This file is part of a Moko Consulting project.
|
|
4
|
+
*
|
|
5
|
+
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
6
|
+
*
|
|
7
|
+
* FILE INFORMATION
|
|
8
|
+
* DEFGROUP: mokogit-mcp.MokoCli
|
|
9
|
+
* INGROUP: mokogit-mcp
|
|
10
|
+
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokogit-mcp
|
|
11
|
+
* PATH: /source/mokocli.ts
|
|
12
|
+
* VERSION: 01.00.00
|
|
13
|
+
* BRIEF: Operation-backend detection + invocation for the MokoGIT MCP.
|
|
14
|
+
* Resolves the mokogit-cli (MokoCLI) binary and plain git at runtime and
|
|
15
|
+
* exposes them as FALLBACK backends behind the REST API. Fleet precedence
|
|
16
|
+
* is API > mokogit-cli > git: the REST API is always the primary path, the
|
|
17
|
+
* CLI is the first fallback, git the last. Detection is non-fatal — when a
|
|
18
|
+
* binary is absent the MCP warns once on stderr and keeps running API-only.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { execFile } from 'node:child_process';
|
|
22
|
+
import { promisify } from 'node:util';
|
|
23
|
+
import { statSync, realpathSync } from 'node:fs';
|
|
24
|
+
import { delimiter, join, isAbsolute, resolve, sep } from 'node:path';
|
|
25
|
+
|
|
26
|
+
const execFileP = promisify(execFile);
|
|
27
|
+
const IS_WIN = process.platform === 'win32';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Resolve a binary name/path to an ABSOLUTE path by searching $PATH explicitly
|
|
31
|
+
* (like `which`/`where`), honoring PATHEXT on Windows. Returns null when not
|
|
32
|
+
* found. Running the resolved absolute path — never a bare name — prevents
|
|
33
|
+
* cwd-relative / planted-binary resolution (Windows CreateProcess search order).
|
|
34
|
+
*/
|
|
35
|
+
function resolveExecutable(nameOrPath: string): string | null {
|
|
36
|
+
const isFile = (p: string): boolean => {
|
|
37
|
+
try { return statSync(p).isFile(); } catch { return false; }
|
|
38
|
+
};
|
|
39
|
+
// Explicit path (absolute or contains a separator): accept only if it is a file.
|
|
40
|
+
// On Windows, refuse a .bat/.cmd target (BatBadBut — argv is not safely escaped
|
|
41
|
+
// when execFile spawns cmd.exe for a batch file), even when set via $MOKOGIT_CLI.
|
|
42
|
+
if (isAbsolute(nameOrPath) || nameOrPath.includes('/') || (IS_WIN && nameOrPath.includes('\\'))) {
|
|
43
|
+
if (IS_WIN && /\.(bat|cmd)$/i.test(nameOrPath)) return null;
|
|
44
|
+
const abs = resolve(nameOrPath);
|
|
45
|
+
return isFile(abs) ? abs : null;
|
|
46
|
+
}
|
|
47
|
+
// Bare name: search PATH directories in order. NEVER include '.' / cwd.
|
|
48
|
+
// On Windows accept ONLY .EXE/.COM — never .BAT/.CMD. Running a batch file via
|
|
49
|
+
// execFile spawns cmd.exe and argv is not safely escaped (BatBadBut / CVE-2024-27980),
|
|
50
|
+
// so a batch target would reintroduce command injection through args. Honor
|
|
51
|
+
// PATHEXT order but intersect it with the exe/com allowlist.
|
|
52
|
+
const WIN_SAFE_EXTS = ['.COM', '.EXE'];
|
|
53
|
+
const exts = IS_WIN
|
|
54
|
+
? (() => {
|
|
55
|
+
const fromEnv = (process.env.PATHEXT ?? '').split(';').map((e) => e.trim().toUpperCase()).filter((e) => WIN_SAFE_EXTS.includes(e));
|
|
56
|
+
return fromEnv.length ? fromEnv : WIN_SAFE_EXTS;
|
|
57
|
+
})()
|
|
58
|
+
: [''];
|
|
59
|
+
const dirs = (process.env.PATH ?? '').split(delimiter).filter((d) => d && d.trim() && d !== '.');
|
|
60
|
+
for (const dir of dirs) {
|
|
61
|
+
for (const ext of exts) {
|
|
62
|
+
const cand = join(dir, nameOrPath + (IS_WIN ? ext : ''));
|
|
63
|
+
if (isFile(cand)) return resolve(cand);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Operation backends, in fleet precedence order (highest first). */
|
|
70
|
+
export const BACKEND_PRECEDENCE = ['api', 'mokogit-cli', 'git'] as const;
|
|
71
|
+
export type Backend = (typeof BACKEND_PRECEDENCE)[number];
|
|
72
|
+
|
|
73
|
+
/** Candidate binary names searched on PATH for the MokoCLI, in order. */
|
|
74
|
+
const MOKOCLI_CANDIDATES = ['mokogit-cli', 'mokogit', 'mokocli'];
|
|
75
|
+
/** Env vars that may hold an explicit MokoCLI path (checked before PATH). */
|
|
76
|
+
const MOKOCLI_ENV_VARS = ['MOKOGIT_CLI', 'MOKOCLI', 'MOKOGIT_CLI_PATH'];
|
|
77
|
+
|
|
78
|
+
const VERSION_TIMEOUT_MS = 5000;
|
|
79
|
+
|
|
80
|
+
export interface BinaryProbe {
|
|
81
|
+
available: boolean;
|
|
82
|
+
/** Resolved command (an absolute path from an env var, or a PATH-resolved name). */
|
|
83
|
+
path: string | null;
|
|
84
|
+
/** First line of `--version` output, when it could be read. */
|
|
85
|
+
version: string | null;
|
|
86
|
+
/** How it was resolved: an env var name, 'path', or null when not found. */
|
|
87
|
+
source: string | null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface BackendStatus {
|
|
91
|
+
/** Static precedence order (API > mokogit-cli > git). */
|
|
92
|
+
precedence: Backend[];
|
|
93
|
+
/** The REST API is always the primary path; reachability is per-request. */
|
|
94
|
+
api: { available: true; note: string };
|
|
95
|
+
mokogitCli: BinaryProbe & { envVarsChecked: string[]; candidates: string[] };
|
|
96
|
+
git: BinaryProbe;
|
|
97
|
+
/** Available backends in precedence order (api is always present). */
|
|
98
|
+
availableChain: Backend[];
|
|
99
|
+
/** Highest-precedence backend usable for a generic op — always 'api'. */
|
|
100
|
+
active: Backend;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Run `<bin> <versionArgs>` and return its trimmed first output line, or null. */
|
|
104
|
+
async function readVersion(bin: string, versionArgs: string[]): Promise<string | null> {
|
|
105
|
+
try {
|
|
106
|
+
const { stdout, stderr } = await execFileP(bin, versionArgs, {
|
|
107
|
+
timeout: VERSION_TIMEOUT_MS,
|
|
108
|
+
windowsHide: true,
|
|
109
|
+
});
|
|
110
|
+
const out = (stdout || stderr || '').trim();
|
|
111
|
+
return out ? out.split(/\r?\n/, 1)[0].trim() : '';
|
|
112
|
+
} catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Probe for a binary: try each env var (as an explicit command/path) first, then
|
|
119
|
+
* each PATH candidate, accepting the first whose `--version` runs successfully.
|
|
120
|
+
*/
|
|
121
|
+
async function probeBinary(
|
|
122
|
+
envVars: string[],
|
|
123
|
+
candidates: string[],
|
|
124
|
+
versionArgs: string[] = ['--version'],
|
|
125
|
+
): Promise<BinaryProbe> {
|
|
126
|
+
for (const ev of envVars) {
|
|
127
|
+
const val = process.env[ev];
|
|
128
|
+
if (val && val.trim()) {
|
|
129
|
+
const abs = resolveExecutable(val.trim());
|
|
130
|
+
if (abs) {
|
|
131
|
+
const ver = await readVersion(abs, versionArgs);
|
|
132
|
+
if (ver !== null) return { available: true, path: abs, version: ver || null, source: ev };
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
for (const name of candidates) {
|
|
137
|
+
const abs = resolveExecutable(name);
|
|
138
|
+
if (abs) {
|
|
139
|
+
const ver = await readVersion(abs, versionArgs);
|
|
140
|
+
if (ver !== null) return { available: true, path: abs, version: ver || null, source: 'path' };
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return { available: false, path: null, version: null, source: null };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Build a MINIMAL environment for the child process: only the variables the CLI
|
|
148
|
+
* needs to locate itself, find its home/temp, and authenticate — never the full
|
|
149
|
+
* parent environment. Prevents leaking unrelated secrets into the subprocess.
|
|
150
|
+
*/
|
|
151
|
+
function minimalChildEnv(): NodeJS.ProcessEnv {
|
|
152
|
+
const allow = [
|
|
153
|
+
'PATH', 'Path', 'PATHEXT', 'SystemRoot', 'windir', 'ComSpec',
|
|
154
|
+
'TEMP', 'TMP', 'TMPDIR', 'HOME', 'USERPROFILE', 'HOMEDRIVE', 'HOMEPATH',
|
|
155
|
+
'LANG', 'LC_ALL', 'LC_CTYPE',
|
|
156
|
+
// MokoGIT auth / CLI config the CLI may need to operate
|
|
157
|
+
'MOKOGIT_TOKEN', 'MOKOGITEA_TOKEN', 'GITEA_TOKEN',
|
|
158
|
+
'MOKOGIT_CLI', 'MOKOCLI', 'MOKOGIT_CLI_PATH',
|
|
159
|
+
'MOKOGIT_MCP_CONFIG', 'GITEA_API_MCP_CONFIG',
|
|
160
|
+
'MOKOGIT_HOST', 'MOKOGIT_URL',
|
|
161
|
+
];
|
|
162
|
+
const env: NodeJS.ProcessEnv = {};
|
|
163
|
+
for (const k of allow) if (process.env[k] !== undefined) env[k] = process.env[k];
|
|
164
|
+
return env;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Validate a caller-supplied cwd. Running the CLI in an arbitrary directory is a
|
|
169
|
+
* risk (relative config/hook pickup), so a cwd is honored ONLY when it resolves
|
|
170
|
+
* inside the operator-configured workspace root $MOKOGIT_CLI_WORKDIR. Returns the
|
|
171
|
+
* resolved cwd to use, or throws. When no cwd is requested, the configured root
|
|
172
|
+
* (if any) is used; otherwise the child inherits the MCP's own cwd.
|
|
173
|
+
*/
|
|
174
|
+
function resolveCwd(requested?: string): string | undefined {
|
|
175
|
+
const rootRaw = process.env.MOKOGIT_CLI_WORKDIR;
|
|
176
|
+
const root = rootRaw && rootRaw.trim() ? realpathSync(resolve(rootRaw.trim())) : null;
|
|
177
|
+
if (!requested) return root ?? undefined;
|
|
178
|
+
if (!root) {
|
|
179
|
+
throw new Error('A cwd was requested but no workspace root is configured. Set $MOKOGIT_CLI_WORKDIR to an allowed directory to use cwd.');
|
|
180
|
+
}
|
|
181
|
+
let abs: string;
|
|
182
|
+
try { abs = realpathSync(resolve(root, requested)); } catch { throw new Error(`cwd does not exist or is not accessible: ${requested}`); }
|
|
183
|
+
if (abs !== root && !abs.startsWith(root + sep)) {
|
|
184
|
+
throw new Error(`cwd must be inside the configured workspace root ($MOKOGIT_CLI_WORKDIR): ${root}`);
|
|
185
|
+
}
|
|
186
|
+
return abs;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
let cached: Promise<BackendStatus> | null = null;
|
|
190
|
+
|
|
191
|
+
/** Detect the available operation backends. Cached after the first call. */
|
|
192
|
+
export function probeBackends(force = false): Promise<BackendStatus> {
|
|
193
|
+
if (cached && !force) return cached;
|
|
194
|
+
cached = (async (): Promise<BackendStatus> => {
|
|
195
|
+
const [cli, git] = await Promise.all([
|
|
196
|
+
probeBinary(MOKOCLI_ENV_VARS, MOKOCLI_CANDIDATES),
|
|
197
|
+
probeBinary([], ['git']),
|
|
198
|
+
]);
|
|
199
|
+
const availableChain: Backend[] = ['api'];
|
|
200
|
+
if (cli.available) availableChain.push('mokogit-cli');
|
|
201
|
+
if (git.available) availableChain.push('git');
|
|
202
|
+
return {
|
|
203
|
+
precedence: [...BACKEND_PRECEDENCE],
|
|
204
|
+
api: { available: true, note: 'primary path — all MCP tools call the MokoGIT REST API directly' },
|
|
205
|
+
mokogitCli: { ...cli, envVarsChecked: MOKOCLI_ENV_VARS, candidates: MOKOCLI_CANDIDATES },
|
|
206
|
+
git,
|
|
207
|
+
availableChain,
|
|
208
|
+
active: 'api',
|
|
209
|
+
};
|
|
210
|
+
})();
|
|
211
|
+
return cached;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
let warned = false;
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Emit a one-time, non-fatal status line to stderr describing backend
|
|
218
|
+
* availability. Absence of the CLI is informational, not an error — the REST API
|
|
219
|
+
* is the primary path and the server runs fine without the CLI.
|
|
220
|
+
*/
|
|
221
|
+
export function warnBackendStatus(status: BackendStatus): void {
|
|
222
|
+
if (warned) return;
|
|
223
|
+
warned = true;
|
|
224
|
+
if (status.mokogitCli.available) {
|
|
225
|
+
process.stderr.write(
|
|
226
|
+
`mokogit-cli detected (${status.mokogitCli.path}${status.mokogitCli.version ? ` — ${status.mokogitCli.version}` : ''}); ` +
|
|
227
|
+
`available as a fallback backend. Precedence: api > mokogit-cli > git.\n`,
|
|
228
|
+
);
|
|
229
|
+
} else {
|
|
230
|
+
process.stderr.write(
|
|
231
|
+
`mokogit-cli not found (checked ${MOKOCLI_ENV_VARS.join('/')} and PATH: ${MOKOCLI_CANDIDATES.join(', ')}). ` +
|
|
232
|
+
`CLI-assisted fallback is disabled — operating API-only (precedence api > mokogit-cli > git). ` +
|
|
233
|
+
`Install mokogit-cli or set $MOKOGIT_CLI to enable it.\n`,
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export interface CliRunResult {
|
|
239
|
+
ok: boolean;
|
|
240
|
+
code: number | null;
|
|
241
|
+
stdout: string;
|
|
242
|
+
stderr: string;
|
|
243
|
+
command: string;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const RUN_TIMEOUT_MS = 60000;
|
|
247
|
+
const MAX_BUFFER = 8 * 1024 * 1024;
|
|
248
|
+
|
|
249
|
+
const TRUTHY = /^(1|true|on|yes)$/i;
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Whether the cli_run passthrough is enabled. Arbitrary CLI execution is an
|
|
253
|
+
* explicit operator opt-in and is DISABLED by default — set
|
|
254
|
+
* $MOKOGIT_ENABLE_CLI_RUN=1 to allow it.
|
|
255
|
+
*/
|
|
256
|
+
export function cliRunEnabled(): boolean {
|
|
257
|
+
return TRUTHY.test((process.env.MOKOGIT_ENABLE_CLI_RUN ?? '').trim());
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Global flags permitted even though they start with '-'. */
|
|
261
|
+
const ALLOWED_FLAGS = new Set(['-h', '--help', '-v', '--version', '--json']);
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Reject option-like args (leading '-') not in the allowlist, so a default-on
|
|
265
|
+
* cli_run cannot be handed arbitrary flags. Operators who need full flags set
|
|
266
|
+
* $MOKOGIT_CLI_ALLOW_FLAGS=1. Throws with a clear message on a rejected arg.
|
|
267
|
+
*/
|
|
268
|
+
export function assertSafeArgs(args: string[]): void {
|
|
269
|
+
if (TRUTHY.test((process.env.MOKOGIT_CLI_ALLOW_FLAGS ?? '').trim())) return;
|
|
270
|
+
for (const a of args) {
|
|
271
|
+
if (a.startsWith('-') && !ALLOWED_FLAGS.has(a)) {
|
|
272
|
+
throw new Error(
|
|
273
|
+
`Refused: option-like argument "${a}" is not allowed by default. ` +
|
|
274
|
+
`Allowed flags: ${[...ALLOWED_FLAGS].join(', ')}. Set $MOKOGIT_CLI_ALLOW_FLAGS=1 to permit arbitrary flags.`,
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Invoke the resolved mokogit-cli with argv (NOT a shell string — no shell
|
|
282
|
+
* interpolation). Throws if the CLI is unavailable; resolves with the exit code,
|
|
283
|
+
* stdout and stderr otherwise (a non-zero exit is returned, not thrown).
|
|
284
|
+
*/
|
|
285
|
+
export async function runMokoCli(
|
|
286
|
+
args: string[],
|
|
287
|
+
opts: { cwd?: string; timeoutMs?: number; input?: string } = {},
|
|
288
|
+
): Promise<CliRunResult> {
|
|
289
|
+
const status = await probeBackends();
|
|
290
|
+
if (!status.mokogitCli.available || !status.mokogitCli.path) {
|
|
291
|
+
throw new Error(
|
|
292
|
+
'mokogit-cli is not available on this host (checked ' +
|
|
293
|
+
`${MOKOCLI_ENV_VARS.join('/')} and PATH: ${MOKOCLI_CANDIDATES.join(', ')}). ` +
|
|
294
|
+
'Use the REST API tools (primary path), or install mokogit-cli / set $MOKOGIT_CLI.',
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
// status.mokogitCli.path is an ABSOLUTE path resolved at probe time via an
|
|
298
|
+
// explicit PATH search — we never exec a bare name (no cwd-relative/planted
|
|
299
|
+
// binary). cwd is allowlisted to $MOKOGIT_CLI_WORKDIR; the child gets a minimal
|
|
300
|
+
// environment and no shell (execFile passes argv directly).
|
|
301
|
+
const bin = status.mokogitCli.path;
|
|
302
|
+
const cwd = resolveCwd(opts.cwd);
|
|
303
|
+
const command = `${bin} ${args.join(' ')}`;
|
|
304
|
+
try {
|
|
305
|
+
const child = execFileP(bin, args, {
|
|
306
|
+
cwd,
|
|
307
|
+
timeout: opts.timeoutMs ?? RUN_TIMEOUT_MS,
|
|
308
|
+
maxBuffer: MAX_BUFFER,
|
|
309
|
+
windowsHide: true,
|
|
310
|
+
shell: false,
|
|
311
|
+
env: minimalChildEnv(),
|
|
312
|
+
});
|
|
313
|
+
if (opts.input !== undefined && child.child.stdin) {
|
|
314
|
+
child.child.stdin.end(opts.input);
|
|
315
|
+
}
|
|
316
|
+
const { stdout, stderr } = await child;
|
|
317
|
+
return { ok: true, code: 0, stdout: stdout ?? '', stderr: stderr ?? '', command };
|
|
318
|
+
} catch (err) {
|
|
319
|
+
const e = err as { code?: number; stdout?: string; stderr?: string; message?: string };
|
|
320
|
+
return {
|
|
321
|
+
ok: false,
|
|
322
|
+
code: typeof e.code === 'number' ? e.code : null,
|
|
323
|
+
stdout: e.stdout ?? '',
|
|
324
|
+
stderr: e.stderr ?? e.message ?? String(err),
|
|
325
|
+
command,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
|
2
|
+
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
3
|
+
*
|
|
4
|
+
* MokoCLI #1043 — JSON-aware credential/token reader (vendored per-repo).
|
|
5
|
+
* GitHub standard primary; Google secondary = DETECT + WARN (no exchange).
|
|
6
|
+
* Secret values are NEVER written to stdout and NEVER echoed in errors/warnings.
|
|
7
|
+
*
|
|
8
|
+
* Detection ladder (resolveTokenValue):
|
|
9
|
+
* 1. trim; empty -> throw (no silent anonymous request).
|
|
10
|
+
* 2. JSON.parse; parse-fail OR non-object(array/null/scalar) -> the trimmed
|
|
11
|
+
* string IS the bearer (bare PAT, `gh auth token` output). [raw]
|
|
12
|
+
* 3. object .type in {service_account, authorized_user, external_account,
|
|
13
|
+
* impersonated_service_account} OR (.refresh_token && .token_uri)
|
|
14
|
+
* -> GOOGLE: emit a stderr WARNING and return an UNUSABLE result
|
|
15
|
+
* (bearer '', needsExchange true). Never bearer a private key.
|
|
16
|
+
* 4. else .token || .access_token -> bearer (+ expiry/scope/permissions). [github-json]
|
|
17
|
+
* 5. else -> throw (unrecognized object; never leak the blob as a token).
|
|
18
|
+
*/
|
|
19
|
+
import { readFile, writeFile, rename, mkdir, stat, access, chmod } from 'node:fs/promises';
|
|
20
|
+
import { homedir } from 'node:os';
|
|
21
|
+
import { resolve, sep } from 'node:path';
|
|
22
|
+
|
|
23
|
+
export type TokenKind = 'raw' | 'github-json' | 'google';
|
|
24
|
+
|
|
25
|
+
export interface ResolvedToken {
|
|
26
|
+
/** Bearer value to send. '' when unusable (Google needs-exchange). */
|
|
27
|
+
bearer: string;
|
|
28
|
+
kind: TokenKind;
|
|
29
|
+
/** true for Google creds — not usable as a bearer by this server. */
|
|
30
|
+
needsExchange?: boolean;
|
|
31
|
+
expiresAt?: string;
|
|
32
|
+
expiresIn?: number;
|
|
33
|
+
scopes?: string[];
|
|
34
|
+
permissions?: Record<string, unknown>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const GOOGLE_TYPES = new Set([
|
|
38
|
+
'service_account',
|
|
39
|
+
'authorized_user',
|
|
40
|
+
'external_account',
|
|
41
|
+
'impersonated_service_account',
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
/** Expand a leading "~" to $HOME, then resolve to an absolute path. */
|
|
45
|
+
export function expandHome(p: string): string {
|
|
46
|
+
if (p === '~') return homedir();
|
|
47
|
+
if (p.startsWith('~/') || p.startsWith('~\\')) return resolve(homedir(), p.slice(2));
|
|
48
|
+
return resolve(p);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** stderr ONLY — must never touch stdout (the MCP stdio channel). Never prints secret values. */
|
|
52
|
+
export function warn(msg: string): void {
|
|
53
|
+
process.stderr.write(`[token] WARNING: ${msg}\n`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Largest credential file we will open (§1b path-safety cap). */
|
|
57
|
+
const MAX_TOKEN_FILE_BYTES = 64 * 1024;
|
|
58
|
+
|
|
59
|
+
/** Reduce an arbitrary label to the safe slug charset [A-Za-z0-9._-]. */
|
|
60
|
+
export function sanitizeSlug(s: string): string {
|
|
61
|
+
return (s || '').replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'connection';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** True when `abs` is $HOME itself or lives beneath it (no `..` escape). */
|
|
65
|
+
function isWithinHome(abs: string): boolean {
|
|
66
|
+
const home = resolve(homedir());
|
|
67
|
+
return abs === home || abs.startsWith(home + sep);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Resolve RAW credential content into a bearer per MokoCLI #1043.
|
|
72
|
+
* `source` only labels errors/warnings; secret values are never echoed.
|
|
73
|
+
*/
|
|
74
|
+
export function resolveTokenValue(raw: string, source = 'credential'): ResolvedToken {
|
|
75
|
+
const trimmed = (raw ?? '').trim();
|
|
76
|
+
if (!trimmed) {
|
|
77
|
+
throw new Error(`Credential "${source}" is empty — refusing an anonymous request (set the token or remove the connection).`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
let obj: unknown;
|
|
81
|
+
try {
|
|
82
|
+
obj = JSON.parse(trimmed);
|
|
83
|
+
} catch {
|
|
84
|
+
return { bearer: trimmed, kind: 'raw' }; // bare PAT / `gh auth token`
|
|
85
|
+
}
|
|
86
|
+
if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) {
|
|
87
|
+
return { bearer: trimmed, kind: 'raw' };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const o = obj as Record<string, unknown>;
|
|
91
|
+
const type = typeof o.type === 'string' ? o.type : '';
|
|
92
|
+
const isGoogle = GOOGLE_TYPES.has(type) || (typeof o.refresh_token === 'string' && typeof o.token_uri === 'string');
|
|
93
|
+
if (isGoogle) {
|
|
94
|
+
warn(`credential "${source}" is a Google ${type || 'OAuth token-cache'} JSON; this server does not perform Google token exchange, so it will NOT be used (a private key is never sent as a bearer token).`);
|
|
95
|
+
return { bearer: '', kind: 'google', needsExchange: true };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const bearer = typeof o.token === 'string' ? o.token : typeof o.access_token === 'string' ? o.access_token : '';
|
|
99
|
+
if (bearer) {
|
|
100
|
+
const scopes = Array.isArray(o.scopes)
|
|
101
|
+
? o.scopes.map(String)
|
|
102
|
+
: typeof o.scope === 'string'
|
|
103
|
+
? o.scope.split(/\s+/).filter(Boolean)
|
|
104
|
+
: undefined;
|
|
105
|
+
const expiresAt = typeof o.expires_at === 'string' ? o.expires_at : typeof o.expiry === 'string' ? o.expiry : undefined;
|
|
106
|
+
const expiresIn = typeof o.expires_in === 'number' ? o.expires_in : undefined;
|
|
107
|
+
const permissions = o.permissions && typeof o.permissions === 'object' ? (o.permissions as Record<string, unknown>) : undefined;
|
|
108
|
+
return { bearer, kind: 'github-json', expiresAt, expiresIn, scopes, permissions };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
throw new Error(`Credential "${source}" is an unrecognized JSON object (no "token"/"access_token") — refusing to use it as a bearer token.`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Read a token file (~ expands) and resolve it per #1043, with §1b PATH SAFETY.
|
|
116
|
+
*
|
|
117
|
+
* A credential-file reference can otherwise be abused to read an arbitrary local
|
|
118
|
+
* file (e.g. a path pointing at /etc/shadow or ~/.ssh/id_rsa) and exfiltrate it as
|
|
119
|
+
* a bearer to an attacker-controlled target. Only honor a reference from a TRUSTED
|
|
120
|
+
* config source (the process environment or the user's HOME config / explicit
|
|
121
|
+
* $..._CONFIG). mokogit-mcp has NO cwd `.env` loader, so every tokenFile reference
|
|
122
|
+
* here already comes from a trusted source; `trusted` defaults to true and is passed
|
|
123
|
+
* explicitly from config.ts for clarity / future-proofing.
|
|
124
|
+
*
|
|
125
|
+
* Path constraints enforced before the file is opened: regular file, located under
|
|
126
|
+
* $HOME (no absolute-outside-home, no `..` escape), owner-read/write only
|
|
127
|
+
* (mode & 0o077 === 0), size ≤ 64 KiB. On any failure: stderr WARN naming the path
|
|
128
|
+
* + reason and SKIP (return an unusable result) — never read/send its contents,
|
|
129
|
+
* never crash. Files we auto-write (migrateInlineSecret) are 0600 under
|
|
130
|
+
* ~/.ssh/tokens, so they always pass.
|
|
131
|
+
*/
|
|
132
|
+
export async function readTokenFile(path: string, source?: string, trusted = true): Promise<ResolvedToken> {
|
|
133
|
+
const abs = expandHome(path);
|
|
134
|
+
const label = source ?? abs;
|
|
135
|
+
|
|
136
|
+
if (!trusted) {
|
|
137
|
+
warn(`refusing credential-file reference "${abs}" from an untrusted source (${label}); a file path is only honored from env or the HOME config — SKIPPING.`);
|
|
138
|
+
return { bearer: '', kind: 'raw', needsExchange: true };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (!isWithinHome(abs)) {
|
|
142
|
+
warn(`credential file "${abs}" (${label}) is outside $HOME — refusing to read it (path-safety) — SKIPPING.`);
|
|
143
|
+
return { bearer: '', kind: 'raw', needsExchange: true };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
let st: Awaited<ReturnType<typeof stat>>;
|
|
147
|
+
try {
|
|
148
|
+
st = await stat(abs);
|
|
149
|
+
} catch (err) {
|
|
150
|
+
warn(`credential file "${abs}" (${label}) could not be stat'd: ${err instanceof Error ? err.message : String(err)} — SKIPPING.`);
|
|
151
|
+
return { bearer: '', kind: 'raw', needsExchange: true };
|
|
152
|
+
}
|
|
153
|
+
if (!st.isFile()) {
|
|
154
|
+
warn(`credential file "${abs}" (${label}) is not a regular file — SKIPPING.`);
|
|
155
|
+
return { bearer: '', kind: 'raw', needsExchange: true };
|
|
156
|
+
}
|
|
157
|
+
// Owner-only perms: no group/world read/write/execute bits. Windows / MSYS do
|
|
158
|
+
// not model POSIX perms (stat.mode returns synthetic 0666/0444 regardless of the
|
|
159
|
+
// real ACL), so the check is POSIX-only; on win32 it is skipped and the file is
|
|
160
|
+
// trusted on the strength of the within-$HOME + regular-file + size guards.
|
|
161
|
+
if (process.platform !== 'win32' && (st.mode & 0o077) !== 0) {
|
|
162
|
+
warn(`credential file "${abs}" (${label}) is group/world-accessible (mode ${(st.mode & 0o777).toString(8)}); run \`chmod 600 "${abs}"\` — SKIPPING.`);
|
|
163
|
+
return { bearer: '', kind: 'raw', needsExchange: true };
|
|
164
|
+
}
|
|
165
|
+
if (st.size > MAX_TOKEN_FILE_BYTES) {
|
|
166
|
+
warn(`credential file "${abs}" (${label}) is ${st.size} bytes (> ${MAX_TOKEN_FILE_BYTES} cap) — SKIPPING.`);
|
|
167
|
+
return { bearer: '', kind: 'raw', needsExchange: true };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
let raw: string;
|
|
171
|
+
try {
|
|
172
|
+
raw = await readFile(abs, 'utf-8');
|
|
173
|
+
} catch (err) {
|
|
174
|
+
warn(`failed to read credential file "${abs}" (${label}): ${err instanceof Error ? err.message : String(err)} — SKIPPING.`);
|
|
175
|
+
return { bearer: '', kind: 'raw', needsExchange: true };
|
|
176
|
+
}
|
|
177
|
+
return resolveTokenValue(raw, label);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export interface MigrationResult {
|
|
181
|
+
/** Absolute path of the token file now holding the secret. */
|
|
182
|
+
tokenFile: string;
|
|
183
|
+
/** true when this call created the file; false when an existing file was reused. */
|
|
184
|
+
created: boolean;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* §1 AUTO-MIGRATE an inline secret to ~/.ssh/tokens/<slug>.token.json (self-heal).
|
|
189
|
+
*
|
|
190
|
+
* Writes { token, host, note, migrated_at } at mode 0600 (never overwriting an
|
|
191
|
+
* existing file), and returns the path so the caller can rewrite the config to a
|
|
192
|
+
* tokenFile reference and drop the inline value. The secret is NEVER logged. The
|
|
193
|
+
* caller is responsible for the config rewrite + .bak; this function only owns the
|
|
194
|
+
* token-file side. Throws on I/O failure so the caller can fall back to warn-only.
|
|
195
|
+
*/
|
|
196
|
+
export async function migrateInlineSecret(secret: string, slug: string, host: string, serverName: string): Promise<MigrationResult> {
|
|
197
|
+
const tokensDir = resolve(homedir(), '.ssh', 'tokens');
|
|
198
|
+
await mkdir(tokensDir, { recursive: true, mode: 0o700 });
|
|
199
|
+
|
|
200
|
+
const target = resolve(tokensDir, `${sanitizeSlug(slug)}.token.json`);
|
|
201
|
+
|
|
202
|
+
// Do not clobber an existing token file — reuse it instead.
|
|
203
|
+
try {
|
|
204
|
+
await access(target);
|
|
205
|
+
return { tokenFile: target, created: false };
|
|
206
|
+
} catch {
|
|
207
|
+
// does not exist → write it
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const payload = JSON.stringify(
|
|
211
|
+
{
|
|
212
|
+
token: secret,
|
|
213
|
+
host: host || undefined,
|
|
214
|
+
note: `auto-migrated from inline ${serverName} MCP config (no-inline-secrets policy, MokoCLI #1043)`,
|
|
215
|
+
migrated_at: new Date().toISOString(),
|
|
216
|
+
},
|
|
217
|
+
null,
|
|
218
|
+
'\t',
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
// Atomic-ish: write a sibling temp 0600, then rename into place.
|
|
222
|
+
const tmp = `${target}.tmp-${process.pid}-${Date.now()}`;
|
|
223
|
+
await writeFile(tmp, payload + '\n', { mode: 0o600 });
|
|
224
|
+
try {
|
|
225
|
+
await chmod(tmp, 0o600);
|
|
226
|
+
} catch {
|
|
227
|
+
/* best-effort on platforms without POSIX perms */
|
|
228
|
+
}
|
|
229
|
+
await rename(tmp, target);
|
|
230
|
+
return { tokenFile: target, created: true };
|
|
231
|
+
}
|
package/source/types.ts
CHANGED
|
@@ -16,6 +16,12 @@
|
|
|
16
16
|
export interface GitConnection {
|
|
17
17
|
baseUrl: string;
|
|
18
18
|
token: string;
|
|
19
|
+
/**
|
|
20
|
+
* Optional path to a file holding the token/credential (MokoCLI #1043).
|
|
21
|
+
* Read via readTokenFile and resolved through the JSON-aware token ladder;
|
|
22
|
+
* "~" expands to $HOME. Takes effect only when no env override is present.
|
|
23
|
+
*/
|
|
24
|
+
tokenFile?: string;
|
|
19
25
|
/** Skip TLS certificate verification (self-signed certs) */
|
|
20
26
|
insecure?: boolean;
|
|
21
27
|
}
|