@mokoconsulting/mokologs-mcp 4.0.3 → 4.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.mokogit/workflows/auto-release.yml +4 -3
- package/CODE_OF_CONDUCT.md +1 -1
- package/CONTRIBUTING.md +1 -1
- package/Makefile +1 -1
- package/SECURITY.md +1 -1
- package/config.example.json +9 -2
- package/package.json +2 -2
- package/source/config.ts +74 -6
- package/source/reader.ts +33 -0
- package/source/tokenMigrate.ts +258 -0
- package/source/tokenReader.ts +125 -0
- package/source/winlog.ts +177 -0
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
# INGROUP: MokoCLI.Release
|
|
8
8
|
# REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Generic
|
|
9
9
|
# PATH: /.mokogit/workflows/auto-release.yml
|
|
10
|
-
# VERSION: 07.
|
|
11
|
-
# BRIEF: NEW (07.23.00): "Vendor PHP dependencies (build-time)" added to BOTH publish legs (promote-rc + stable) — this workflow had NO build-time composer vendoring at all, so any component declaring runtime PHP deps via a sub-package composer.json (vendor-dir admin/vendor, e.g. mPDF in MokonyxCRM/MokonyxCRMDCM) shipped its rc/stable zip WITHOUT admin/vendor while the component manifest lists <folder>vendor</folder> — JInstaller then aborts the ENTIRE package install on-site ("File does not exist [ROOT][TMP]/.../admin/vendor" → "Failed to copy administrator files"). The step mirrors pre-release.yml 07.15.00: no-op without a sub-package composer.json; provisions ext-gd when missing (the release runners' php-cli lacks it, which failed mpdf dependency RESOLUTION outright); verifies the CONFIGURED vendor-dir (composer config vendor-dir) materialized autoload.php; FAILS the build loud when vendoring fails for a package whose manifest ships <folder>vendor</folder> (a green build with an install-bricking artifact is strictly worse than a red build); fail-soft warning for packages whose manifests do not reference vendor/. Runs before `moko publish` (release_publish.php → release_package.php zips the plain working tree; vendor/ was never excluded — the tree simply never had it in CI). PREVIOUS (07.22.00): #498 — the #469 SRC_DIR gate no longer self-traps, and the fast-merge guard is MINOR-identity aware (fixes stable version never advancing; MokoCLI stuck re-publishing v10.20.00). (1) The gate read SRC_DIR with grep|sed, capturing the LITERAL unexpanded `$(shell …)` from the fleet-standard `SRC_DIR := $(shell [ -d src ] && echo src || echo source)` — an unmatchable directory that forced should_bump=false on EVERY push even when source changed. It now resolves SRC_DIR via `make --eval` expansion, falls back to the grep|sed read only if make is unavailable, nulls out a still-unexpanded `$(...)`/backtick value, and treats a declared-but-NONEXISTENT SRC_DIR (repos with code in cli/lib/bin, not src/source) as the non-source denylist fallback instead of an unmatchable INCLUSION prefix. (2) The "Guarantee monotonic release version (fast-merge bump guard)" compared the FULL version, so dev's per-merge PATCH bumps (10.20.00→10.20.04) fooled it into "normal path, no heal" and it re-published the already-released vMAJOR.MINOR.00. It now compares the MINOR identity (MAJOR.MINOR), so a fast dev→stable merge with no promote-rc cuts a new MINOR. Fleet-wide release-semantics change — validate on one repo before syncing. PREVIOUS (07.21.00): #469 — gate the version BUMP on whether the push touched the Makefile SRC_DIR. The former non-code paths-ignore denylist on the push trigger is REMOVED: a docs/workflow/sample push no longer skips the pipeline, it now RUNS in no-bump "update the existing stable release in place" mode (re-stamps the UNCHANGED current version via the existing `moko publish --bump none --no-commit`, refreshes notes/assets, no version advance, no new vNN tag). A new early step "Resolve SRC_DIR bump gate (#469)" (id: srcgate) decides should_bump: (1) ELECTRON (file-detected) or opt-out vars.RELEASE_SRCDIR_GATE=off -> BYPASS, always bump as today (electron has NO source dir, so a naive SRC_DIR test would never bump — a release-breaking trap); (2) else SRC_DIR is REQUIRED — a non-electron repo with no resolvable SRC_DIR FAILS LOUD (::error:: + exit 1), never silently never-bump; (3) else diff the push range (github.event.before..GITHUB_SHA, with a HEAD^1 / merge-base fallback) intersected with ${SRC_DIR}/ — source hit -> should_bump=true, none -> should_bump=false. workflow_dispatch always bumps. The self-heal monotonic bump + the new vNN semver tag are now gated on should_bump!='false' so no-bump mode keeps the version PUT; the publish + "Update release notes and reconcile changelog" (channel-pointer resolution) run on BOTH paths to refresh the current release. Loop-safety is unchanged: the job if: still re-checks [skip ci]/[skip sync], so the workflow's own commits never re-trigger. Fleet-wide release-semantics change — validate on one repo before syncing. PREVIOUS (07.20.00): the dev/promote bump + release steps now name the per-run token env var GITHUB_TOKEN (was the misleadingly-named MOKOGIT_TOKEN; the VALUE was always ${{ github.token }}). All token use in this workflow is SAME-REPO (release API, the release_bump_dev dev base bump, pushes to its own repo) — the org MOKOGIT_TOKEN secret is not referenced here. Naming-only; no behavior change. Mirrors the pre-release.yml fix (MokoGIT#1721). PREVIOUS: NEW (07.19.00): #1621 Phase 6 / .mokogit-private#76 — resolve the CURRENT stable release by its CHANNEL POINTER, not by a fixed tag_name=stable. Under version-primary releases (a release is born with the immutable tag_name=vNN and `stable`/`rc` attached as movable is_channel tags), GET /releases/tags/stable no longer resolves the current stable release, which would break two steps that read "the last released version". Both now call GET /repos/{owner}/{repo}/releases/channel/stable (#1702 — resolves whichever release the `stable` channel points at via ReleaseStreamMap) with a GRACEFUL FALLBACK to the legacy GET /releases/tags/stable for forges predating #1702 (the channel route 404s pre-rollout and the rolling `stable` tag still exists). (1) The monotonic-version guard ("Guarantee monotonic release version") resolves ROLLING_LAST by parsing the same `VERSION: NN.NN.NN` out of the channel-resolved release NAME. (2) "Update release notes and reconcile changelog" resolves the stable RELEASE_ID + VERSION the same channel-first way. The #386 integrity gate is UNCHANGED (it already keys off the immutable vNN version tag). SEQUENCING unchanged: do NOT enable in prod until #1702 server support is on stable/prod; works pre-#1702 via the fallback. PREVIOUS (07.18.00): #1621 Phase 6 — version/* BRANCHES retired in favor of dual-tagged version RELEASES. REMOVED three now-redundant steps: "Step 12: Create version branch from main", "Step 13: Prune superseded-major version branches", and "Advance rolling channel tag via git". A release's permanent version identity is now the git tag v<version>, attached by the publish as a MOVABLE channel tag on the dual-tagged release (MokoCLI release_publish -> release_create channel_tags[]/update_stream; the forge attaches/moves the tag at the release commit via AttachChannelTag server-side) — so a new cut is born dual-tagged (rolling `stable`/`release-candidate` primary tag + permanent v<version> channel tag) and the channel tag MOVES onto the new release instead of a new version/* branch being created. The rolling `stable` tag is still pinned by release_create forceTagRef() (#808), so the "Advance rolling channel tag" force-push was redundant. KEPT UNCHANGED: "Create semver tag" (v<version>), the #386 integrity gate (still resolves tags/stable to the built commit), and the monotonic guard (still reads the stable release name VERSION: NN.NN.NN). Pruning EXISTING version/* branches is a SEPARATE one-time manual reconcile — not done here. SEQUENCING: do NOT enable in prod until the #1621 SERVER channel_tags support is on stable/prod. PREVIOUS (07.17.00): Setup MokoCLI tools no longer installs a STALE mokocli during the registry publish-lag window (#1671). After the fresh registry install, the step now compares the installed /tmp/mokocli PROJECT_VERSION against the /opt/mokocli failsafe and PREFERS whichever is NEWER (sort -V), and the opportunistic /opt refresh is gated to only move FORWARD (never downgrade /opt when it is the newer, just-deployed copy). Backward-compatible: runners with no /opt/mokocli use the registry copy exactly as before. PREVIOUS (07.16.00): release-integrity + dev-channel fixes. (1) A new pre-publish "no stale (pre-merge) tree" guard (#428/MokoCLI#901) hard-FAILS the release if the triggering GITHUB_SHA is an ANCESTOR of the current branch tip (a concurrent merge landed mid-run) -- otherwise a green, version-bumped release silently ships old code missing that merge (reproduced: built ef8c1a5d while tip was 2740342). Fail-open on any unresolved/replica-lagged tip so a good release is never falsely blocked. Complements the mokocli engine assertion (packaged-tree == GITHUB_SHA), which cannot see that GITHUB_SHA itself trails the tip. (2) The "Post-release: Reset dev version" safety-net (#309/MokoCLI#900) no longer swallows failure: a failed dev-advance now emits ::error:: + a job-summary block (dev-channel would trail stable), and a SUCCESSFUL advance dispatches pre-release.yml (development) to actually publish the leading dev build -- the advance commit is [skip ci]+paths-ignored and never auto-fires. Stays green (continue-on-error) since stable already published. NEW (07.15.00): the rolling stable channel tag is now advanced via a git force-push BEFORE the #386 integrity gate. MokoGIT's tags API 405s on the release-attached `stable` tag, so release_publish.php could not move it ("Could not pin tag stable ... HTTP 405") and every Joomla/electron stable cut left the tag lagging the freshly-built commit -- red-failing a correct release on the integrity gate. A new step git-force-pushes refs/tags/stable to the built HEAD (force-push allowlist -- the same escape hatch the rc BRANCH pin already uses), so the gate passes and no manual tag repoint is needed. PREVIOUS (07.14.00): integrity gate #386 no longer false-fails. It compared the published tag against GITHUB_SHA (the triggering commit), but the publish step commits the CHANGELOG entry ("build NN.NN.NN [skip ci]") and mokocli pins the channel tag + semver tag to that POST-PUBLISH HEAD -- so every cut whose CHANGELOG wasn't pre-seeded on dev (e.g. a version set straight on the release branch) diverged by one commit and red-failed a correct release. The gate now compares against the actual built/published HEAD (git rev-parse HEAD, falling back to GITHUB_SHA), which equals what the tag is pinned to in both the commit and no-commit cases while still catching a tag pinned to a commit we did NOT build; the semver tag is pinned to the same built HEAD for consistency. PREVIOUS (07.13.00): RC is ON-DEMAND ONLY. The `pull_request` trigger (RC-preview on a PR to main/stable) is REMOVED and the promote-rc job now runs solely on workflow_dispatch action=promote-rc. Only dev + stable release automatically (dev via pre-release.yml push->dev; stable via the push->main release job here); alpha/beta/rc never auto-cut. PREVIOUS (07.12.00): electron repos create NO release on ANY channel (RC enabled=false + stable publish/tag/notes/integrity guarded on is_electron; opt back in with vars.ELECTRON_CI_BUILD=true) and build NO installer in CI — always built locally + uploaded manually; the dev/promote version BUMP still runs. Template-Generic #390. NEW (07.11.00): the electron installer build/attach steps (electron-builder via Wine/Xvfb) are gated to MANUAL workflow_dispatch only (`&& github.event_name == 'workflow_dispatch'`). An automatic push no longer auto-builds electron installers (heavy ~90m, runner-fragile — OOM/Wine-zombie; migrating to mokocli ElectronPackager, MokoCLI #756/#758). The version bump, CHANGELOG, tag and npm publish STILL run on push — only the electron installer is skipped and pushed MANUALLY via dispatch. Non-electron platforms (joomla/go/npm) unaffected. Template-Generic #390. Universal build & release - BUMP-ON-DEV: base version + CHANGELOG promoted on dev pre-promotion (promote-rc), main publishes --bump none --no-commit so it never diverges from dev. MokoCLI build agent (moko publish). NEW (07.06.00): npm stable publishing ABSORBED here from Template-NPM's retired npm-publish.yml as npm-gated dual-registry steps (MokoGIT registry via per-run token + npmjs.org best-effort), replacing the former npm-publish.yml dispatch. No-Makefile repos SKIP the release job gracefully (green) instead of failing. Conflict-marker scan reads git-tracked files only (git ls-files | xargs grep) so vendored/generated trees can't false-match. NEW (07.07.00): npm stamp normalizes the zero-padded Makefile PROJECT_VERSION (NN.NN.NN) to strict semver via awk before `npm version`, and an unstampable version now HARD-FAILS instead of silently keeping the old package.json version (#382). NEW (07.00.00): `moko assets:optimize` artifact-time step added before publish (graceful-skip on non-asset repos); Setup MokoCLI tools makes the fresh `moko` executable + first on PATH to fix exit-126 (mokocli #650).
|
|
10
|
+
# VERSION: 07.24.00
|
|
11
|
+
# BRIEF: NEW (07.24.00): npmjs.org publish now FAILS LOUD (::error:: + exit $rc) on an AUTHORIZATION error (404 "not in this registry" / 401 / 403 / ENEEDAUTH / EOTP) instead of swallowing EVERY failure as a non-failing ::warning:: — a missing-scope / unauthorized / revoked / expired NPM_TOKEN previously hid behind a green release fleet-wide (the "secondary registry, not failing the run" catch masked a 404 PUT, which is exactly how npm reports an unauthorized scoped write; CI had in fact NEVER successfully published any MCP package to npmjs while every run reported success). Transient/5xx/network failures still stay a non-failing ::warning:: (npmjs is the SECONDARY mirror; the MokoGIT registry is primary and already hard-fails), and an unset NPM_TOKEN still skips cleanly — so repos that do not publish to npmjs are 100% unaffected. PREVIOUS (07.23.00): "Vendor PHP dependencies (build-time)" added to BOTH publish legs (promote-rc + stable) — this workflow had NO build-time composer vendoring at all, so any component declaring runtime PHP deps via a sub-package composer.json (vendor-dir admin/vendor, e.g. mPDF in MokonyxCRM/MokonyxCRMDCM) shipped its rc/stable zip WITHOUT admin/vendor while the component manifest lists <folder>vendor</folder> — JInstaller then aborts the ENTIRE package install on-site ("File does not exist [ROOT][TMP]/.../admin/vendor" → "Failed to copy administrator files"). The step mirrors pre-release.yml 07.15.00: no-op without a sub-package composer.json; provisions ext-gd when missing (the release runners' php-cli lacks it, which failed mpdf dependency RESOLUTION outright); verifies the CONFIGURED vendor-dir (composer config vendor-dir) materialized autoload.php; FAILS the build loud when vendoring fails for a package whose manifest ships <folder>vendor</folder> (a green build with an install-bricking artifact is strictly worse than a red build); fail-soft warning for packages whose manifests do not reference vendor/. Runs before `moko publish` (release_publish.php → release_package.php zips the plain working tree; vendor/ was never excluded — the tree simply never had it in CI). PREVIOUS (07.22.00): #498 — the #469 SRC_DIR gate no longer self-traps, and the fast-merge guard is MINOR-identity aware (fixes stable version never advancing; MokoCLI stuck re-publishing v10.20.00). (1) The gate read SRC_DIR with grep|sed, capturing the LITERAL unexpanded `$(shell …)` from the fleet-standard `SRC_DIR := $(shell [ -d src ] && echo src || echo source)` — an unmatchable directory that forced should_bump=false on EVERY push even when source changed. It now resolves SRC_DIR via `make --eval` expansion, falls back to the grep|sed read only if make is unavailable, nulls out a still-unexpanded `$(...)`/backtick value, and treats a declared-but-NONEXISTENT SRC_DIR (repos with code in cli/lib/bin, not src/source) as the non-source denylist fallback instead of an unmatchable INCLUSION prefix. (2) The "Guarantee monotonic release version (fast-merge bump guard)" compared the FULL version, so dev's per-merge PATCH bumps (10.20.00→10.20.04) fooled it into "normal path, no heal" and it re-published the already-released vMAJOR.MINOR.00. It now compares the MINOR identity (MAJOR.MINOR), so a fast dev→stable merge with no promote-rc cuts a new MINOR. Fleet-wide release-semantics change — validate on one repo before syncing. PREVIOUS (07.21.00): #469 — gate the version BUMP on whether the push touched the Makefile SRC_DIR. The former non-code paths-ignore denylist on the push trigger is REMOVED: a docs/workflow/sample push no longer skips the pipeline, it now RUNS in no-bump "update the existing stable release in place" mode (re-stamps the UNCHANGED current version via the existing `moko publish --bump none --no-commit`, refreshes notes/assets, no version advance, no new vNN tag). A new early step "Resolve SRC_DIR bump gate (#469)" (id: srcgate) decides should_bump: (1) ELECTRON (file-detected) or opt-out vars.RELEASE_SRCDIR_GATE=off -> BYPASS, always bump as today (electron has NO source dir, so a naive SRC_DIR test would never bump — a release-breaking trap); (2) else SRC_DIR is REQUIRED — a non-electron repo with no resolvable SRC_DIR FAILS LOUD (::error:: + exit 1), never silently never-bump; (3) else diff the push range (github.event.before..GITHUB_SHA, with a HEAD^1 / merge-base fallback) intersected with ${SRC_DIR}/ — source hit -> should_bump=true, none -> should_bump=false. workflow_dispatch always bumps. The self-heal monotonic bump + the new vNN semver tag are now gated on should_bump!='false' so no-bump mode keeps the version PUT; the publish + "Update release notes and reconcile changelog" (channel-pointer resolution) run on BOTH paths to refresh the current release. Loop-safety is unchanged: the job if: still re-checks [skip ci]/[skip sync], so the workflow's own commits never re-trigger. Fleet-wide release-semantics change — validate on one repo before syncing. PREVIOUS (07.20.00): the dev/promote bump + release steps now name the per-run token env var GITHUB_TOKEN (was the misleadingly-named MOKOGIT_TOKEN; the VALUE was always ${{ github.token }}). All token use in this workflow is SAME-REPO (release API, the release_bump_dev dev base bump, pushes to its own repo) — the org MOKOGIT_TOKEN secret is not referenced here. Naming-only; no behavior change. Mirrors the pre-release.yml fix (MokoGIT#1721). PREVIOUS: NEW (07.19.00): #1621 Phase 6 / .mokogit-private#76 — resolve the CURRENT stable release by its CHANNEL POINTER, not by a fixed tag_name=stable. Under version-primary releases (a release is born with the immutable tag_name=vNN and `stable`/`rc` attached as movable is_channel tags), GET /releases/tags/stable no longer resolves the current stable release, which would break two steps that read "the last released version". Both now call GET /repos/{owner}/{repo}/releases/channel/stable (#1702 — resolves whichever release the `stable` channel points at via ReleaseStreamMap) with a GRACEFUL FALLBACK to the legacy GET /releases/tags/stable for forges predating #1702 (the channel route 404s pre-rollout and the rolling `stable` tag still exists). (1) The monotonic-version guard ("Guarantee monotonic release version") resolves ROLLING_LAST by parsing the same `VERSION: NN.NN.NN` out of the channel-resolved release NAME. (2) "Update release notes and reconcile changelog" resolves the stable RELEASE_ID + VERSION the same channel-first way. The #386 integrity gate is UNCHANGED (it already keys off the immutable vNN version tag). SEQUENCING unchanged: do NOT enable in prod until #1702 server support is on stable/prod; works pre-#1702 via the fallback. PREVIOUS (07.18.00): #1621 Phase 6 — version/* BRANCHES retired in favor of dual-tagged version RELEASES. REMOVED three now-redundant steps: "Step 12: Create version branch from main", "Step 13: Prune superseded-major version branches", and "Advance rolling channel tag via git". A release's permanent version identity is now the git tag v<version>, attached by the publish as a MOVABLE channel tag on the dual-tagged release (MokoCLI release_publish -> release_create channel_tags[]/update_stream; the forge attaches/moves the tag at the release commit via AttachChannelTag server-side) — so a new cut is born dual-tagged (rolling `stable`/`release-candidate` primary tag + permanent v<version> channel tag) and the channel tag MOVES onto the new release instead of a new version/* branch being created. The rolling `stable` tag is still pinned by release_create forceTagRef() (#808), so the "Advance rolling channel tag" force-push was redundant. KEPT UNCHANGED: "Create semver tag" (v<version>), the #386 integrity gate (still resolves tags/stable to the built commit), and the monotonic guard (still reads the stable release name VERSION: NN.NN.NN). Pruning EXISTING version/* branches is a SEPARATE one-time manual reconcile — not done here. SEQUENCING: do NOT enable in prod until the #1621 SERVER channel_tags support is on stable/prod. PREVIOUS (07.17.00): Setup MokoCLI tools no longer installs a STALE mokocli during the registry publish-lag window (#1671). After the fresh registry install, the step now compares the installed /tmp/mokocli PROJECT_VERSION against the /opt/mokocli failsafe and PREFERS whichever is NEWER (sort -V), and the opportunistic /opt refresh is gated to only move FORWARD (never downgrade /opt when it is the newer, just-deployed copy). Backward-compatible: runners with no /opt/mokocli use the registry copy exactly as before. PREVIOUS (07.16.00): release-integrity + dev-channel fixes. (1) A new pre-publish "no stale (pre-merge) tree" guard (#428/MokoCLI#901) hard-FAILS the release if the triggering GITHUB_SHA is an ANCESTOR of the current branch tip (a concurrent merge landed mid-run) -- otherwise a green, version-bumped release silently ships old code missing that merge (reproduced: built ef8c1a5d while tip was 2740342). Fail-open on any unresolved/replica-lagged tip so a good release is never falsely blocked. Complements the mokocli engine assertion (packaged-tree == GITHUB_SHA), which cannot see that GITHUB_SHA itself trails the tip. (2) The "Post-release: Reset dev version" safety-net (#309/MokoCLI#900) no longer swallows failure: a failed dev-advance now emits ::error:: + a job-summary block (dev-channel would trail stable), and a SUCCESSFUL advance dispatches pre-release.yml (development) to actually publish the leading dev build -- the advance commit is [skip ci]+paths-ignored and never auto-fires. Stays green (continue-on-error) since stable already published. NEW (07.15.00): the rolling stable channel tag is now advanced via a git force-push BEFORE the #386 integrity gate. MokoGIT's tags API 405s on the release-attached `stable` tag, so release_publish.php could not move it ("Could not pin tag stable ... HTTP 405") and every Joomla/electron stable cut left the tag lagging the freshly-built commit -- red-failing a correct release on the integrity gate. A new step git-force-pushes refs/tags/stable to the built HEAD (force-push allowlist -- the same escape hatch the rc BRANCH pin already uses), so the gate passes and no manual tag repoint is needed. PREVIOUS (07.14.00): integrity gate #386 no longer false-fails. It compared the published tag against GITHUB_SHA (the triggering commit), but the publish step commits the CHANGELOG entry ("build NN.NN.NN [skip ci]") and mokocli pins the channel tag + semver tag to that POST-PUBLISH HEAD -- so every cut whose CHANGELOG wasn't pre-seeded on dev (e.g. a version set straight on the release branch) diverged by one commit and red-failed a correct release. The gate now compares against the actual built/published HEAD (git rev-parse HEAD, falling back to GITHUB_SHA), which equals what the tag is pinned to in both the commit and no-commit cases while still catching a tag pinned to a commit we did NOT build; the semver tag is pinned to the same built HEAD for consistency. PREVIOUS (07.13.00): RC is ON-DEMAND ONLY. The `pull_request` trigger (RC-preview on a PR to main/stable) is REMOVED and the promote-rc job now runs solely on workflow_dispatch action=promote-rc. Only dev + stable release automatically (dev via pre-release.yml push->dev; stable via the push->main release job here); alpha/beta/rc never auto-cut. PREVIOUS (07.12.00): electron repos create NO release on ANY channel (RC enabled=false + stable publish/tag/notes/integrity guarded on is_electron; opt back in with vars.ELECTRON_CI_BUILD=true) and build NO installer in CI — always built locally + uploaded manually; the dev/promote version BUMP still runs. Template-Generic #390. NEW (07.11.00): the electron installer build/attach steps (electron-builder via Wine/Xvfb) are gated to MANUAL workflow_dispatch only (`&& github.event_name == 'workflow_dispatch'`). An automatic push no longer auto-builds electron installers (heavy ~90m, runner-fragile — OOM/Wine-zombie; migrating to mokocli ElectronPackager, MokoCLI #756/#758). The version bump, CHANGELOG, tag and npm publish STILL run on push — only the electron installer is skipped and pushed MANUALLY via dispatch. Non-electron platforms (joomla/go/npm) unaffected. Template-Generic #390. Universal build & release - BUMP-ON-DEV: base version + CHANGELOG promoted on dev pre-promotion (promote-rc), main publishes --bump none --no-commit so it never diverges from dev. MokoCLI build agent (moko publish). NEW (07.06.00): npm stable publishing ABSORBED here from Template-NPM's retired npm-publish.yml as npm-gated dual-registry steps (MokoGIT registry via per-run token + npmjs.org best-effort), replacing the former npm-publish.yml dispatch. No-Makefile repos SKIP the release job gracefully (green) instead of failing. Conflict-marker scan reads git-tracked files only (git ls-files | xargs grep) so vendored/generated trees can't false-match. NEW (07.07.00): npm stamp normalizes the zero-padded Makefile PROJECT_VERSION (NN.NN.NN) to strict semver via awk before `npm version`, and an unstampable version now HARD-FAILS instead of silently keeping the old package.json version (#382). NEW (07.00.00): `moko assets:optimize` artifact-time step added before publish (graceful-skip on non-asset repos); Setup MokoCLI tools makes the fresh `moko` executable + first on PATH to fix exit-126 (mokocli #650).
|
|
12
12
|
#
|
|
13
13
|
# +=======================================================================+
|
|
14
14
|
# | UNIVERSAL BUILD & RELEASE PIPELINE |
|
|
@@ -1676,7 +1676,8 @@ jobs:
|
|
|
1676
1676
|
rm -f .npmrc; echo "$out"
|
|
1677
1677
|
if [ "$rc" -eq 0 ]; then echo "Published to npmjs.org."
|
|
1678
1678
|
elif printf '%s' "$out" | grep -qiE 'already exists|cannot publish over|EPUBLISHCONFLICT|409 Conflict'; then echo "::notice::npmjs.org already has this version - no-op."
|
|
1679
|
-
|
|
1679
|
+
elif printf '%s' "$out" | grep -qiE 'not in this registry|ENEEDAUTH|EOTP|need auth|E401|E403|401 Unauthorized|403 Forbidden|404 Not Found|Forbidden'; then echo "::error::npmjs.org publish FAILED — authorization error (npm exit ${rc}). NPM_TOKEN lacks publish/create rights on the @mokoconsulting scope (new-scoped-name first publish, an unauthorized token, or a revoked/expired token). Failing loud so a broken publish token cannot hide behind a green release — a 404 PUT is how npm masks an unauthorized scoped write."; exit "$rc"
|
|
1680
|
+
else echo "::warning::npmjs.org publish failed (npm exit ${rc}) — transient/5xx/network issue; npmjs is the secondary mirror (MokoGIT registry is primary), not failing the run."; fi
|
|
1680
1681
|
|
|
1681
1682
|
# GitHub mirroring (release assets + main branch) moved to an out-of-band
|
|
1682
1683
|
# OAuth push-mirror (Gitea repo setting) -- former Steps 9 & 10 removed.
|
package/CODE_OF_CONDUCT.md
CHANGED
package/CONTRIBUTING.md
CHANGED
package/Makefile
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
# Identity (auto-filled by workflow sync — the Makefile is the version source of truth)
|
|
11
11
|
# ==============================================================================
|
|
12
12
|
PROJECT_NAME := mokologs-mcp
|
|
13
|
-
PROJECT_VERSION := 04.00
|
|
13
|
+
PROJECT_VERSION := 04.02.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
|
|
26
|
+
VERSION: 04.02.00
|
|
27
27
|
BRIEF: Security vulnerability reporting and handling policy
|
|
28
28
|
-->
|
|
29
29
|
|
package/config.example.json
CHANGED
|
@@ -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": {
|
|
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
|
+
"version": "4.2.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": "
|
|
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';
|
|
@@ -68,6 +70,11 @@ const SourceSchema = z.object({
|
|
|
68
70
|
})
|
|
69
71
|
.refine((s) => s.type !== 'gitea' || !!s.baseUrl, {
|
|
70
72
|
message: 'a "gitea" source requires "baseUrl"',
|
|
73
|
+
})
|
|
74
|
+
// A `windows` source reads the LOCAL Event Log in this version (no remote transport).
|
|
75
|
+
// Its `logDirs` are interpreted as Event Log channel names (e.g. ["System","Application"]).
|
|
76
|
+
.refine((s) => s.type !== 'windows' || !s.ssh, {
|
|
77
|
+
message: 'a "windows" source is local-only in this version (no "ssh" block); put channel names in "logDirs"',
|
|
71
78
|
});
|
|
72
79
|
|
|
73
80
|
const RedactionSchema = z
|
|
@@ -114,10 +121,63 @@ export function resolveSecret(value: string | undefined): string | undefined {
|
|
|
114
121
|
return readFileSync(p, 'utf-8').trim();
|
|
115
122
|
}
|
|
116
123
|
|
|
117
|
-
/**
|
|
118
|
-
|
|
124
|
+
/** Context needed to warn + auto-migrate an inline token during a load. */
|
|
125
|
+
interface MigrateCtx {
|
|
126
|
+
/** Mutable parsed config object (rewritten in place + on disk on migration). */
|
|
127
|
+
rawObj: Record<string, unknown>;
|
|
128
|
+
/** On-disk config path to rewrite. */
|
|
129
|
+
configPath: string;
|
|
130
|
+
/** Shared one-time `.bak` flag. */
|
|
131
|
+
backup: { done: boolean };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Resolve a TOKEN field only, enforcing the no-inline-secrets policy:
|
|
136
|
+
* - `file:` ref → PATH-GUARDED read (§1b: within-$HOME, regular file,
|
|
137
|
+
* owner-only perms, ≤64 KiB) then the #1043 JSON ladder.
|
|
138
|
+
* - inline value → WARN + AUTO-MIGRATE to ~/.ssh/tokens/<slug>.token.json
|
|
139
|
+
* (0600), rewrite the config to a `file:` ref, then read it
|
|
140
|
+
* back via the guarded path. Migration failure → warn-only,
|
|
141
|
+
* use the inline value directly for this run.
|
|
142
|
+
*
|
|
143
|
+
* ONLY for token fields (notify.token, source.token) — NEVER for SSH
|
|
144
|
+
* keys/passphrases/passwords or the mysql password (those stay raw).
|
|
145
|
+
*
|
|
146
|
+
* @param plan JSON-path + slug/host metadata for a possible migration.
|
|
147
|
+
*/
|
|
148
|
+
function resolveTokenSecret(value: string | undefined, label: string, ctx: MigrateCtx, plan: Omit<MigrationPlan, 'value' | 'label'>): string | undefined {
|
|
149
|
+
if (value === undefined) return undefined;
|
|
150
|
+
|
|
151
|
+
// Already file-backed: guarded read + #1043 ladder. No migration, no warning.
|
|
152
|
+
if (isFileRef(value)) {
|
|
153
|
+
const raw = safeReadCredentialFile(value, label);
|
|
154
|
+
if (raw === undefined) return undefined; // path-safety skip already warned
|
|
155
|
+
return resolveTokenValue(raw, label).bearer || undefined;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Inline literal → warn + auto-migrate, then read back the migrated file.
|
|
159
|
+
const fileRef = migrateInlineToken(ctx.rawObj, ctx.configPath, { ...plan, value, label }, ctx.backup);
|
|
160
|
+
if (fileRef !== undefined) {
|
|
161
|
+
const raw = safeReadCredentialFile(fileRef, label);
|
|
162
|
+
if (raw !== undefined) return resolveTokenValue(raw, label).bearer || undefined;
|
|
163
|
+
// migrated file somehow unreadable — fall through to the inline value
|
|
164
|
+
}
|
|
165
|
+
// Migration failed (or migrated file unreadable): use the inline value this run.
|
|
166
|
+
return resolveTokenValue(value, label).bearer || undefined;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Resolve all `file:`-backed secrets on a source (returns a copy). The token
|
|
170
|
+
* field gets the policy treatment; SSH key/passphrase/password and the mysql
|
|
171
|
+
* password stay RAW via resolveSecret (unchanged). */
|
|
172
|
+
function resolveSourceSecrets(name: string, src: LogSource, ctx: MigrateCtx): LogSource {
|
|
119
173
|
const out: LogSource = { ...src };
|
|
120
|
-
if (out.token)
|
|
174
|
+
if (out.token) {
|
|
175
|
+
out.token = resolveTokenSecret(out.token, `source "${name}".token`, ctx, {
|
|
176
|
+
path: ['sources', name, 'token'],
|
|
177
|
+
slugBasis: hostOf(src.baseUrl) ?? name,
|
|
178
|
+
host: hostOf(src.baseUrl),
|
|
179
|
+
});
|
|
180
|
+
}
|
|
121
181
|
if (out.ssh) {
|
|
122
182
|
out.ssh = {
|
|
123
183
|
...out.ssh,
|
|
@@ -138,21 +198,29 @@ export async function loadConfig(): Promise<LogsConfig> {
|
|
|
138
198
|
: resolve(homedir(), CONFIG_FILENAME);
|
|
139
199
|
|
|
140
200
|
let parsed: z.infer<typeof ConfigSchema>;
|
|
201
|
+
let rawObj: Record<string, unknown>;
|
|
141
202
|
try {
|
|
142
203
|
const raw = await readFile(configPath, 'utf-8');
|
|
143
|
-
|
|
204
|
+
rawObj = JSON.parse(raw) as Record<string, unknown>;
|
|
205
|
+
parsed = ConfigSchema.parse(rawObj);
|
|
144
206
|
} catch (err) {
|
|
145
207
|
const message =
|
|
146
208
|
err instanceof z.ZodError ? err.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; ') : err instanceof Error ? err.message : String(err);
|
|
147
209
|
throw new Error(`Failed to load config from ${configPath}: ${message}\nCreate ${configPath} — see config.example.json for format.`);
|
|
148
210
|
}
|
|
149
211
|
|
|
212
|
+
// Shared migration context: inline TOKEN fields self-heal into ~/.ssh/tokens
|
|
213
|
+
// and the on-disk config is rewritten to file: refs (atomic, one 0600 .bak).
|
|
214
|
+
const ctx: MigrateCtx = { rawObj, configPath, backup: { done: false } };
|
|
215
|
+
|
|
150
216
|
const sources: Record<string, LogSource> = {};
|
|
151
217
|
for (const [name, src] of Object.entries(parsed.sources)) {
|
|
152
|
-
sources[name] = resolveSourceSecrets(src as LogSource);
|
|
218
|
+
sources[name] = resolveSourceSecrets(name, src as LogSource, ctx);
|
|
153
219
|
}
|
|
154
220
|
|
|
155
|
-
const notify = parsed.notify
|
|
221
|
+
const notify = parsed.notify
|
|
222
|
+
? { ...parsed.notify, token: resolveTokenSecret(parsed.notify.token, 'notify.token', ctx, { path: ['notify', 'token'], slugBasis: hostOf(parsed.notify.ntfyUrl) ?? 'ntfy', host: hostOf(parsed.notify.ntfyUrl) }) }
|
|
223
|
+
: undefined;
|
|
156
224
|
|
|
157
225
|
return {
|
|
158
226
|
sources,
|
package/source/reader.ts
CHANGED
|
@@ -22,12 +22,16 @@ import { gunzipSync } from 'node:zlib';
|
|
|
22
22
|
import { dirname, basename, join } from 'node:path';
|
|
23
23
|
import { Client } from 'ssh2';
|
|
24
24
|
import type { LogSource, ReadResult, RedactionPolicy } from './types.js';
|
|
25
|
+
import { winListLogs, winFetchEvents, winNewestRecordId } from './winlog.js';
|
|
25
26
|
|
|
26
27
|
export interface ReadCaps {
|
|
27
28
|
maxBytes: number;
|
|
28
29
|
maxLines: number;
|
|
29
30
|
}
|
|
30
31
|
|
|
32
|
+
/** Max Event Log records a single watch_log delta read pulls (bounded follow). */
|
|
33
|
+
const MAX_WIN_WATCH_EVENTS = 1000;
|
|
34
|
+
|
|
31
35
|
/**
|
|
32
36
|
* POSIX single-quote a value for safe interpolation into a remote shell command
|
|
33
37
|
* (ssh2's exec always runs through the login shell). Mirrors mokossh `shq()`:
|
|
@@ -327,6 +331,13 @@ export async function tail(
|
|
|
327
331
|
): Promise<ReadResult> {
|
|
328
332
|
const n = Math.min(opts.lines, opts.caps.maxLines);
|
|
329
333
|
|
|
334
|
+
// Windows Event Log (#14): `target` is a channel name; fetch the N newest events
|
|
335
|
+
// rendered one-per-line. Rotation does not apply (the Event Log has no .1/.gz).
|
|
336
|
+
if (source.type === 'windows') {
|
|
337
|
+
const win = await winFetchEvents(opts.target, n);
|
|
338
|
+
return toResult(sourceName, opts.target, win, { maxBytes: opts.caps.maxBytes, maxLines: n });
|
|
339
|
+
}
|
|
340
|
+
|
|
330
341
|
if (opts.includeRotated) {
|
|
331
342
|
const segs = await resolveRotatedFiles(source, opts.target); // oldest → newest
|
|
332
343
|
let combined = '';
|
|
@@ -358,6 +369,20 @@ export async function grep(
|
|
|
358
369
|
): Promise<ReadResult> {
|
|
359
370
|
const n = Math.min(opts.lines, opts.caps.maxLines);
|
|
360
371
|
|
|
372
|
+
// Windows Event Log (#14): fetch the recent events (bounded by maxLines) rendered
|
|
373
|
+
// one-per-line, then filter by the pattern on the rendered line. This is what lets
|
|
374
|
+
// get_errors / get_server_status match Level words (Error/Critical/Warning).
|
|
375
|
+
if (source.type === 'windows') {
|
|
376
|
+
const raw = await winFetchEvents(opts.target, opts.caps.maxLines);
|
|
377
|
+
const re = new RegExp(opts.pattern, opts.ignoreCase ? 'i' : undefined);
|
|
378
|
+
const matched = raw
|
|
379
|
+
.split(/\r?\n/)
|
|
380
|
+
.filter((l) => l && re.test(l))
|
|
381
|
+
.slice(-n)
|
|
382
|
+
.join('\n');
|
|
383
|
+
return toResult(sourceName, opts.target, matched, opts.caps);
|
|
384
|
+
}
|
|
385
|
+
|
|
361
386
|
if (opts.includeRotated) {
|
|
362
387
|
const segs = await resolveRotatedFiles(source, opts.target); // oldest → newest
|
|
363
388
|
let combined = '';
|
|
@@ -398,6 +423,9 @@ export interface LogFileInfo {
|
|
|
398
423
|
* `extensions` (default `.log`). Returns path/size/mtime, capped to `limit`.
|
|
399
424
|
*/
|
|
400
425
|
export async function listLogs(source: LogSource, caps: ReadCaps, limit = 200): Promise<LogFileInfo[]> {
|
|
426
|
+
// Windows Event Log (#14): the "files" are the channels this source reads.
|
|
427
|
+
if (source.type === 'windows') return winListLogs(source, limit);
|
|
428
|
+
|
|
401
429
|
const exts = source.extensions ?? ['.log'];
|
|
402
430
|
const dirs = source.logDirs ?? (source.ssh ? ['/var/log'] : ['.']);
|
|
403
431
|
const out: LogFileInfo[] = [];
|
|
@@ -439,6 +467,8 @@ export async function listLogs(source: LogSource, caps: ReadCaps, limit = 200):
|
|
|
439
467
|
|
|
440
468
|
/** Byte size of a file (local or remote); 0 when missing. */
|
|
441
469
|
export async function sizeOf(source: LogSource, path: string): Promise<number> {
|
|
470
|
+
// Windows Event Log (#14): the watch cursor is the newest RecordId (monotonic).
|
|
471
|
+
if (source.type === 'windows') return winNewestRecordId(path);
|
|
442
472
|
if (source.ssh) {
|
|
443
473
|
const out = await sshRun(source, `stat -c %s ${shq(path)} 2>/dev/null || wc -c < ${shq(path)} 2>/dev/null`, 64);
|
|
444
474
|
return parseInt(out.trim(), 10) || 0;
|
|
@@ -452,6 +482,9 @@ export async function sizeOf(source: LogSource, path: string): Promise<number> {
|
|
|
452
482
|
|
|
453
483
|
/** Read a file from byte `offset` to EOF, byte-capped (the watch_log delta read). */
|
|
454
484
|
export async function readFrom(source: LogSource, path: string, offset: number, maxBytes: number): Promise<string> {
|
|
485
|
+
// Windows Event Log (#14): `offset` is the last-seen RecordId cursor — return the
|
|
486
|
+
// rendered events newer than it (bounded). maxBytes is applied by the caller's cap.
|
|
487
|
+
if (source.type === 'windows') return winFetchEvents(path, MAX_WIN_WATCH_EVENTS, offset);
|
|
455
488
|
if (source.ssh) {
|
|
456
489
|
return sshRun(source, `tail -c +${offset + 1} ${shq(path)} 2>/dev/null | head -c ${maxBytes}`, maxBytes);
|
|
457
490
|
}
|
|
@@ -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
|
+
}
|
package/source/winlog.ts
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
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.WinLog
|
|
9
|
+
* INGROUP: mokologs-mcp
|
|
10
|
+
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokologs-mcp
|
|
11
|
+
* PATH: /source/winlog.ts
|
|
12
|
+
* VERSION: 01.00.00
|
|
13
|
+
* BRIEF: Windows Event Log source adapter (#14). Reads local Event Log channels
|
|
14
|
+
* (System/Application/Security/…) via PowerShell Get-WinEvent and renders
|
|
15
|
+
* each event as ONE line — `<iso> <Level> <Channel> <Provider>[<Id>]: <msg>`
|
|
16
|
+
* — so the generic reader tools (tail/grep/get_errors/get_server_status)
|
|
17
|
+
* work unchanged against it. Local Windows only in this version.
|
|
18
|
+
*
|
|
19
|
+
* SECURITY: every caller-supplied value (channel, max, cursor) is passed to
|
|
20
|
+
* PowerShell via the child ENVIRONMENT and referenced as a $env: variable in
|
|
21
|
+
* a FIXED script — never string-interpolated into the command — so a channel
|
|
22
|
+
* name cannot inject PowerShell. powershell.exe is resolved to an absolute
|
|
23
|
+
* path. Channel names are also charset-validated as defense in depth.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { execFile } from 'node:child_process';
|
|
27
|
+
import { promisify } from 'node:util';
|
|
28
|
+
import { statSync } from 'node:fs';
|
|
29
|
+
import { join, resolve as presolve } from 'node:path';
|
|
30
|
+
import type { LogSource } from './types.js';
|
|
31
|
+
import type { LogFileInfo } from './reader.js';
|
|
32
|
+
|
|
33
|
+
const execFileP = promisify(execFile);
|
|
34
|
+
const IS_WIN = process.platform === 'win32';
|
|
35
|
+
|
|
36
|
+
/** Event Log channels used when a windows source lists none (via `logDirs`). */
|
|
37
|
+
const DEFAULT_CHANNELS = ['System', 'Application', 'Security', 'Setup'];
|
|
38
|
+
|
|
39
|
+
/** Valid Event Log channel name (e.g. `System`, `Microsoft-Windows-Kernel-Power/Operational`). */
|
|
40
|
+
const CHANNEL_RE = /^[A-Za-z0-9 _.\-/%()]+$/;
|
|
41
|
+
|
|
42
|
+
const PS_TIMEOUT_MS = 30_000;
|
|
43
|
+
const MAX_EVENTS_CEILING = 5000;
|
|
44
|
+
|
|
45
|
+
function assertWindows(): void {
|
|
46
|
+
if (!IS_WIN) {
|
|
47
|
+
throw new Error('windows source: Event Log access requires the mokologs MCP to run on Windows (local only in this version).');
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The Event Log channels this source reads (charset-validated). */
|
|
52
|
+
export function channelsOf(source: LogSource): string[] {
|
|
53
|
+
const list = source.logDirs && source.logDirs.length ? source.logDirs : DEFAULT_CHANNELS;
|
|
54
|
+
return list.filter((c) => CHANNEL_RE.test(c));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Resolve powershell.exe to an ABSOLUTE path (canonical System32 first, then PATH:
|
|
58
|
+
// pwsh.exe / powershell.exe). Never run a bare name — avoids cwd/planted-binary pickup.
|
|
59
|
+
let cachedPwsh: string | null | undefined;
|
|
60
|
+
function resolvePowershell(): string {
|
|
61
|
+
if (cachedPwsh !== undefined) {
|
|
62
|
+
if (cachedPwsh) return cachedPwsh;
|
|
63
|
+
throw new Error('PowerShell not found.');
|
|
64
|
+
}
|
|
65
|
+
const isFile = (p: string): boolean => {
|
|
66
|
+
try { return statSync(p).isFile(); } catch { return false; }
|
|
67
|
+
};
|
|
68
|
+
const sysRoot = process.env.SystemRoot || process.env.windir || 'C:\\Windows';
|
|
69
|
+
const candidates = [join(sysRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe')];
|
|
70
|
+
for (const d of (process.env.PATH ?? '').split(';')) {
|
|
71
|
+
if (d && d.trim() && d !== '.') candidates.push(join(d, 'pwsh.exe'), join(d, 'powershell.exe'));
|
|
72
|
+
}
|
|
73
|
+
for (const c of candidates) {
|
|
74
|
+
if (isFile(c)) { cachedPwsh = presolve(c); return cachedPwsh; }
|
|
75
|
+
}
|
|
76
|
+
cachedPwsh = null;
|
|
77
|
+
throw new Error('PowerShell not found (looked for System32 powershell.exe and pwsh/powershell on PATH).');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Run a FIXED PowerShell script. Caller parameters are supplied via a minimal
|
|
82
|
+
* child environment (MLOG_* keys) and read inside the script as `$env:MLOG_*`
|
|
83
|
+
* variables — they are NEVER concatenated into the command string, so no value
|
|
84
|
+
* can inject PowerShell. A non-zero exit returns whatever stdout was produced.
|
|
85
|
+
*/
|
|
86
|
+
async function runPs(script: string, params: Record<string, string>): Promise<string> {
|
|
87
|
+
assertWindows();
|
|
88
|
+
const bin = resolvePowershell();
|
|
89
|
+
const env: NodeJS.ProcessEnv = {
|
|
90
|
+
SystemRoot: process.env.SystemRoot,
|
|
91
|
+
windir: process.env.windir,
|
|
92
|
+
PATH: process.env.PATH,
|
|
93
|
+
Path: process.env.Path,
|
|
94
|
+
PATHEXT: process.env.PATHEXT,
|
|
95
|
+
TEMP: process.env.TEMP,
|
|
96
|
+
TMP: process.env.TMP,
|
|
97
|
+
USERPROFILE: process.env.USERPROFILE,
|
|
98
|
+
...params,
|
|
99
|
+
};
|
|
100
|
+
try {
|
|
101
|
+
const { stdout } = await execFileP(bin, ['-NoProfile', '-NonInteractive', '-Command', script], {
|
|
102
|
+
env,
|
|
103
|
+
timeout: PS_TIMEOUT_MS,
|
|
104
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
105
|
+
windowsHide: true,
|
|
106
|
+
shell: false,
|
|
107
|
+
});
|
|
108
|
+
return stdout ?? '';
|
|
109
|
+
} catch (err) {
|
|
110
|
+
return (err as { stdout?: string }).stdout ?? '';
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Render N events (optionally only those newer than a RecordId cursor) as one
|
|
115
|
+
// line each, oldest→newest. Fixed script; channel/max/since arrive via $env.
|
|
116
|
+
const RENDER_SCRIPT = `
|
|
117
|
+
$ErrorActionPreference='SilentlyContinue'
|
|
118
|
+
$ch=$env:MLOG_CHANNEL
|
|
119
|
+
$max=[int]$env:MLOG_MAX
|
|
120
|
+
$since=$env:MLOG_SINCE
|
|
121
|
+
try { $ev = Get-WinEvent -LogName $ch -MaxEvents $max -ErrorAction Stop } catch { $ev=@() }
|
|
122
|
+
if ($since) { $sid=[int64]$since; $ev = $ev | Where-Object { $_.RecordId -gt $sid } }
|
|
123
|
+
$ev | Sort-Object RecordId | ForEach-Object {
|
|
124
|
+
$lvl=$_.LevelDisplayName; if(-not $lvl){$lvl='Information'}
|
|
125
|
+
$m=$_.Message; if(-not $m){$m=''}
|
|
126
|
+
$m=($m -replace '\\r?\\n',' ')
|
|
127
|
+
if($m.Length -gt 2000){$m=$m.Substring(0,2000)}
|
|
128
|
+
'{0} {1} {2} {3}[{4}]: {5}' -f $_.TimeCreated.ToString('s'), $lvl, $ch, $_.ProviderName, $_.Id, $m
|
|
129
|
+
}`;
|
|
130
|
+
|
|
131
|
+
/** Fetch rendered event lines for a channel (optionally since a RecordId cursor). */
|
|
132
|
+
export async function winFetchEvents(channel: string, maxEvents: number, sinceRecordId?: number): Promise<string> {
|
|
133
|
+
assertWindows();
|
|
134
|
+
if (!CHANNEL_RE.test(channel)) throw new Error(`windows source: invalid Event Log channel name "${channel}".`);
|
|
135
|
+
const max = Math.max(1, Math.min(Math.floor(maxEvents) || 1, MAX_EVENTS_CEILING));
|
|
136
|
+
const params: Record<string, string> = { MLOG_CHANNEL: channel, MLOG_MAX: String(max) };
|
|
137
|
+
if (sinceRecordId !== undefined && Number.isFinite(sinceRecordId)) params.MLOG_SINCE = String(Math.floor(sinceRecordId));
|
|
138
|
+
return runPs(RENDER_SCRIPT, params);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** List the source's Event Log channels as "files" (name, record count, newest write time). */
|
|
142
|
+
export async function winListLogs(source: LogSource, limit = 200): Promise<LogFileInfo[]> {
|
|
143
|
+
assertWindows();
|
|
144
|
+
const channels = channelsOf(source);
|
|
145
|
+
if (!channels.length) return [];
|
|
146
|
+
const script = `
|
|
147
|
+
$ErrorActionPreference='SilentlyContinue'
|
|
148
|
+
($env:MLOG_CHANNELS -split ';') | ForEach-Object {
|
|
149
|
+
if(-not $_){ return }
|
|
150
|
+
try {
|
|
151
|
+
$l = Get-WinEvent -ListLog $_ -ErrorAction Stop
|
|
152
|
+
$t = if($l.LastWriteTime){ $l.LastWriteTime.ToString('s') } else { '' }
|
|
153
|
+
'{0}\t{1}\t{2}' -f [int64]$l.RecordCount, $t, $l.LogName
|
|
154
|
+
} catch {}
|
|
155
|
+
}`;
|
|
156
|
+
const out = await runPs(script, { MLOG_CHANNELS: channels.join(';') });
|
|
157
|
+
const files: LogFileInfo[] = [];
|
|
158
|
+
for (const line of out.split(/\r?\n/)) {
|
|
159
|
+
if (!line.trim()) continue;
|
|
160
|
+
const [size, mtime, ...rest] = line.split('\t');
|
|
161
|
+
const path = rest.join('\t').trim();
|
|
162
|
+
if (path) files.push({ path, size: Number(size) || 0, mtime: (mtime || '').trim() });
|
|
163
|
+
}
|
|
164
|
+
return files.slice(0, limit);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Newest RecordId in a channel — the monotonic cursor used by watch_log. 0 when empty/absent. */
|
|
168
|
+
export async function winNewestRecordId(channel: string): Promise<number> {
|
|
169
|
+
assertWindows();
|
|
170
|
+
if (!CHANNEL_RE.test(channel)) return 0;
|
|
171
|
+
const out = await runPs(
|
|
172
|
+
`$ErrorActionPreference='SilentlyContinue'
|
|
173
|
+
try { $e = Get-WinEvent -LogName $env:MLOG_CHANNEL -MaxEvents 1 -ErrorAction Stop; if($e){ [int64]$e[0].RecordId } else { 0 } } catch { 0 }`,
|
|
174
|
+
{ MLOG_CHANNEL: channel },
|
|
175
|
+
);
|
|
176
|
+
return parseInt(out.trim(), 10) || 0;
|
|
177
|
+
}
|