Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -69,3 +80,45 @@ jobs:
shell: pwsh
run: |
./build/Build.Linux.ps1 -SeqDockerTag $env:SEQ_DOCKER_TAG

publish-npm:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should do this as a separate workflow file.

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
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/

Expand Down Expand Up @@ -296,3 +298,7 @@ global.json
.claude/
.qwen/
.agents/

# npm packaging staging area (build/Build.Npm.ps1)
npm-staging/
npm-archives/
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <command>` always runs the npm version.

To set a default server URL and API key, run:

```
Expand Down
9 changes: 9 additions & 0 deletions build/Build.Common.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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])"
}
250 changes: 250 additions & 0 deletions build/Build.Npm.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
# Publishes the npm packages for a seqcli build: one `@datalust/seqcli-<rid>` 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-<version>-<rid>.zip|.tar.gz archives; when omitted, the archives
# are downloaded from GitHub release v<version> 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-<version>-<rid>/` 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
33 changes: 33 additions & 0 deletions npm/README.md
Original file line number Diff line number Diff line change
@@ -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 <command>` 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.

Loading