@mokoconsulting/mokologs-mcp 4.0.3 → 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: 00.00.01
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: 00.00.01
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 := mokologs-mcp
13
- PROJECT_VERSION := 04.00.03
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: 04.00.15
26
+ VERSION: 04.01.00
27
27
  BRIEF: Security vulnerability reporting and handling policy
28
28
  -->
29
29
 
@@ -1,9 +1,15 @@
1
1
  {
2
+ "//": "No-inline-secrets policy (MokoCLI #1043): token fields MUST be file refs, e.g. \"file:~/.ssh/tokens/<name>.token\". An inline literal token is auto-migrated to ~/.ssh/tokens/<slug>.token.json on first run and this file is rewritten to a file: ref (a one-time .bak is kept — delete it after verifying). SSH keys/passphrases/passwords and the mysql password are NOT affected by this policy.",
2
3
  "defaultSource": "local",
3
4
  "maxBytes": 262144,
4
5
  "maxLines": 2000,
5
6
  "redaction": { "secrets": true, "email": true, "creditcard": true, "jwt": true, "ip": false },
6
- "notify": { "ntfyUrl": "https://ntfy.sh", "topic": "mokologs-alerts", "token": "file:~/.ssh/tokens/ntfy.token" },
7
+ "notify": {
8
+ "ntfyUrl": "https://ntfy.sh",
9
+ "topic": "mokologs-alerts",
10
+ "token": "file:~/.ssh/tokens/ntfy.token",
11
+ "//token-legacy": "DEPRECATED: an inline \"token\": \"<literal>\" is auto-migrated to a file on first run. Prefer the file: ref above."
12
+ },
7
13
  "sources": {
8
14
  "local": {
9
15
  "type": "local",
@@ -36,7 +42,8 @@
36
42
  "type": "gitea",
37
43
  "label": "MokoGIT CI logs",
38
44
  "baseUrl": "https://git.mokoconsulting.tech",
39
- "token": "file:~/.ssh/tokens/git.mokoconsulting.tech.jmiller.token"
45
+ "token": "file:~/.ssh/tokens/git.mokoconsulting.tech.jmiller.token",
46
+ "//token-legacy": "DEPRECATED: an inline \"token\": \"<literal>\" is auto-migrated to a file on first run. Prefer the file: ref above."
40
47
  }
41
48
  }
42
49
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mokoconsulting/mokologs-mcp",
3
- "version": "4.0.3",
3
+ "version": "4.1.0",
4
4
  "description": "MCP server for multi-source log triage & analysis — discover, tail, search, and assess logs across local and remote sources",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -14,7 +14,7 @@
14
14
  "clean": "rm -rf dist/"
15
15
  },
16
16
  "dependencies": {
17
- "@modelcontextprotocol/sdk": "^1.12.1",
17
+ "@modelcontextprotocol/sdk": "1.12.1",
18
18
  "ssh2": "^1.16.0",
19
19
  "zod": "^3.24.4"
20
20
  },
package/source/config.ts CHANGED
@@ -19,6 +19,8 @@ import { resolve } from 'node:path';
19
19
  import { homedir } from 'node:os';
20
20
  import { z } from 'zod';
21
21
  import type { LogsConfig, LogSource } from './types.js';
22
+ import { resolveTokenValue } from './tokenReader.js';
23
+ import { isFileRef, migrateInlineToken, safeReadCredentialFile, hostOf, type MigrationPlan } from './tokenMigrate.js';
22
24
 
23
25
  const CONFIG_FILENAME = '.mokologs-mcp.json';
24
26
  const CONFIG_ENV_VAR = 'MOKOLOGS_CONFIG';
@@ -114,10 +116,63 @@ export function resolveSecret(value: string | undefined): string | undefined {
114
116
  return readFileSync(p, 'utf-8').trim();
115
117
  }
116
118
 
117
- /** Resolve all `file:`-backed secrets on a source (returns a copy). */
118
- function resolveSourceSecrets(src: LogSource): LogSource {
119
+ /** Context needed to warn + auto-migrate an inline token during a load. */
120
+ interface MigrateCtx {
121
+ /** Mutable parsed config object (rewritten in place + on disk on migration). */
122
+ rawObj: Record<string, unknown>;
123
+ /** On-disk config path to rewrite. */
124
+ configPath: string;
125
+ /** Shared one-time `.bak` flag. */
126
+ backup: { done: boolean };
127
+ }
128
+
129
+ /**
130
+ * Resolve a TOKEN field only, enforcing the no-inline-secrets policy:
131
+ * - `file:` ref → PATH-GUARDED read (§1b: within-$HOME, regular file,
132
+ * owner-only perms, ≤64 KiB) then the #1043 JSON ladder.
133
+ * - inline value → WARN + AUTO-MIGRATE to ~/.ssh/tokens/<slug>.token.json
134
+ * (0600), rewrite the config to a `file:` ref, then read it
135
+ * back via the guarded path. Migration failure → warn-only,
136
+ * use the inline value directly for this run.
137
+ *
138
+ * ONLY for token fields (notify.token, source.token) — NEVER for SSH
139
+ * keys/passphrases/passwords or the mysql password (those stay raw).
140
+ *
141
+ * @param plan JSON-path + slug/host metadata for a possible migration.
142
+ */
143
+ function resolveTokenSecret(value: string | undefined, label: string, ctx: MigrateCtx, plan: Omit<MigrationPlan, 'value' | 'label'>): string | undefined {
144
+ if (value === undefined) return undefined;
145
+
146
+ // Already file-backed: guarded read + #1043 ladder. No migration, no warning.
147
+ if (isFileRef(value)) {
148
+ const raw = safeReadCredentialFile(value, label);
149
+ if (raw === undefined) return undefined; // path-safety skip already warned
150
+ return resolveTokenValue(raw, label).bearer || undefined;
151
+ }
152
+
153
+ // Inline literal → warn + auto-migrate, then read back the migrated file.
154
+ const fileRef = migrateInlineToken(ctx.rawObj, ctx.configPath, { ...plan, value, label }, ctx.backup);
155
+ if (fileRef !== undefined) {
156
+ const raw = safeReadCredentialFile(fileRef, label);
157
+ if (raw !== undefined) return resolveTokenValue(raw, label).bearer || undefined;
158
+ // migrated file somehow unreadable — fall through to the inline value
159
+ }
160
+ // Migration failed (or migrated file unreadable): use the inline value this run.
161
+ return resolveTokenValue(value, label).bearer || undefined;
162
+ }
163
+
164
+ /** Resolve all `file:`-backed secrets on a source (returns a copy). The token
165
+ * field gets the policy treatment; SSH key/passphrase/password and the mysql
166
+ * password stay RAW via resolveSecret (unchanged). */
167
+ function resolveSourceSecrets(name: string, src: LogSource, ctx: MigrateCtx): LogSource {
119
168
  const out: LogSource = { ...src };
120
- if (out.token) out.token = resolveSecret(out.token);
169
+ if (out.token) {
170
+ out.token = resolveTokenSecret(out.token, `source "${name}".token`, ctx, {
171
+ path: ['sources', name, 'token'],
172
+ slugBasis: hostOf(src.baseUrl) ?? name,
173
+ host: hostOf(src.baseUrl),
174
+ });
175
+ }
121
176
  if (out.ssh) {
122
177
  out.ssh = {
123
178
  ...out.ssh,
@@ -138,21 +193,29 @@ export async function loadConfig(): Promise<LogsConfig> {
138
193
  : resolve(homedir(), CONFIG_FILENAME);
139
194
 
140
195
  let parsed: z.infer<typeof ConfigSchema>;
196
+ let rawObj: Record<string, unknown>;
141
197
  try {
142
198
  const raw = await readFile(configPath, 'utf-8');
143
- parsed = ConfigSchema.parse(JSON.parse(raw));
199
+ rawObj = JSON.parse(raw) as Record<string, unknown>;
200
+ parsed = ConfigSchema.parse(rawObj);
144
201
  } catch (err) {
145
202
  const message =
146
203
  err instanceof z.ZodError ? err.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; ') : err instanceof Error ? err.message : String(err);
147
204
  throw new Error(`Failed to load config from ${configPath}: ${message}\nCreate ${configPath} — see config.example.json for format.`);
148
205
  }
149
206
 
207
+ // Shared migration context: inline TOKEN fields self-heal into ~/.ssh/tokens
208
+ // and the on-disk config is rewritten to file: refs (atomic, one 0600 .bak).
209
+ const ctx: MigrateCtx = { rawObj, configPath, backup: { done: false } };
210
+
150
211
  const sources: Record<string, LogSource> = {};
151
212
  for (const [name, src] of Object.entries(parsed.sources)) {
152
- sources[name] = resolveSourceSecrets(src as LogSource);
213
+ sources[name] = resolveSourceSecrets(name, src as LogSource, ctx);
153
214
  }
154
215
 
155
- const notify = parsed.notify ? { ...parsed.notify, token: resolveSecret(parsed.notify.token) } : undefined;
216
+ const notify = parsed.notify
217
+ ? { ...parsed.notify, token: resolveTokenSecret(parsed.notify.token, 'notify.token', ctx, { path: ['notify', 'token'], slugBasis: hostOf(parsed.notify.ntfyUrl) ?? 'ntfy', host: hostOf(parsed.notify.ntfyUrl) }) }
218
+ : undefined;
156
219
 
157
220
  return {
158
221
  sources,
@@ -0,0 +1,258 @@
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: mokologs-mcp.TokenMigrate
9
+ * INGROUP: mokologs-mcp
10
+ * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokologs-mcp
11
+ * PATH: /source/tokenMigrate.ts
12
+ * VERSION: 01.00.00
13
+ * BRIEF: No-inline-secrets self-heal for TOKEN fields + credential-file path safety.
14
+ *
15
+ * SCOPE (mokologs-mcp): applies ONLY to the two TOKEN fields `notify.token` and
16
+ * `sources.<name>.token`. SSH private keys / passphrases / passwords and the
17
+ * mysql password are NOT migrated — they stay inline/raw exactly as before
18
+ * (they are legitimately PEM/opaque and out of scope for the JSON token policy).
19
+ *
20
+ * Behaviour (MokoCLI #1043 no-inline-secrets policy):
21
+ * - A `file:<path>` token ref is left untouched; its read is PATH-GUARDED (§1b).
22
+ * - An inline literal token in ~/.mokologs-mcp.json is WARNED + AUTO-MIGRATED
23
+ * to ~/.ssh/tokens/<slug>.token.json (0600), the config rewritten to a
24
+ * `file:` ref (atomic, with a one-time 0600 .bak), and the value used for
25
+ * this run. Any migration error → warn-only (never crash, never lose creds).
26
+ * - Secret values are NEVER written to stdout and NEVER echoed in warnings.
27
+ */
28
+ import {
29
+ chmodSync,
30
+ closeSync,
31
+ constants as fsConstants,
32
+ copyFileSync,
33
+ existsSync,
34
+ mkdirSync,
35
+ openSync,
36
+ readFileSync,
37
+ renameSync,
38
+ statSync,
39
+ writeFileSync,
40
+ writeSync,
41
+ } from 'node:fs';
42
+ import { homedir } from 'node:os';
43
+ import { resolve, sep } from 'node:path';
44
+
45
+ const TOKENS_DIR = () => resolve(homedir(), '.ssh', 'tokens');
46
+ const MAX_CRED_BYTES = 64 * 1024; // §1b size cap
47
+
48
+ /** stderr ONLY — never stdout (the MCP stdio channel). Never prints secret values. */
49
+ function warn(msg: string): void {
50
+ process.stderr.write(`[token] WARNING: ${msg}\n`);
51
+ }
52
+
53
+ /** Expand a leading `~` and the `file:` prefix to an absolute path. */
54
+ export function fileRefToPath(ref: string): string {
55
+ let p = ref.startsWith('file:') ? ref.slice('file:'.length) : ref;
56
+ if (p.startsWith('~')) p = resolve(homedir(), p.slice(1).replace(/^[/\\]/, ''));
57
+ return resolve(p);
58
+ }
59
+
60
+ /** True when a token value is a `file:` reference (already file-backed). */
61
+ export function isFileRef(value: string | undefined): boolean {
62
+ return typeof value === 'string' && value.startsWith('file:');
63
+ }
64
+
65
+ /**
66
+ * §1b credential-file PATH SAFETY. A `file:` token ref from a trusted source
67
+ * (HOME config) may still point anywhere; guard every read:
68
+ * - resolve under $HOME (reject absolute-outside-home and any `..` escape),
69
+ * - regular file,
70
+ * - owner-only perms (mode & 0o077 === 0),
71
+ * - size ≤ 64 KiB.
72
+ * On any failure: stderr WARN (naming path + reason) and return undefined (SKIP) —
73
+ * never read/return the contents, never crash.
74
+ */
75
+ export function safeReadCredentialFile(ref: string, label: string): string | undefined {
76
+ const abs = fileRefToPath(ref);
77
+ const home = resolve(homedir());
78
+
79
+ // within-$HOME + no-`..` escape (compare resolved absolute path against $HOME)
80
+ const rel = abs.slice(home.length);
81
+ const underHome = abs === home || (abs.startsWith(home + sep) && !rel.split(sep).includes('..'));
82
+ if (!underHome) {
83
+ warn(`credential file for ${label} ("${abs}") is outside $HOME or escapes it via "..": refusing to read it.`);
84
+ return undefined;
85
+ }
86
+
87
+ let st;
88
+ try {
89
+ st = statSync(abs);
90
+ } catch (err) {
91
+ warn(`credential file for ${label} ("${abs}") could not be stat'd (${err instanceof Error ? err.message : String(err)}): skipping.`);
92
+ return undefined;
93
+ }
94
+ if (!st.isFile()) {
95
+ warn(`credential file for ${label} ("${abs}") is not a regular file: skipping.`);
96
+ return undefined;
97
+ }
98
+ // owner-only perms. (Windows reports mode 0o666 and lacks POSIX bits — skip the
99
+ // perms gate there; the within-HOME + regular-file + size checks still apply.)
100
+ if (process.platform !== 'win32' && (st.mode & 0o077) !== 0) {
101
+ warn(`credential file for ${label} ("${abs}") is group/world accessible (mode ${(st.mode & 0o777).toString(8)}); expected owner-only (chmod 600): skipping.`);
102
+ return undefined;
103
+ }
104
+ if (st.size > MAX_CRED_BYTES) {
105
+ warn(`credential file for ${label} ("${abs}") is ${st.size} bytes (> ${MAX_CRED_BYTES} cap): skipping.`);
106
+ return undefined;
107
+ }
108
+ try {
109
+ return readFileSync(abs, 'utf-8');
110
+ } catch (err) {
111
+ warn(`credential file for ${label} ("${abs}") could not be read (${err instanceof Error ? err.message : String(err)}): skipping.`);
112
+ return undefined;
113
+ }
114
+ }
115
+
116
+ /** Sanitize an identifier for use in a token filename (`[A-Za-z0-9._-]`). */
117
+ function slugify(s: string): string {
118
+ return (s || 'token').replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'token';
119
+ }
120
+
121
+ /** Derive the host for the token metadata/slug from a baseUrl (if any). */
122
+ function hostOf(baseUrl: string | undefined): string | undefined {
123
+ if (!baseUrl) return undefined;
124
+ try {
125
+ return new URL(baseUrl).host || undefined;
126
+ } catch {
127
+ return undefined;
128
+ }
129
+ }
130
+
131
+ export interface MigrationPlan {
132
+ /** JSON path segments to the inline token field, e.g. ['notify','token']. */
133
+ path: string[];
134
+ /** The inline secret value (never logged). */
135
+ value: string;
136
+ /** Label for warnings, e.g. `source "mokogit-ci".token`. */
137
+ label: string;
138
+ /** Preferred slug basis (host from baseUrl, else connection/field name). */
139
+ slugBasis: string;
140
+ /** Host to record in the token JSON metadata, when known. */
141
+ host?: string;
142
+ }
143
+
144
+ /** Write a file with an explicit mode, failing if it already exists (COPYFILE_EXCL semantics). */
145
+ function writeNewFile0600(path: string, contents: string): void {
146
+ const fd = openSync(path, 'wx', 0o600); // wx = O_CREAT|O_EXCL → throws if exists
147
+ try {
148
+ writeSync(fd, contents);
149
+ } finally {
150
+ closeSync(fd);
151
+ }
152
+ try {
153
+ chmodSync(path, 0o600);
154
+ } catch {
155
+ /* best effort on platforms without chmod */
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Perform one inline-token migration: write the token file, rewrite the config
161
+ * atomically to a `file:` ref, and return the `file:` ref to use for this run.
162
+ * On ANY error, warn-only and return undefined (caller keeps the inline value).
163
+ *
164
+ * @param rawConfigObj the mutable parsed config object (rewritten in place + on disk)
165
+ * @param configPath the on-disk config file to rewrite
166
+ * @param plan which field to migrate
167
+ * @param backupState shared flag so the `.bak` is created at most once per load
168
+ */
169
+ export function migrateInlineToken(
170
+ rawConfigObj: Record<string, unknown>,
171
+ configPath: string,
172
+ plan: MigrationPlan,
173
+ backupState: { done: boolean },
174
+ ): string | undefined {
175
+ try {
176
+ const dir = TOKENS_DIR();
177
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
178
+
179
+ const host = plan.host;
180
+ const slug = slugify(host ?? plan.slugBasis);
181
+ const target = resolve(dir, `${slug}.token.json`);
182
+ const fileRef = `file:${target}`;
183
+
184
+ if (existsSync(target)) {
185
+ warn(`inline secret ${plan.label} not migrated: token file "${target}" already exists; reusing it (verify it holds the right token). Remove the inline value from "${configPath}".`);
186
+ // Still rewrite the config to point at the existing file + drop the inline value.
187
+ } else {
188
+ const tokenJson =
189
+ JSON.stringify(
190
+ {
191
+ token: plan.value,
192
+ host: host ?? '',
193
+ note: 'auto-migrated from inline mokologs MCP config (no-inline-secrets policy, MokoCLI #1043)',
194
+ migrated_at: new Date().toISOString(),
195
+ },
196
+ null,
197
+ '\t',
198
+ ) + '\n';
199
+ writeNewFile0600(target, tokenJson);
200
+ warn(
201
+ `inline secret ${plan.label} migrated → "${target}" (mode 0600); config "${configPath}" rewritten to a file: reference. ` +
202
+ `A one-time backup "${configPath}.bak" STILL CONTAINS the old inline secret — delete it after verifying the migration.`,
203
+ );
204
+ }
205
+
206
+ // Rewrite the config: set the field to the file: ref, then persist atomically.
207
+ setByPath(rawConfigObj, plan.path, fileRef);
208
+ backupAndWriteConfig(rawConfigObj, configPath, backupState);
209
+ return fileRef;
210
+ } catch (err) {
211
+ warn(`could not auto-migrate inline secret ${plan.label} (${err instanceof Error ? err.message : String(err)}): keeping the inline value for this run (no config change).`);
212
+ return undefined;
213
+ }
214
+ }
215
+
216
+ /** Set obj[path[0]][path[1]]... = value (assumes the parents exist). */
217
+ function setByPath(obj: Record<string, unknown>, path: string[], value: unknown): void {
218
+ let cur: Record<string, unknown> = obj;
219
+ for (let i = 0; i < path.length - 1; i++) {
220
+ cur = cur[path[i]] as Record<string, unknown>;
221
+ }
222
+ cur[path[path.length - 1]] = value;
223
+ }
224
+
225
+ /**
226
+ * Back the config up ONCE (0600, only if no `.bak` yet) then write the mutated
227
+ * object atomically (temp file + rename), mode 0600.
228
+ */
229
+ function backupAndWriteConfig(obj: Record<string, unknown>, configPath: string, backupState: { done: boolean }): void {
230
+ const bak = `${configPath}.bak`;
231
+ if (!backupState.done && !existsSync(bak)) {
232
+ // copy then chmod (COPYFILE_EXCL guards against a race)
233
+ copyFileSync(configPath, bak, fsConstants.COPYFILE_EXCL);
234
+ try {
235
+ chmodSync(bak, 0o600);
236
+ } catch {
237
+ /* best effort */
238
+ }
239
+ }
240
+ backupState.done = true;
241
+
242
+ const tmp = `${configPath}.tmp-${process.pid}-${Date.now()}`;
243
+ const contents = JSON.stringify(obj, null, '\t') + '\n';
244
+ writeFileSync(tmp, contents, { mode: 0o600 });
245
+ try {
246
+ chmodSync(tmp, 0o600);
247
+ } catch {
248
+ /* best effort */
249
+ }
250
+ renameSync(tmp, configPath); // atomic replace on the same filesystem
251
+ try {
252
+ chmodSync(configPath, 0o600);
253
+ } catch {
254
+ /* best effort */
255
+ }
256
+ }
257
+
258
+ export { hostOf };
@@ -0,0 +1,125 @@
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: mokologs-mcp.TokenReader
9
+ * INGROUP: mokologs-mcp
10
+ * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokologs-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
+ * SCOPE (mokologs-mcp): apply ONLY to the token fields (notify.token,
19
+ * source.token). SSH private keys / passphrases / passwords and the mysql
20
+ * password are legitimately raw PEM/opaque strings and must NOT be routed
21
+ * through this ladder — they keep returning RAW via resolveSecret.
22
+ *
23
+ * Detection ladder (resolveTokenValue):
24
+ * 1. trim; empty -> throw (no silent anonymous request).
25
+ * 2. JSON.parse; parse-fail OR non-object(array/null/scalar) -> the trimmed
26
+ * string IS the bearer (bare PAT, `gh auth token` output). [raw]
27
+ * 3. object .type in {service_account, authorized_user, external_account,
28
+ * impersonated_service_account} OR (.refresh_token && .token_uri)
29
+ * -> GOOGLE: emit a stderr WARNING and return an UNUSABLE result
30
+ * (bearer '', needsExchange true). Never bearer a private key.
31
+ * 4. else .token || .access_token -> bearer (+ expiry/scope/permissions). [github-json]
32
+ * 5. else -> throw (unrecognized object; never leak the blob as a token).
33
+ */
34
+ import { readFile } from 'node:fs/promises';
35
+ import { homedir } from 'node:os';
36
+ import { resolve } from 'node:path';
37
+
38
+ export type TokenKind = 'raw' | 'github-json' | 'google';
39
+
40
+ export interface ResolvedToken {
41
+ /** Bearer value to send. '' when unusable (Google needs-exchange). */
42
+ bearer: string;
43
+ kind: TokenKind;
44
+ /** true for Google creds — not usable as a bearer by this server. */
45
+ needsExchange?: boolean;
46
+ expiresAt?: string;
47
+ expiresIn?: number;
48
+ scopes?: string[];
49
+ permissions?: Record<string, unknown>;
50
+ }
51
+
52
+ const GOOGLE_TYPES = new Set([
53
+ 'service_account',
54
+ 'authorized_user',
55
+ 'external_account',
56
+ 'impersonated_service_account',
57
+ ]);
58
+
59
+ /** Expand a leading "~" to $HOME, then resolve to an absolute path. */
60
+ export function expandHome(p: string): string {
61
+ if (p === '~') return homedir();
62
+ if (p.startsWith('~/') || p.startsWith('~\\')) return resolve(homedir(), p.slice(2));
63
+ return resolve(p);
64
+ }
65
+
66
+ /** stderr ONLY — must never touch stdout (the MCP stdio channel). Never prints secret values. */
67
+ function warn(msg: string): void {
68
+ process.stderr.write(`[token] WARNING: ${msg}\n`);
69
+ }
70
+
71
+ /**
72
+ * Resolve RAW credential content into a bearer per MokoCLI #1043.
73
+ * `source` only labels errors/warnings; secret values are never echoed.
74
+ */
75
+ export function resolveTokenValue(raw: string, source = 'credential'): ResolvedToken {
76
+ const trimmed = (raw ?? '').trim();
77
+ if (!trimmed) {
78
+ throw new Error(`Credential "${source}" is empty — refusing an anonymous request (set the token or remove the connection).`);
79
+ }
80
+
81
+ let obj: unknown;
82
+ try {
83
+ obj = JSON.parse(trimmed);
84
+ } catch {
85
+ return { bearer: trimmed, kind: 'raw' }; // bare PAT / `gh auth token`
86
+ }
87
+ if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) {
88
+ return { bearer: trimmed, kind: 'raw' };
89
+ }
90
+
91
+ const o = obj as Record<string, unknown>;
92
+ const type = typeof o.type === 'string' ? o.type : '';
93
+ const isGoogle = GOOGLE_TYPES.has(type) || (typeof o.refresh_token === 'string' && typeof o.token_uri === 'string');
94
+ if (isGoogle) {
95
+ 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).`);
96
+ return { bearer: '', kind: 'google', needsExchange: true };
97
+ }
98
+
99
+ const bearer = typeof o.token === 'string' ? o.token : typeof o.access_token === 'string' ? o.access_token : '';
100
+ if (bearer) {
101
+ const scopes = Array.isArray(o.scopes)
102
+ ? o.scopes.map(String)
103
+ : typeof o.scope === 'string'
104
+ ? o.scope.split(/\s+/).filter(Boolean)
105
+ : undefined;
106
+ const expiresAt = typeof o.expires_at === 'string' ? o.expires_at : typeof o.expiry === 'string' ? o.expiry : undefined;
107
+ const expiresIn = typeof o.expires_in === 'number' ? o.expires_in : undefined;
108
+ const permissions = o.permissions && typeof o.permissions === 'object' ? (o.permissions as Record<string, unknown>) : undefined;
109
+ return { bearer, kind: 'github-json', expiresAt, expiresIn, scopes, permissions };
110
+ }
111
+
112
+ throw new Error(`Credential "${source}" is an unrecognized JSON object (no "token"/"access_token") — refusing to use it as a bearer token.`);
113
+ }
114
+
115
+ /** Read a token file (~ expands) and resolve it per #1043. */
116
+ export async function readTokenFile(path: string, source?: string): Promise<ResolvedToken> {
117
+ const abs = expandHome(path);
118
+ let raw: string;
119
+ try {
120
+ raw = await readFile(abs, 'utf-8');
121
+ } catch (err) {
122
+ throw new Error(`Failed to read token file "${abs}": ${err instanceof Error ? err.message : String(err)}`);
123
+ }
124
+ return resolveTokenValue(raw, source ?? abs);
125
+ }