@mokoconsulting/mokoabs-mcp 1.1.0 → 4.1.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.
@@ -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.23.00
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
- else echo "::warning::npmjs.org publish failed (npm exit ${rc}) - secondary registry only, not failing the run."; fi
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.
@@ -14,7 +14,7 @@
14
14
  DEFGROUP:
15
15
  INGROUP: Project.Documentation
16
16
  REPO: MokoConsulting/Template-NPM
17
- VERSION: 01.01.00
17
+ VERSION: 04.01.00
18
18
  PATH: ./CODE_OF_CONDUCT.md
19
19
  BRIEF: Contributor Covenant Code of Conduct version 1.3.0
20
20
  -->
package/CONTRIBUTING.md CHANGED
@@ -14,7 +14,7 @@
14
14
  DEFGROUP:
15
15
  INGROUP: Project.Documentation
16
16
  REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-NPM
17
- VERSION: 01.01.00
17
+ VERSION: 04.01.00
18
18
  PATH: ./CONTRIBUTING.md
19
19
  BRIEF: Contribution guidelines for the project
20
20
  -->
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-mokoabs
13
- PROJECT_VERSION := 01.01.00
13
+ PROJECT_VERSION := 04.01.00
14
14
  PLATFORM := npm
15
15
 
16
16
  # ==============================================================================
package/SECURITY.md CHANGED
@@ -23,7 +23,7 @@ DEFGROUP: Template-Generic
23
23
  INGROUP: Template-Generic.Documentation
24
24
  REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Generic
25
25
  PATH: /SECURITY.md
26
- VERSION: 01.01.00
26
+ VERSION: 04.01.00
27
27
  BRIEF: Security vulnerability reporting and handling policy
28
28
  -->
29
29
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mokoconsulting/mokoabs-mcp",
3
- "version": "1.1.0",
3
+ "version": "4.1.0",
4
4
  "description": "MCP server for Audiobookshelf API operations",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -16,7 +16,7 @@
16
16
  "clean": "rm -rf dist/"
17
17
  },
18
18
  "dependencies": {
19
- "@modelcontextprotocol/sdk": "^1.12.1",
19
+ "@modelcontextprotocol/sdk": "1.12.1",
20
20
  "zod": "^3.24.4"
21
21
  },
22
22
  "devDependencies": {
@@ -1,9 +1,15 @@
1
1
  {
2
+ "_comment": "No inline secrets. Lead every connection with the file-backed `tokenFile`. An inline `apiKey` is deprecated/legacy and is auto-migrated to ~/.ssh/tokens/<slug>.token.json (and this config rewritten) on first run.",
2
3
  "defaultConnection": "production",
3
4
  "connections": {
4
5
  "production": {
5
6
  "baseUrl": "https://abs.mokoconsulting.tech",
6
- "apiKey": "your-audiobookshelf-api-key"
7
+ "tokenFile": "~/.ssh/tokens/abs.mokoconsulting.tech.token"
8
+ },
9
+ "legacy-inline": {
10
+ "_comment": "DEPRECATED: inline apiKey. Shown only to illustrate the legacy shape; it is auto-migrated to a token file on first run.",
11
+ "baseUrl": "https://abs.example.com",
12
+ "apiKey": "legacy-inline-api-key-auto-migrated-on-first-run"
7
13
  }
8
14
  }
9
15
  }
package/source/client.ts CHANGED
@@ -32,12 +32,15 @@ export class ApiClient {
32
32
  this.base_url = conn.baseUrl.replace(/\/+$/, '') + API_PREFIX;
33
33
 
34
34
  // ── Customize auth headers ──────────────────────────────────
35
+ // The bearer is resolved at config-load time (MokoCLI #1043) from the
36
+ // connection's apiKey/tokenFile; fall back to a raw apiKey for safety.
35
37
  // Examples:
36
- // Bearer token: { 'Authorization': `Bearer ${conn.apiKey}` }
37
- // API key header: { 'DOLAPIKEY': conn.apiKey }
38
+ // Bearer token: { 'Authorization': `Bearer ${conn.bearer}` }
39
+ // API key header: { 'DOLAPIKEY': conn.bearer }
38
40
  // Basic auth: { 'Authorization': `Basic ${btoa(user + ':' + pass)}` }
41
+ const bearer = conn.bearer ?? conn.apiKey ?? '';
39
42
  this.headers = {
40
- 'Authorization': `Bearer ${conn.apiKey}`,
43
+ 'Authorization': `Bearer ${bearer}`,
41
44
  'Content-Type': 'application/json',
42
45
  'Accept': 'application/json',
43
46
  };
package/source/config.ts CHANGED
@@ -17,6 +17,8 @@ import { readFile } from 'node:fs/promises';
17
17
  import { resolve } from 'node:path';
18
18
  import { homedir } from 'node:os';
19
19
  import type { ApiConfig, ApiConnection } from './types.js';
20
+ import { resolveTokenValue, readTokenFile } from './tokenReader.js';
21
+ import { migrateInlineSecret, safeCredentialPath } from './credentialStore.js';
20
22
 
21
23
  // ── Customize this ──────────────────────────────────────────────────────
22
24
  // Change the filename to match your project (e.g. ".dolibarr-api-mcp.json")
@@ -38,6 +40,16 @@ export async function loadConfig(): Promise<ApiConfig> {
38
40
  throw new Error('No connections defined in config');
39
41
  }
40
42
 
43
+ // Resolve each connection's credential into a bearer per MokoCLI #1043.
44
+ // A file-backed `tokenFile` takes precedence over an inline `apiKey`.
45
+ // Raw bearer strings pass through unchanged (back-compat); GitHub JSON
46
+ // blobs yield their token/access_token; Google creds are refused. Any
47
+ // inline `apiKey` literal is warned + auto-migrated to a token file and
48
+ // the on-disk config is rewritten (no-inline-secrets policy, Pass 2).
49
+ for (const [name, conn] of Object.entries(parsed.connections)) {
50
+ await resolveConnectionBearer(conn, name, config_path);
51
+ }
52
+
41
53
  return {
42
54
  connections: parsed.connections,
43
55
  defaultConnection: parsed.defaultConnection ?? Object.keys(parsed.connections)[0],
@@ -51,6 +63,70 @@ export async function loadConfig(): Promise<ApiConfig> {
51
63
  }
52
64
  }
53
65
 
66
+ /**
67
+ * Resolve a single connection's credential into `conn.bearer` (in place).
68
+ *
69
+ * Precedence: a file-backed `tokenFile` wins over an inline `apiKey`. The chosen
70
+ * credential runs through the MokoCLI #1043 ladder — raw strings pass through,
71
+ * GitHub JSON yields its token/access_token, Google creds are detected and
72
+ * refused (unusable bearer). Secret values are never logged.
73
+ *
74
+ * No-inline-secrets (Pass 2): a `tokenFile` from the HOME config is a TRUSTED
75
+ * source and is path-safety-checked (§1b) before being read. An inline `apiKey`
76
+ * literal is warned + auto-migrated to ~/.ssh/tokens/<slug>.token.json and the
77
+ * on-disk config rewritten; the current run still uses the resolved bearer even
78
+ * if migration falls back to warn-only.
79
+ */
80
+ async function resolveConnectionBearer(conn: ApiConnection, name: string, configPath: string): Promise<void> {
81
+ let resolved;
82
+
83
+ if (conn.tokenFile) {
84
+ // §1b path safety — the config is a trusted source (HOME / $MOKOABS_CONFIG).
85
+ const safe = safeCredentialPath(conn.tokenFile, { trusted: true });
86
+ if (!safe) {
87
+ throw new Error(`Connection "${name}" tokenFile "${conn.tokenFile}" failed credential-file safety checks (see warning above).`);
88
+ }
89
+ resolved = await readTokenFile(safe, `connection "${name}".tokenFile`);
90
+ } else if (conn.apiKey !== undefined) {
91
+ // Resolve first (validates shape; raw passes through). Then, if the value
92
+ // is an inline literal secret, auto-migrate it out of the config.
93
+ resolved = resolveTokenValue(conn.apiKey, `connection "${name}".apiKey`);
94
+
95
+ const migrated = migrateInlineSecret({
96
+ configPath,
97
+ connectionName: name,
98
+ field: 'apiKey',
99
+ secret: conn.apiKey,
100
+ baseUrl: conn.baseUrl,
101
+ primary: true,
102
+ });
103
+ if (migrated) {
104
+ // Reflect the migration in the in-memory config for this run, and use
105
+ // the FILE as the authoritative secret going forward (§1.6). When an
106
+ // existing token file was reused, its contents may differ from the
107
+ // inline value we just resolved — re-resolve from the file so the
108
+ // current run matches what every future run will use.
109
+ conn.tokenFile = migrated.tokenFile;
110
+ delete conn.apiKey;
111
+ const safe = safeCredentialPath(migrated.tokenFile, { trusted: true });
112
+ if (safe) {
113
+ resolved = await readTokenFile(safe, `connection "${name}".tokenFile (migrated)`);
114
+ }
115
+ }
116
+ } else {
117
+ throw new Error(`Connection "${name}" has neither "apiKey" nor "tokenFile" — nothing to authenticate with.`);
118
+ }
119
+
120
+ if (resolved.needsExchange || !resolved.bearer) {
121
+ throw new Error(
122
+ `Connection "${name}" credential is not usable as a bearer token` +
123
+ (resolved.kind === 'google' ? ' (Google credentials require token exchange, which this server does not perform).' : '.'),
124
+ );
125
+ }
126
+
127
+ conn.bearer = resolved.bearer;
128
+ }
129
+
54
130
  export function getConnection(config: ApiConfig, name?: string): ApiConnection {
55
131
  const key = name ?? config.defaultConnection;
56
132
  const conn = config.connections[key];
@@ -0,0 +1,216 @@
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: mokoabs-mcp.CredentialStore
9
+ * INGROUP: mokoabs-mcp
10
+ * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoabs-mcp
11
+ * PATH: /source/credentialStore.ts
12
+ * VERSION: 01.00.00
13
+ * BRIEF: No-inline-secrets policy — auto-migration + credential-file path safety.
14
+ *
15
+ * Policy (MokoCLI #1043, Pass 2): no secure information may EVER be inline in the
16
+ * MCP settings file. For mokoabs-mcp the only secret field is a connection's
17
+ * `apiKey` (the Audiobookshelf bearer). When an inline literal is found in the
18
+ * on-disk config we warn + auto-migrate it to ~/.ssh/tokens/<slug>.token.json and
19
+ * rewrite the config to reference it via `tokenFile`. Secret values are NEVER
20
+ * printed; warnings go to stderr only (stdout is the MCP stdio channel).
21
+ */
22
+ import { constants as FS } from 'node:fs';
23
+ import {
24
+ accessSync,
25
+ chmodSync,
26
+ copyFileSync,
27
+ existsSync,
28
+ mkdirSync,
29
+ readFileSync,
30
+ renameSync,
31
+ statSync,
32
+ writeFileSync,
33
+ } from 'node:fs';
34
+ import { homedir } from 'node:os';
35
+ import { dirname, join, relative, resolve } from 'node:path';
36
+ import { expandHome } from './tokenReader.js';
37
+
38
+ /** stderr ONLY — never stdout; never prints secret values. */
39
+ function warn(msg: string): void {
40
+ process.stderr.write(`[credential] WARNING: ${msg}\n`);
41
+ }
42
+
43
+ /** Max size for a credential file we are willing to read (64 KiB). */
44
+ const MAX_CRED_FILE_BYTES = 64 * 1024;
45
+
46
+ /** Sanitize an arbitrary label into a safe filename fragment ([A-Za-z0-9._-]). */
47
+ function sanitizeSlug(s: string): string {
48
+ return (s || '').replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'connection';
49
+ }
50
+
51
+ /** Derive the host portion of a baseUrl, or '' if unparseable. */
52
+ function hostOf(baseUrl: string | undefined): string {
53
+ if (!baseUrl) return '';
54
+ try {
55
+ return new URL(baseUrl).host;
56
+ } catch {
57
+ return '';
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Validate a credential-file reference before it is read (section 1b).
63
+ * Returns the resolved absolute path when safe, or null (after a stderr warning)
64
+ * when it must be skipped. `trusted` MUST be false for any reference that came
65
+ * from an untrusted source (e.g. a cwd .env) — those are never honored as paths.
66
+ */
67
+ export function safeCredentialPath(ref: string, opts: { trusted: boolean }): string | null {
68
+ if (!opts.trusted) {
69
+ warn(`credential-file reference "${ref}" came from an untrusted source (cwd .env) — refusing to open it; provide the secret inline or via the HOME config.`);
70
+ return null;
71
+ }
72
+ const abs = expandHome(ref);
73
+ const home = resolve(homedir());
74
+ const rel = relative(home, abs);
75
+ // Must resolve to a path strictly under $HOME (reject the home dir itself,
76
+ // any absolute path outside home, and any ".." escape).
77
+ if (rel === '' || rel.startsWith('..') || rel.split(/[\\/]/).includes('..')) {
78
+ warn(`credential file "${abs}" is outside $HOME (or escapes it via "..") — refusing to open it.`);
79
+ return null;
80
+ }
81
+ let st;
82
+ try {
83
+ st = statSync(abs);
84
+ } catch (err) {
85
+ warn(`credential file "${abs}" could not be stat'd (${err instanceof Error ? err.message : String(err)}) — skipping.`);
86
+ return null;
87
+ }
88
+ if (!st.isFile()) {
89
+ warn(`credential file "${abs}" is not a regular file — skipping.`);
90
+ return null;
91
+ }
92
+ // Owner-read-only: no group/world bits. (On Windows these bits are not
93
+ // meaningfully enforced, so skip the perm check there to avoid false rejects.)
94
+ if (process.platform !== 'win32' && (st.mode & 0o077) !== 0) {
95
+ warn(`credential file "${abs}" is group/world accessible (mode ${(st.mode & 0o777).toString(8)}) — refusing to open it; chmod 600.`);
96
+ return null;
97
+ }
98
+ if (st.size > MAX_CRED_FILE_BYTES) {
99
+ warn(`credential file "${abs}" is larger than ${MAX_CRED_FILE_BYTES} bytes — refusing to open it (not a credential file).`);
100
+ return null;
101
+ }
102
+ return abs;
103
+ }
104
+
105
+ export interface MigrationResult {
106
+ /** Absolute path of the file now holding the secret (may be a pre-existing one). */
107
+ tokenFile: string;
108
+ }
109
+
110
+ /**
111
+ * Auto-migrate an inline secret out of the on-disk config into
112
+ * ~/.ssh/tokens/<slug>.token.json and rewrite the config to reference it.
113
+ * Returns the token-file path on success, or null (warn-only) if migration
114
+ * could not be completed — the caller then keeps using the inline value.
115
+ *
116
+ * NEVER throws for an expected failure (read-only FS, perms): warns and returns
117
+ * null so the server keeps running with the inline value.
118
+ */
119
+ export function migrateInlineSecret(args: {
120
+ configPath: string;
121
+ connectionName: string;
122
+ field: string;
123
+ secret: string;
124
+ baseUrl?: string;
125
+ /** Whether `field` is the primary bearer field (no suffix) or a secondary one. */
126
+ primary: boolean;
127
+ }): MigrationResult | null {
128
+ const { configPath, connectionName, field, secret, baseUrl, primary } = args;
129
+ try {
130
+ // Only migrate the real on-disk config (it must exist and be writable).
131
+ if (!existsSync(configPath)) return null;
132
+ try {
133
+ accessSync(configPath, FS.W_OK);
134
+ } catch {
135
+ warn(`inline secret ${connectionName}.${field} detected but config "${configPath}" is not writable — keeping inline (warn-only).`);
136
+ return null;
137
+ }
138
+
139
+ const tokensDir = join(resolve(homedir()), '.ssh', 'tokens');
140
+ mkdirSync(tokensDir, { recursive: true, mode: 0o700 });
141
+
142
+ const base = sanitizeSlug(hostOf(baseUrl) || connectionName);
143
+ const suffix = primary ? '' : `-${sanitizeSlug(field)}`;
144
+ const target = join(tokensDir, `${base}${suffix}.token.json`);
145
+
146
+ let reusedExisting = false;
147
+ if (existsSync(target)) {
148
+ reusedExisting = true;
149
+ } else {
150
+ const payload = JSON.stringify(
151
+ {
152
+ token: secret,
153
+ host: hostOf(baseUrl) || undefined,
154
+ note: 'auto-migrated from inline mokoabs MCP config (no-inline-secrets policy, MokoCLI #1043)',
155
+ migrated_at: new Date().toISOString(),
156
+ },
157
+ null,
158
+ 2,
159
+ );
160
+ writeFileSync(target, payload + '\n', { mode: 0o600, flag: 'wx' });
161
+ }
162
+
163
+ // Back up the config ONCE (preserving the old inline secret), then rewrite.
164
+ const backup = `${configPath}.bak`;
165
+ if (!existsSync(backup)) {
166
+ try {
167
+ copyFileSync(configPath, backup, FS.COPYFILE_EXCL);
168
+ } catch {
169
+ // Non-fatal: continue with the rewrite even if the backup races.
170
+ }
171
+ try {
172
+ // Harden the backup perms; it still holds the old secret.
173
+ chmodSync(backup, 0o600);
174
+ } catch { /* best-effort */ }
175
+ }
176
+
177
+ rewriteConfigTokenFile(configPath, connectionName, field, target);
178
+
179
+ if (reusedExisting) {
180
+ warn(`inline secret ${connectionName}.${field} migrated: a token file already existed at ${target}; reusing it (did NOT overwrite). Config rewritten to reference it. Backup at ${backup} STILL CONTAINS the old inline secret — delete it after verifying.`);
181
+ } else {
182
+ warn(`inline secret ${connectionName}.${field} migrated to ${target} (mode 0600) and config rewritten to reference it via tokenFile. Backup at ${backup} STILL CONTAINS the old inline secret — delete ${backup} after verifying the migration.`);
183
+ }
184
+ return { tokenFile: target };
185
+ } catch (err) {
186
+ warn(`auto-migration of ${connectionName}.${field} failed (${err instanceof Error ? err.message : String(err)}) — keeping inline value (warn-only).`);
187
+ return null;
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Atomically rewrite the config: set connection[field→tokenFile] = target and
193
+ * delete the inline `field`. Preserves all other fields; writes temp + rename.
194
+ */
195
+ function rewriteConfigTokenFile(configPath: string, connectionName: string, field: string, target: string): void {
196
+ const raw = readFileSync(configPath, 'utf-8');
197
+ const parsed = JSON.parse(raw) as {
198
+ connections?: Record<string, Record<string, unknown>>;
199
+ [k: string]: unknown;
200
+ };
201
+ const conn = parsed.connections?.[connectionName];
202
+ if (!conn) throw new Error(`connection "${connectionName}" vanished from config`);
203
+
204
+ // Map the inline secret field to its file-ref field. For mokoabs the only
205
+ // secret is apiKey → tokenFile; a generic *_password would map to *File.
206
+ const fileField = field === 'apiKey' ? 'tokenFile' : `${field}File`;
207
+ conn[fileField] = `~/${relative(resolve(homedir()), target).split(/[\\/]/).join('/')}`;
208
+ delete conn[field];
209
+
210
+ const tmp = `${configPath}.tmp-${process.pid}-${Date.now()}`;
211
+ writeFileSync(tmp, JSON.stringify(parsed, null, '\t') + '\n', { mode: 0o600 });
212
+ renameSync(tmp, configPath);
213
+ try {
214
+ chmodSync(configPath, 0o600);
215
+ } catch { /* best-effort on platforms without POSIX perms */ }
216
+ }
@@ -0,0 +1,120 @@
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: mokoabs-mcp.TokenReader
9
+ * INGROUP: mokoabs-mcp
10
+ * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoabs-mcp
11
+ * PATH: /source/tokenReader.ts
12
+ * VERSION: 01.00.00
13
+ * BRIEF: MokoCLI #1043 — JSON-aware credential/token reader (vendored per-repo).
14
+ *
15
+ * GitHub standard primary; Google secondary = DETECT + WARN (no exchange).
16
+ * Secret values are NEVER written to stdout and NEVER echoed in errors/warnings.
17
+ *
18
+ * Detection ladder (resolveTokenValue):
19
+ * 1. trim; empty -> throw (no silent anonymous request).
20
+ * 2. JSON.parse; parse-fail OR non-object(array/null/scalar) -> the trimmed
21
+ * string IS the bearer (bare PAT, `gh auth token` output). [raw]
22
+ * 3. object .type in {service_account, authorized_user, external_account,
23
+ * impersonated_service_account} OR (.refresh_token && .token_uri)
24
+ * -> GOOGLE: emit a stderr WARNING and return an UNUSABLE result
25
+ * (bearer '', needsExchange true). Never bearer a private key.
26
+ * 4. else .token || .access_token -> bearer (+ expiry/scope/permissions). [github-json]
27
+ * 5. else -> throw (unrecognized object; never leak the blob as a token).
28
+ */
29
+ import { readFile } from 'node:fs/promises';
30
+ import { homedir } from 'node:os';
31
+ import { resolve } from 'node:path';
32
+
33
+ export type TokenKind = 'raw' | 'github-json' | 'google';
34
+
35
+ export interface ResolvedToken {
36
+ /** Bearer value to send. '' when unusable (Google needs-exchange). */
37
+ bearer: string;
38
+ kind: TokenKind;
39
+ /** true for Google creds — not usable as a bearer by this server. */
40
+ needsExchange?: boolean;
41
+ expiresAt?: string;
42
+ expiresIn?: number;
43
+ scopes?: string[];
44
+ permissions?: Record<string, unknown>;
45
+ }
46
+
47
+ const GOOGLE_TYPES = new Set([
48
+ 'service_account',
49
+ 'authorized_user',
50
+ 'external_account',
51
+ 'impersonated_service_account',
52
+ ]);
53
+
54
+ /** Expand a leading "~" to $HOME, then resolve to an absolute path. */
55
+ export function expandHome(p: string): string {
56
+ if (p === '~') return homedir();
57
+ if (p.startsWith('~/') || p.startsWith('~\\')) return resolve(homedir(), p.slice(2));
58
+ return resolve(p);
59
+ }
60
+
61
+ /** stderr ONLY — must never touch stdout (the MCP stdio channel). Never prints secret values. */
62
+ function warn(msg: string): void {
63
+ process.stderr.write(`[token] WARNING: ${msg}\n`);
64
+ }
65
+
66
+ /**
67
+ * Resolve RAW credential content into a bearer per MokoCLI #1043.
68
+ * `source` only labels errors/warnings; secret values are never echoed.
69
+ */
70
+ export function resolveTokenValue(raw: string, source = 'credential'): ResolvedToken {
71
+ const trimmed = (raw ?? '').trim();
72
+ if (!trimmed) {
73
+ throw new Error(`Credential "${source}" is empty — refusing an anonymous request (set the token or remove the connection).`);
74
+ }
75
+
76
+ let obj: unknown;
77
+ try {
78
+ obj = JSON.parse(trimmed);
79
+ } catch {
80
+ return { bearer: trimmed, kind: 'raw' }; // bare PAT / `gh auth token`
81
+ }
82
+ if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) {
83
+ return { bearer: trimmed, kind: 'raw' };
84
+ }
85
+
86
+ const o = obj as Record<string, unknown>;
87
+ const type = typeof o.type === 'string' ? o.type : '';
88
+ const isGoogle = GOOGLE_TYPES.has(type) || (typeof o.refresh_token === 'string' && typeof o.token_uri === 'string');
89
+ if (isGoogle) {
90
+ 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).`);
91
+ return { bearer: '', kind: 'google', needsExchange: true };
92
+ }
93
+
94
+ const bearer = typeof o.token === 'string' ? o.token : typeof o.access_token === 'string' ? o.access_token : '';
95
+ if (bearer) {
96
+ const scopes = Array.isArray(o.scopes)
97
+ ? o.scopes.map(String)
98
+ : typeof o.scope === 'string'
99
+ ? o.scope.split(/\s+/).filter(Boolean)
100
+ : undefined;
101
+ const expiresAt = typeof o.expires_at === 'string' ? o.expires_at : typeof o.expiry === 'string' ? o.expiry : undefined;
102
+ const expiresIn = typeof o.expires_in === 'number' ? o.expires_in : undefined;
103
+ const permissions = o.permissions && typeof o.permissions === 'object' ? (o.permissions as Record<string, unknown>) : undefined;
104
+ return { bearer, kind: 'github-json', expiresAt, expiresIn, scopes, permissions };
105
+ }
106
+
107
+ throw new Error(`Credential "${source}" is an unrecognized JSON object (no "token"/"access_token") — refusing to use it as a bearer token.`);
108
+ }
109
+
110
+ /** Read a token file (~ expands) and resolve it per #1043. */
111
+ export async function readTokenFile(path: string, source?: string): Promise<ResolvedToken> {
112
+ const abs = expandHome(path);
113
+ let raw: string;
114
+ try {
115
+ raw = await readFile(abs, 'utf-8');
116
+ } catch (err) {
117
+ throw new Error(`Failed to read token file "${abs}": ${err instanceof Error ? err.message : String(err)}`);
118
+ }
119
+ return resolveTokenValue(raw, source ?? abs);
120
+ }
package/source/types.ts CHANGED
@@ -25,8 +25,23 @@
25
25
  export interface ApiConnection {
26
26
  /** Base URL of the API instance (no trailing slash) */
27
27
  baseUrl: string;
28
- /** API key or token for authentication */
29
- apiKey: string;
28
+ /**
29
+ * API key or token for authentication. Either a raw bearer string (passes
30
+ * through unchanged) or a JSON credential blob (GitHub token/access_token,
31
+ * or a Google credential which is detected and refused) per MokoCLI #1043.
32
+ * Optional when `tokenFile` is supplied instead.
33
+ */
34
+ apiKey?: string;
35
+ /**
36
+ * Path to a file holding the credential (~ expands). Read and resolved per
37
+ * MokoCLI #1043; takes precedence over inline `apiKey` when both are set.
38
+ */
39
+ tokenFile?: string;
40
+ /**
41
+ * Resolved bearer value used for `Authorization: Bearer <bearer>`. Populated
42
+ * by loadConfig() from `apiKey`/`tokenFile`; not provided by users directly.
43
+ */
44
+ bearer?: string;
30
45
  /** Skip TLS certificate verification (self-signed certs) */
31
46
  insecure?: boolean;
32
47
  }