diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dea0c78e..53907f7b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,6 +42,17 @@ jobs: shell: pwsh run: | ./build/Build.Windows.ps1 + - name: Upload archives + # Consumed by the publish-npm job + if: github.event_name != 'pull_request' + uses: actions/upload-artifact@v7 + with: + name: archives + path: | + artifacts/seqcli-*-*.zip + artifacts/seqcli-*-*.tar.gz + if-no-files-found: error + retention-days: 1 build-linux: name: Build (Linux) @@ -69,3 +80,45 @@ jobs: shell: pwsh run: | ./build/Build.Linux.ps1 -SeqDockerTag $env:SEQ_DOCKER_TAG + + publish-npm: + name: Publish (npm) + runs-on: ubuntu-24.04 + needs: build-windows + + # Mirrors NuGet publishing: builds from any branch this workflow targets (dev builds as + # prereleases under the `dev` dist-tag, main builds as `latest`), but never pull requests. + if: github.event_name != 'pull_request' + + permissions: + contents: read + # Required for npm trusted publishing (OIDC) and provenance + id-token: write + + steps: + - uses: actions/checkout@v6 + - name: Setup + uses: actions/setup-node@v7 + with: + node-version: 24.x + # Bootstrap only: together with NODE_AUTH_TOKEN below, authenticates using the NPM_TOKEN + # secret. Once trusted publishing is configured for every @datalust/seqcli* package + # (bound to this workflow file, ci.yml), remove `registry-url` here and `NODE_AUTH_TOKEN` + # below so that npm authenticates with the OIDC token instead. + registry-url: https://registry.npmjs.org/ + - name: Update npm + # Trusted publishing and automatic provenance require npm 11.5.1 or later + run: | + npm install -g npm@latest + npm --version + - name: Download archives + uses: actions/download-artifact@v8 + with: + name: archives + path: npm-archives + - name: Publish + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + shell: pwsh + run: | + ./build/Build.Npm.ps1 -ArchiveDir ./npm-archives diff --git a/.gitignore b/.gitignore index 4e050436..5cbf57dc 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,8 @@ x64/ x86/ bld/ [Bb]in/ +# The npm launcher package keeps its script in bin/ +!npm/seqcli/bin/ [Oo]bj/ [Ll]og/ @@ -296,3 +298,7 @@ global.json .claude/ .qwen/ .agents/ + +# npm packaging staging area (build/Build.Npm.ps1) +npm-staging/ +npm-archives/ diff --git a/README.md b/README.md index 1213183d..5a9eebd7 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,14 @@ The Seq installer for Windows includes `seqcli`. Otherwise, download the [releas dotnet tool install --global seqcli ``` +With Node.js installed, `seqcli` can be installed from npm using: + +``` +npm install -g @datalust/seqcli +``` + +On Windows, if the Seq installation directory is on your `PATH`, the `seqcli` bundled with Seq may take precedence over the npm-installed copy; `where seqcli` shows the resolution order, and `npx @datalust/seqcli ` always runs the npm version. + To set a default server URL and API key, run: ``` diff --git a/build/Build.Common.ps1 b/build/Build.Common.ps1 index 7d9e97a1..7abf7b7d 100644 --- a/build/Build.Common.ps1 +++ b/build/Build.Common.ps1 @@ -12,3 +12,12 @@ function Get-SemVer() $base + "." + $revision } } + +function Get-NpmVersion($version) +{ + # npm requires strict semver, which forbids leading zeros in numeric identifiers; the build number + # is zero-padded (e.g. 2026.1.02616), so strip the padding from the patch component (-> 2026.1.2616). + # Prerelease suffixes are alphanumeric identifiers and are left as-is. + if ($version -notmatch '^(\d+)\.(\d+)\.(\d+)(.*)$') { throw "Unrecognized version: $version" } + "$([int]$Matches[1]).$([int]$Matches[2]).$([int]$Matches[3])$($Matches[4])" +} diff --git a/build/Build.Npm.ps1 b/build/Build.Npm.ps1 new file mode 100644 index 00000000..3c5d3453 --- /dev/null +++ b/build/Build.Npm.ps1 @@ -0,0 +1,250 @@ +# Publishes the npm packages for a seqcli build: one `@datalust/seqcli-` package per release +# archive, then the launcher package `@datalust/seqcli` (from ./npm/seqcli) with its +# optionalDependencies pinned to the same version. +# +# In CI (see publish-npm in .github/workflows/ci.yml) the archives come from the build-windows job's +# artifacts, so dev builds are published as prereleases (dist-tag `dev`) and main builds as `latest`, +# matching NuGet. Packages that already exist on the registry at the target version are skipped, so +# a partially-failed run can simply be re-run. +# +# Usage: +# ./build/Build.Npm.ps1 -ArchiveDir ./npm-archives # CI: version from Get-SemVer +# ./build/Build.Npm.ps1 -Version 2026.1.02616 # (re)publish GitHub release v2026.1.02616 +# ./build/Build.Npm.ps1 -Version 2026.1.02616 -ArchiveDir ./x -DryRun # stage and `npm pack` only +param( + # Build version as it appears in archive names, e.g. 2026.1.02616 or 2026.1.02700-dev-02700. + # Defaults to Get-SemVer, which in CI reproduces the version computed by the build jobs. + [string] $Version, + + # npm dist-tag; defaults to `latest` for release versions and `dev` for prereleases. + [string] $DistTag, + + # Directory containing seqcli--.zip|.tar.gz archives; when omitted, the archives + # are downloaded from GitHub release v with `gh release download`. + [string] $ArchiveDir, + + # GitHub repository to download release assets from. Also identifies the repository whose CI + # publishes via npm trusted publishing (forks without an NPM_TOKEN skip publishing). + [string] $Repo = 'datalust/seqcli', + + # Stage the packages and run `npm pack` instead of `npm publish`. + [switch] $DryRun +) + +Push-Location $PSScriptRoot/../ + +. ./build/Build.Common.ps1 + +$ErrorActionPreference = 'Stop' + +$scope = '@datalust' +$launcherName = "$scope/seqcli" +$staging = './npm-staging' + +if (-not $Version) { + $Version = Get-SemVer +} + +$npmVersion = Get-NpmVersion $Version + +if (-not $DistTag) { + $DistTag = @{ $true = 'dev'; $false = 'latest' }[$npmVersion.Contains('-')] +} + +Write-Host "Release version: $Version" +Write-Host "npm version: $npmVersion" +Write-Host "npm dist-tag: $DistTag" +Write-Host "Dry run: $DryRun" + +if (-not $DryRun -and -not $env:NODE_AUTH_TOKEN -and $env:GITHUB_REPOSITORY -ne $Repo) { + # Forks have neither the NPM_TOKEN secret nor a trusted publisher configuration. + Write-Host "Skipping npm publishing: no npm credentials are available in this environment" + Pop-Location + exit 0 +} + +function Get-Rids +{ + $([xml](Get-Content ./src/SeqCli/SeqCli.csproj)).Project.PropertyGroup.RuntimeIdentifiers.Split(';') +} + +function Get-PlatformSpec($rid) +{ + $os = switch -Wildcard ($rid) { + 'win-*' { 'win32' } + 'osx-*' { 'darwin' } + 'linux-*' { 'linux' } + default { throw "Unrecognized RID: $rid" } + } + + $cpu = ($rid -split '-')[-1] + + $libc = $null + if ($rid -like 'linux-musl-*') { $libc = 'musl' } + elseif ($rid -like 'linux-*') { $libc = 'glibc' } + + return @{ os = $os; cpu = $cpu; libc = $libc; isWindows = ($os -eq 'win32') } +} + +function Get-ReleaseArchive($rid) +{ + $pattern = "seqcli-$Version-$rid.*" + + if ($ArchiveDir) { + $archive = Get-ChildItem -Path $ArchiveDir -Filter $pattern | Select-Object -First 1 + if (-not $archive) { throw "No archive matching $pattern in $ArchiveDir" } + return $archive.FullName + } + + $downloads = "$staging/download" + New-Item -ItemType Directory -Force -Path $downloads | Out-Null + + & gh release download "v$Version" --repo $Repo --dir $downloads --pattern $pattern --clobber + if ($LASTEXITCODE -ne 0) { throw "Downloading $pattern from release v$Version failed" } + + $archive = Get-ChildItem -Path $downloads -Filter $pattern | Select-Object -First 1 + if (-not $archive) { throw "Release v$Version has no asset matching $pattern" } + return $archive.FullName +} + +function Expand-ReleaseArchive($archive, $destination) +{ + if (Test-Path $destination) { Remove-Item -Recurse -Force $destination } + New-Item -ItemType Directory -Force -Path $destination | Out-Null + + if ($archive -like '*.zip') { + Expand-Archive -Path $archive -DestinationPath $destination -Force + } else { + & tar -xzf $archive -C $destination + if ($LASTEXITCODE -ne 0) { throw "Extracting $archive failed" } + } + + # The archives contain a single `seqcli--/` root folder; the package needs the + # binary at its root, so lift the contents up one level. + $entries = @(Get-ChildItem -Force $destination) + if ($entries.Count -eq 1 -and $entries[0].PSIsContainer) { + $root = $entries[0].FullName + Get-ChildItem -Force $root | Move-Item -Destination $destination + Remove-Item -Force $root + } +} + +function Write-PlatformPackageJson($rid, $spec, $destination) +{ + $package = Get-Content ./npm/platform-package.json -Raw | ConvertFrom-Json -AsHashtable + $package.name = "$scope/seqcli-$rid" + $package.version = $npmVersion + $package.description = $package.description.Replace('{{rid}}', $rid) + $package.os = @($spec.os) + $package.cpu = @($spec.cpu) + if ($spec.libc) { $package.libc = @($spec.libc) } + + $package | ConvertTo-Json -Depth 5 | Set-Content -Path "$destination/package.json" -NoNewline +} + +function Test-NpmPublished($name) +{ + $output = & npm view "$name@$npmVersion" version --json 2>$null + return ($LASTEXITCODE -eq 0) -and -not [string]::IsNullOrWhiteSpace(($output -join '')) +} + +function Publish-NpmPackage($name, $directory) +{ + if ($DryRun) { + Write-Host "Packing $name@$npmVersion" + $tarballs = "$staging/tarballs" + New-Item -ItemType Directory -Force -Path $tarballs | Out-Null + & npm pack $directory --pack-destination $tarballs + if ($LASTEXITCODE -ne 0) { throw "Packing $name failed" } + return + } + + if (Test-NpmPublished $name) { + Write-Host "Skipping $name@$npmVersion; already published" + return + } + + Write-Host "Publishing $name@$npmVersion with dist-tag $DistTag" + $arguments = @('publish', $directory, '--access', 'public', '--tag', $DistTag) + if ($env:GITHUB_ACTIONS -eq 'true') { $arguments += '--provenance' } + & npm @arguments + if ($LASTEXITCODE -ne 0) { throw "Publishing $name failed" } +} + +function Assert-NpmPublished($name) +{ + # The registry can take a moment to reflect a new version. + for ($attempt = 1; $attempt -le 6; $attempt++) { + if (Test-NpmPublished $name) { return } + Start-Sleep -Seconds 5 + } + throw "$name@$npmVersion is not visible on the registry" +} + +function Stage-PlatformPackage($rid) +{ + $spec = Get-PlatformSpec $rid + $directory = "$staging/seqcli-$rid" + + $archive = Get-ReleaseArchive $rid + Write-Host "Staging $scope/seqcli-$rid from $archive" + Expand-ReleaseArchive $archive $directory + + $binary = Join-Path $directory $(if ($spec.isWindows) { 'seqcli.exe' } else { 'seqcli' }) + if (-not (Test-Path $binary)) { throw "Expected $binary in $archive" } + + if (-not $spec.isWindows) { + & chmod +x $binary + if ($LASTEXITCODE -ne 0) { throw "chmod failed for $binary" } + } + + Write-PlatformPackageJson $rid $spec $directory + + return $directory +} + +function Stage-LauncherPackage($rids) +{ + $directory = "$staging/seqcli" + if (Test-Path $directory) { Remove-Item -Recurse -Force $directory } + Copy-Item -Recurse ./npm/seqcli $directory + + $package = Get-Content "$directory/package.json" -Raw | ConvertFrom-Json -AsHashtable + $package.version = $npmVersion + $package.optionalDependencies = [ordered]@{} + foreach ($rid in $rids) { + $package.optionalDependencies["$scope/seqcli-$rid"] = $npmVersion + } + + $package | ConvertTo-Json -Depth 5 | Set-Content -Path "$directory/package.json" -NoNewline + + return $directory +} + +if (Test-Path $staging) { Remove-Item -Recurse -Force $staging } +New-Item -ItemType Directory -Force -Path $staging | Out-Null + +$rids = Get-Rids + +foreach ($rid in $rids) { + $directory = Stage-PlatformPackage $rid + Publish-NpmPackage "$scope/seqcli-$rid" $directory +} + +if (-not $DryRun) { + # Never expose a launcher whose optional dependencies can't all be resolved. + foreach ($rid in $rids) { + Assert-NpmPublished "$scope/seqcli-$rid" + } +} + +$launcherDirectory = Stage-LauncherPackage $rids +Publish-NpmPackage $launcherName $launcherDirectory + +if (-not $DryRun) { + Assert-NpmPublished $launcherName + & npm view $launcherName dist-tags + Write-Host "Install with: npm install -g $launcherName@$npmVersion" +} + +Pop-Location diff --git a/npm/README.md b/npm/README.md new file mode 100644 index 00000000..fca21e72 --- /dev/null +++ b/npm/README.md @@ -0,0 +1,33 @@ +## How the package works + +`@datalust/seqcli` is a small launcher. The self-contained `seqcli` binary for your platform is installed alongside it as an optional dependency from one of these packages: + +| Package | Platform | +|---|---| +| `@datalust/seqcli-win-x64` | Windows x64 | +| `@datalust/seqcli-win-arm64` | Windows ARM64 | +| `@datalust/seqcli-osx-x64` | macOS x64 | +| `@datalust/seqcli-osx-arm64` | macOS ARM64 (Apple Silicon) | +| `@datalust/seqcli-linux-x64` | Linux x64 (glibc) | +| `@datalust/seqcli-linux-arm64` | Linux ARM64 (glibc) | +| `@datalust/seqcli-linux-musl-x64` | Linux x64 (musl, e.g. Alpine) | +| `@datalust/seqcli-linux-musl-arm64` | Linux ARM64 (musl, e.g. Alpine) | + +The binaries are byte-for-byte the ones attached to the matching [GitHub release](https://github.com/datalust/seqcli/releases). Because the .NET runtime is bundled, no `dotnet` installation is needed. Each platform package is roughly 45 MB to download and 120 MB on disk. + +Do not install with `--omit=optional` (or `--no-optional`): the platform package would be skipped and `seqcli` would fail to start with a message explaining how to fix it. + +## Alpine Linux + +The musl builds need the ICU globalization libraries, which minimal Alpine images don't include: either `apk add icu-libs`, or set `DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1` to run without them. + +## Versions + +npm versions are the GitHub release versions with leading zeros removed from the last component, because npm requires strict semantic versioning. For example, release `v2026.1.02616` is published to npm as `2026.1.2616`. Prerelease builds are published under the `dev` dist-tag. + +## Notes for Windows users + +The Seq installer for Windows also installs `seqcli.exe`, into `C:\Program Files\Seq`. If that directory is on your `PATH` ahead of npm's global bin directory (`%APPDATA%\npm`), running `seqcli` will use the copy bundled with Seq rather than the one installed by npm. Run `where seqcli` to see which copies are found and in what order; `npx @datalust/seqcli ` always runs the npm-installed version. Both copies share the same `SeqCli.json` configuration. + +Do not use the npm package to host the `seqcli forwarder` Windows service. The service registers the path of the executable that installed it, and npm replaces the installed files on every upgrade. Use the Seq installer or a release archive from GitHub instead. + diff --git a/npm/platform-package.json b/npm/platform-package.json new file mode 100644 index 00000000..eee4c44a --- /dev/null +++ b/npm/platform-package.json @@ -0,0 +1,24 @@ +{ + "name": "{{name}}", + "version": "{{version}}", + "description": "seqcli binaries for {{rid}}. Install @datalust/seqcli instead of depending on this package directly.", + "license": "Apache-2.0", + "homepage": "https://github.com/datalust/seqcli", + "repository": { + "type": "git", + "url": "git+https://github.com/datalust/seqcli.git" + }, + "engines": { + "node": ">=18" + }, + "os": [ + "{{os}}" + ], + "cpu": [ + "{{cpu}}" + ], + "preferUnplugged": true, + "publishConfig": { + "access": "public" + } +} diff --git a/npm/seqcli/bin/seqcli.js b/npm/seqcli/bin/seqcli.js new file mode 100755 index 00000000..d7078783 --- /dev/null +++ b/npm/seqcli/bin/seqcli.js @@ -0,0 +1,147 @@ +#!/usr/bin/env node +'use strict'; + +// Launcher for the platform-specific seqcli binary. The binary itself ships in one of the +// `@datalust/seqcli-` packages, installed as an optional dependency of `@datalust/seqcli` +// and selected by npm using the `os`/`cpu`/`libc` fields in each package. + +const { spawn } = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const SCOPE = '@datalust'; +const RELEASES_URL = 'https://github.com/datalust/seqcli/releases'; + +// `${process.platform}-${process.arch}` (with `-musl` inserted for musl-based Linux) -> .NET RID. +const RIDS = { + 'win32-x64': 'win-x64', + 'win32-arm64': 'win-arm64', + 'darwin-x64': 'osx-x64', + 'darwin-arm64': 'osx-arm64', + 'linux-x64': 'linux-x64', + 'linux-arm64': 'linux-arm64', + 'linux-musl-x64': 'linux-musl-x64', + 'linux-musl-arm64': 'linux-musl-arm64', +}; + +function isMusl() { + try { + return !process.report.getReport().header.glibcVersionRuntime; + } catch { + return false; + } +} + +// Candidate platform packages in order of preference. On Linux the detected libc variant is tried +// first, then the other one, so that package managers that ignore the `libc` field (and therefore +// install both) still run the right binary. +function candidatePackages() { + const { platform, arch } = process; + const keys = []; + if (platform === 'linux') { + const musl = isMusl(); + keys.push(musl ? `linux-musl-${arch}` : `linux-${arch}`); + keys.push(musl ? `linux-${arch}` : `linux-musl-${arch}`); + } else { + keys.push(`${platform}-${arch}`); + } + return keys.filter((k) => RIDS[k]).map((k) => `${SCOPE}/seqcli-${RIDS[k]}`); +} + +function locateBinary() { + const launcherVersion = require('../package.json').version; + const candidates = candidatePackages(); + const problems = []; + + for (const name of candidates) { + let packageJsonPath; + try { + packageJsonPath = require.resolve(`${name}/package.json`); + } catch { + problems.push(`${name} is not installed`); + continue; + } + + const installedVersion = require(packageJsonPath).version; + if (installedVersion !== launcherVersion) { + problems.push(`${name}@${installedVersion} does not match ${SCOPE}/seqcli@${launcherVersion}`); + continue; + } + + const exe = path.join(path.dirname(packageJsonPath), process.platform === 'win32' ? 'seqcli.exe' : 'seqcli'); + if (!fs.existsSync(exe)) { + problems.push(`${name} is installed but ${exe} is missing`); + continue; + } + + return exe; + } + + const lines = []; + if (candidates.length === 0) { + lines.push(`seqcli: ${process.platform}-${process.arch} is not supported by the npm package.`); + } else { + lines.push('seqcli: could not find the platform-specific seqcli package.'); + for (const p of problems) lines.push(` - ${p}`); + lines.push(''); + lines.push(`Reinstall with: npm install -g ${SCOPE}/seqcli@${launcherVersion}`); + lines.push('(optional dependencies must not be omitted; check for --omit=optional / --no-optional)'); + lines.push(`or install the platform package directly: npm install -g ${candidates[0]}@${launcherVersion}`); + } + lines.push(''); + lines.push(`Supported platforms: ${Object.values(RIDS).join(', ')}.`); + lines.push(`Other downloads: ${RELEASES_URL}`); + console.error(lines.join('\n')); + process.exit(1); +} + +function ensureExecutable(exe) { + if (process.platform === 'win32') return; + try { + fs.accessSync(exe, fs.constants.X_OK); + } catch { + try { + fs.chmodSync(exe, 0o755); + } catch { + // Reported by spawn() as EACCES below. + } + } +} + +function run() { + const exe = locateBinary(); + ensureExecutable(exe); + + const child = spawn(exe, process.argv.slice(2), { stdio: 'inherit', windowsHide: true }); + + // Ctrl+C is delivered by the terminal to the whole foreground process group (or console on + // Windows), so the child already receives it. Ignore it here so this process outlives the + // child and can report the child's exit status. + process.on('SIGINT', () => {}); + + // Signals from supervisors (kill, systemd, CI cancellation) target this process only; forward them. + for (const signal of ['SIGTERM', 'SIGHUP']) { + process.on(signal, () => child.kill(signal)); + } + + child.on('error', (err) => { + console.error(`seqcli: failed to start ${exe}: ${err.message}`); + process.exit(1); + }); + + child.on('exit', (code, signal) => { + if (signal) { + process.removeAllListeners(signal); + try { + process.kill(process.pid, signal); + } catch { + // Fall through to a conventional exit code. + } + process.exit(128 + (os.constants.signals[signal] || 0)); + } + process.exit(code === null ? 1 : code); + }); +} + +run(); diff --git a/npm/seqcli/package.json b/npm/seqcli/package.json new file mode 100644 index 00000000..fbd36399 --- /dev/null +++ b/npm/seqcli/package.json @@ -0,0 +1,50 @@ +{ + "name": "@datalust/seqcli", + "//version": "These version are replaced during the deployment process", + "version": "0.0.0", + "description": "The Seq command-line client. Administer, log, ingest, search, from any OS.", + "keywords": [ + "seq", + "seqcli", + "datalust", + "logging", + "structured-logging", + "cli" + ], + "scripts": { + "prepack": "cp ../../README.md ./README.md && cp ../../LICENSE ./LICENSE", + "postpack": "rm ./README.md ./LICENSE" + }, + "license": "Apache-2.0", + "homepage": "https://github.com/datalust/seqcli", + "repository": { + "type": "git", + "url": "git+https://github.com/datalust/seqcli.git" + }, + "bugs": { + "url": "https://github.com/datalust/seqcli/issues" + }, + "bin": { + "seqcli": "bin/seqcli.js" + }, + "files": [ + "bin", + "README.md" + ], + "engines": { + "node": ">=18" + }, + "publishConfig": { + "access": "public" + }, + "optionalDependencies": { + "@datalust/seqcli-win-x64": "0.0.0", + "@datalust/seqcli-win-arm64": "0.0.0", + "@datalust/seqcli-linux-x64": "0.0.0", + "@datalust/seqcli-linux-arm64": "0.0.0", + "@datalust/seqcli-linux-musl-x64": "0.0.0", + "@datalust/seqcli-linux-musl-arm64": "0.0.0", + "@datalust/seqcli-osx-x64": "0.0.0", + "@datalust/seqcli-osx-arm64": "0.0.0" + } +} diff --git a/src/SeqCli/Mcp/McpServerInstaller.cs b/src/SeqCli/Mcp/McpServerInstaller.cs index 1160aa33..8ada8823 100644 --- a/src/SeqCli/Mcp/McpServerInstaller.cs +++ b/src/SeqCli/Mcp/McpServerInstaller.cs @@ -15,6 +15,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using Newtonsoft.Json.Linq; using Serilog; @@ -121,9 +122,11 @@ public static void Install(string? agent, bool global, string? profileName = nul root[target.ServerMapKey] = serverMap; } + var (command, leadingArgs) = ResolveCommand(); + // A connection profile is the only connection setting we propagate; the server URL and // API key are resolved from config at runtime so they're not baked into the agent's file. - var args = new JArray("mcp", "run"); + var args = new JArray(leadingArgs.Concat(["mcp", "run"]).ToArray()); if (profileName != null) { args.Add("--profile"); @@ -132,7 +135,7 @@ public static void Install(string? agent, bool global, string? profileName = nul serverMap[ServerName] = new JObject { - ["command"] = "seqcli", + ["command"] = command, ["args"] = args, }; @@ -146,6 +149,60 @@ public static void Install(string? agent, bool global, string? profileName = nul Log.Information("Installed Seq MCP server for {Agent} to {Path}", agent, path); } + // Agents resolve `seqcli` from PATH when they start the server. On Windows, an npm-installed + // `seqcli` is a `seqcli.cmd` shim, which hosts that spawn processes without a shell can't run + // directly, so in that case the server is launched through `cmd /c` instead. + static (string Command, string[] LeadingArgs) ResolveCommand() => + ResolveCommand( + OperatingSystem.IsWindows(), + Environment.GetEnvironmentVariable("PATH"), + Environment.GetEnvironmentVariable("PATHEXT"), + File.Exists); + + internal static (string Command, string[] LeadingArgs) ResolveCommand( + bool isWindows, + string? path, + string? pathExt, + Func fileExists) + { + if (!isWindows) + return ("seqcli", []); + + var found = FindOnWindowsPath("seqcli", path, pathExt, fileExists); + if (found == null) + return ("seqcli", []); + + var extension = Path.GetExtension(found); + if (extension.Equals(".cmd", StringComparison.OrdinalIgnoreCase) || + extension.Equals(".bat", StringComparison.OrdinalIgnoreCase)) + { + Log.Information("Found `seqcli` on PATH as {ShimPath}; the MCP server will be launched via `cmd /c`", found); + return ("cmd", ["/c", "seqcli"]); + } + + return ("seqcli", []); + } + + // Mirrors how Windows locates a command: each PATH directory in turn, trying the PATHEXT + // extensions in order within it. + static string? FindOnWindowsPath(string name, string? path, string? pathExt, Func fileExists) + { + var extensions = (pathExt is { Length: > 0 } ? pathExt : ".COM;.EXE;.BAT;.CMD") + .Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + foreach (var directory in (path ?? "").Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + foreach (var extension in extensions) + { + var candidate = Path.Combine(directory, name + extension); + if (fileExists(candidate)) + return candidate; + } + } + + return null; + } + static AgentTarget Unsupported(string message) => new(_ => throw new NotSupportedException(message), "mcpServers"); diff --git a/test/SeqCli.Tests/Mcp/McpServerInstallerTests.cs b/test/SeqCli.Tests/Mcp/McpServerInstallerTests.cs new file mode 100644 index 00000000..b6334c5f --- /dev/null +++ b/test/SeqCli.Tests/Mcp/McpServerInstallerTests.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Generic; +using SeqCli.Mcp; +using Xunit; + +namespace SeqCli.Tests.Mcp; + +public class McpServerInstallerTests +{ + // Candidate paths are built with Path.Combine, which uses the host's separator; normalize so the + // Windows-style expectations hold when the tests run on Linux or macOS. + static Func FileSystemWith(params string[] files) + { + var set = new HashSet(files, StringComparer.OrdinalIgnoreCase); + return candidate => set.Contains(candidate.Replace('/', '\\')); + } + + [Fact] + public void OnNonWindowsPlatformsSeqCliIsLaunchedDirectly() + { + var (command, leadingArgs) = McpServerInstaller.ResolveCommand( + false, "/usr/local/bin:/usr/bin", null, _ => true); + + Assert.Equal("seqcli", command); + Assert.Empty(leadingArgs); + } + + [Fact] + public void OnWindowsAnExecutableOnPathIsLaunchedDirectly() + { + var (command, leadingArgs) = McpServerInstaller.ResolveCommand( + true, + @"C:\Program Files\Seq;C:\Users\me\AppData\Roaming\npm", + ".COM;.EXE;.BAT;.CMD", + FileSystemWith(@"C:\Program Files\Seq\seqcli.exe", @"C:\Users\me\AppData\Roaming\npm\seqcli.cmd")); + + Assert.Equal("seqcli", command); + Assert.Empty(leadingArgs); + } + + [Fact] + public void OnWindowsAnNpmShimOnPathIsLaunchedViaCmd() + { + var (command, leadingArgs) = McpServerInstaller.ResolveCommand( + true, + @"C:\Users\me\AppData\Roaming\npm;C:\Program Files\Seq", + ".COM;.EXE;.BAT;.CMD", + FileSystemWith(@"C:\Users\me\AppData\Roaming\npm\seqcli.cmd", @"C:\Program Files\Seq\seqcli.exe")); + + Assert.Equal("cmd", command); + Assert.Equal(["/c", "seqcli"], leadingArgs); + } + + [Fact] + public void OnWindowsWhenSeqCliIsNotOnPathItIsLaunchedDirectly() + { + var (command, leadingArgs) = McpServerInstaller.ResolveCommand( + true, @"C:\Windows\system32", null, _ => false); + + Assert.Equal("seqcli", command); + Assert.Empty(leadingArgs); + } + + [Fact] + public void PathExtDefaultsAreUsedWhenTheVariableIsMissing() + { + var (command, _) = McpServerInstaller.ResolveCommand( + true, + @"C:\Users\me\AppData\Roaming\npm", + null, + FileSystemWith(@"C:\Users\me\AppData\Roaming\npm\seqcli.cmd")); + + Assert.Equal("cmd", command); + } +}