Skip to content
Merged
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
32 changes: 32 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,38 @@ Each entry starts with a **plain-language summary** (what changed, in
everyday words) before any technical detail — written so someone outside
engineering can understand what shipped and why it matters.

## [0.7.17] - 2026-08-31

**In plain terms:** three small fixes reported by the same operator in one
debug session. The Windows users got a one-liner installer that matches
the bash experience.

### Fixed

- **`deltix diff` crashed with `TypeError: rows.reduce is not a function`**
on every call. The server returns `{ fromRef, toRef, tables: [...] }`;
the CLI passed `diff` directly to `printTable`, which then called
`.reduce()` on an object. Now passes `diff.tables`.
- **`deltix log -n 5` mis-parsed `-n` as the repo name.** The flag
helper `parseFlagValue` only matched `--name=value`, not the space-
separated forms (`--name value`, `-n 5`). Rewrote it to accept all
three; `-n 5` and `--branch main` and `--limit=5` all work now.
`flagValue` for `--password=` and `--from=` already supported the
space-separated forms; brought everything into one consistent helper.
Added 7 unit tests covering all three forms.

### Added

- **Windows installer: `scripts/get-deltix-client.ps1`.** The PowerShell
equivalent of the bash installer: detects AMD64/ARM64, downloads the
matching asset, SHA-256-verifies against the GitHub-published digest
(via `Get-FileHash`), and installs to `$HOME\.local\bin\deltix.exe`
(or `C:\Program Files\Deltix` with `-System`, which auto-elevates via
`runas`). Honors the same `VERSION=` and `INSTALL_DIR=` env vars as the
bash script. The README's Windows section now shows this as the
recommended path ahead of Scoop, and the script itself ships with full
PowerShell comment-based help (`Get-Help .\get-deltix-client.ps1`).

## [0.7.16] - 2026-08-31

**In plain terms:** the help text now tells you `[<repo>]` is optional
Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,24 @@ including Arch):
curl -fsSL https://raw.githubusercontent.com/SammyBytes/Deltix-Client/main/scripts/get-deltix-client.sh | bash
```

Windows (PowerShell one-liner, no admin required):

```powershell
# Latest release, install to $HOME\.local\bin\deltix.exe
iex "& { $(irm https://raw.githubusercontent.com/SammyBytes/Deltix-Client/main/scripts/get-deltix-client.ps1) }"

# Pin a version
$env:VERSION = '0.7.17'
iex "& { $(irm https://raw.githubusercontent.com/SammyBytes/Deltix-Client/main/scripts/get-deltix-client.ps1) }"
Remove-Item Env:VERSION

# System-wide install (admin prompt once; writes to C:\Program Files\Deltix)
iex "& { $(irm https://raw.githubusercontent.com/SammyBytes/Deltix-Client/main/scripts/get-deltix-client.ps1) } -System"
```

Both flavours verify the asset's published SHA-256 against `Get-FileHash`
before installing — same integrity guarantee as the bash installer.

Windows (Scoop bucket ships in this repo):

```powershell
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "deltix-client",
"version": "0.7.16",
"version": "0.7.17",
"private": true,
"license": "MIT",
"type": "module",
Expand Down
184 changes: 184 additions & 0 deletions scripts/get-deltix-client.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
<#
.SYNOPSIS
Deltix-Client one-line installer (Windows / PowerShell).

.DESCRIPTION
Downloads the prebuilt Deltix-Client binary for Windows from GitHub
Releases, verifies its SHA-256 against the value GitHub publishes for
the release asset, and installs it to $HOME\.local\bin (no admin
required).

The Linux/macOS equivalent is scripts/get-deltix-client.sh.

.EXAMPLE
# Default: latest release, install to ~\AppData\Local\Microsoft\WindowsApps
# (see INSTALL_DIR env var below — default is actually $HOME\.local\bin)
iex "& { $(irm https://raw.githubusercontent.com/SammyBytes/Deltix-Client/main/scripts/get-deltix-client.ps1) }"

.EXAMPLE
# Pin a version
$env:VERSION = '0.7.16'
iex "& { $(irm https://raw.githubusercontent.com/SammyBytes/Deltix-Client/main/scripts/get-deltix-client.ps1) }"
Remove-Item Env:VERSION

.EXAMPLE
# System-wide install (writes to C:\Program Files\Deltix — needs admin)
iex "& { $(irm https://raw.githubusercontent.com/SammyBytes/Deltix-Client/main/scripts/get-deltix-client.ps1) } -System"

.EXAMPLE
# Custom install directory (e.g. inside a portable app folder)
$env:INSTALL_DIR = 'C:\Tools\deltix'
iex "& { $(irm https://raw.githubusercontent.com/SammyBytes/Deltix-Client/main/scripts/get-deltix-client.ps1) }"
Remove-Item Env:INSTALL_DIR

.NOTES
Environment variables recognised (all optional):
VERSION - pin a specific version (e.g. "0.7.16"); default = latest
INSTALL_DIR - override the install directory; default = ~/.local/bin
(or C:\Program Files\Deltix with -System)
#>

[CmdletBinding()]
param(
[switch]$System
)

$ErrorActionPreference = 'Stop'

$Repo = 'SammyBytes/Deltix-Client'

function Write-Info { param([string]$M) Write-Host "[INFO] $M" }
function Write-Warn { param([string]$M) Write-Host "[WARN] $M" -ForegroundColor Yellow }
function Write-Err { param([string]$M) Write-Host "[ERROR] $M" -ForegroundColor Red }

# --- 1. Refuse to run on non-Windows -----------------------------------------
if (-not $IsWindows -and $env:OS -ne 'Windows_NT') {
Write-Err "This installer is for Windows. For Linux/macOS use get-deltix-client.sh."
exit 1
}

# --- 2. Resolve version ------------------------------------------------------
$Version = $env:VERSION
if (-not $Version) {
Write-Info "Resolving latest ${Repo} release..."
try {
$release = Invoke-RestMethod -Method Get -Uri "https://api.github.com/repos/$Repo/releases/latest"
} catch {
Write-Err "Could not query GitHub for the latest release: $_"
Write-Info "Pin a version with: `$env:VERSION = '0.7.16'"
exit 1
}
$Version = $release.tag_name -replace '^v',''
}
$Tag = "v$Version"

# --- 3. Detect arch -> release asset name -----------------------------------
# PROCESSOR_ARCHITECTURE is 'AMD64' on x64 Windows, 'ARM64' on ARM64 Windows.
# WoW processes report x86 / x64 / arm; reject those (not what we ship).
$procArch = $env:PROCESSOR_ARCHITECTURE
switch ($procArch) {
'AMD64' { $assetArch = 'x64' }
'ARM64' { $assetArch = 'arm64' }
default {
Write-Err "Unsupported architecture: $procArch (only AMD64 / ARM64 are shipped)."
exit 1
}
}
$Asset = "deltix-windows-$assetArch.exe"

# --- 4. Fetch release metadata, extract download URL + expected digest ------
Write-Info "Fetching release metadata for $Tag..."
$releaseJson = Invoke-RestMethod -Method Get -Uri "https://api.github.com/repos/$Repo/releases/tags/$Tag"
$assetMeta = $releaseJson.assets | Where-Object { $_.name -eq $Asset } | Select-Object -First 1
if (-not $assetMeta) {
Write-Err "Asset '$Asset' not found in $Tag."
exit 1
}
$DownloadUrl = $assetMeta.browser_download_url
$Digest = $assetMeta.digest

# --- 5. Download to temp file ------------------------------------------------
$tmp = [System.IO.Path]::GetTempFileName()
try {
Write-Info "Downloading $Asset ($Tag)..."
# -UseBasicParsing for maximum compat (PS 5.1 too); TLS 1.2 forced for GitHub.
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
Invoke-WebRequest -Uri $DownloadUrl -OutFile $tmp.FullName -UseBasicParsing
} catch {
Write-Err "Download failed: $_"
Remove-Item -Force $tmp.FullName -ErrorAction SilentlyContinue
exit 1
}

# --- 6. Verify SHA-256 against GitHub's published digest (sha256:...) -------
if ($Digest -and $Digest.StartsWith('sha256:')) {
$expected = $Digest.Substring(7)
$actual = (Get-FileHash -Path $tmp.FullName -Algorithm SHA256).Hash.ToLower()
if ($actual -ne $expected) {
Write-Err "SHA-256 mismatch: got $actual, expected $expected."
Remove-Item -Force $tmp.FullName -ErrorAction SilentlyContinue
exit 1
}
Write-Info "SHA-256 verified."
} else {
Write-Warn "Release asset has no published digest; skipping integrity check."
}

# --- 7. Resolve install directory -------------------------------------------
if ($System) {
$InstallDir = if ($env:INSTALL_DIR) { $env:INSTALL_DIR } else { 'C:\Program Files\Deltix' }
} else {
$InstallDir = if ($env:INSTALL_DIR) {
$env:INSTALL_DIR
} else {
Join-Path $HOME '.local\bin'
}
}
$bin = Join-Path $InstallDir 'deltix.exe'

# If system install requires admin and we don't have it, escalate once.
$needsAdmin = $System -and -not (([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator))
if ($needsAdmin) {
Write-Info "System install requires admin. Re-launching with elevation..."
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = 'powershell.exe'
$psi.Arguments = "-NoProfile -ExecutionPolicy Bypass -File `"$PSCommandPath`" + ($System ? ' -System' : '')
$psi.Verb = 'runas'
try {
[System.Diagnostics.Process]::Start($psi) | Out-Null
exit 0
} catch {
Write-Err "Could not elevate: $($_.Exception.Message)"
Write-Info "Re-run this script from an elevated PowerShell."
Remove-Item -Force $tmp.FullName -ErrorAction SilentlyContinue
exit 1
}
}

try {
if (-not (Test-Path -LiteralPath $InstallDir)) {
New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
}
Move-Item -Path $tmp.FullName -Destination $bin -Force
} catch {
Write-Err "Could not install to '$bin': $_"
Remove-Item -Force $tmp.FullName -ErrorAction SilentlyContinue
exit 1
}
Write-Info "Installed $bin"

# --- 8. PATH hint ------------------------------------------------------------
$pathDirs = ($env:PATH -split ';') | ForEach-Object { $_.TrimEnd('\') }
if ($pathDirs -notcontains $InstallDir.TrimEnd('\')) {
Write-Warn "$InstallDir is not on your PATH."
Write-Host ""
Write-Host " PowerShell (current session only):"
Write-Host " `$env:PATH = '$InstallDir;' + `$env:PATH"
Write-Host ""
Write-Host " PowerShell (persistent, current user):"
Write-Host " [Environment]::SetEnvironmentVariable('Path', '$InstallDir;' + [Environment]::GetEnvironmentVariable('Path','User'), 'User')"
Write-Host " # then open a new PowerShell"
}

# --- 9. Self-test ------------------------------------------------------------
Write-Info "Done. Try: deltix version"
33 changes: 30 additions & 3 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -538,8 +538,31 @@ async function maybePromptForDsnPassword(dsn: string): Promise<string> {
return parsed.toString();
}

/**
* Reads a CLI flag value in any of the three POSIX-style forms:
* `--limit=5` (long, equals-separated)
* `--limit 5` (long, space-separated)
* `-n 5` (short, space-separated)
* The short form requires a separate value (the next arg) so that
* `-n5` is not mis-parsed — operators almost always space it. Returns
* `undefined` if the flag isn't present.
*
* Only long flags accept the `--flag=value` form (short flags can't
* practically be `--n=5`, and supporting it would invite ambiguity).
*/
function parseFlagValue(args: string[], flagName: string): string | undefined {
return args.find((arg) => arg.startsWith(`--${flagName}=`))?.slice(flagName.length + 3);
// Long form: --name=value
const eq = args.find((a) => a.startsWith(`--${flagName}=`));
if (eq) return eq.slice(flagName.length + 3);
// Long form: --name value
const li = args.indexOf(`--${flagName}`);
if (li >= 0 && li + 1 < args.length) return args[li + 1];
// Short form: -x value (single-char name)
if (flagName.length === 1) {
const si = args.indexOf(`-${flagName}`);
if (si >= 0 && si + 1 < args.length) return args[si + 1];
}
return undefined;
}

function normalizeTables(args: string[]): string[] | null {
Expand Down Expand Up @@ -806,8 +829,12 @@ async function runDiff(args: string[]): Promise<number> {

try {
const diff = await createVersioningService().getDiff(repo, from, to);
// Server returns `{ fromRef, toRef, tables: [...] }`. The row table is
// `diff.tables`, not `diff` itself — passing `diff` directly (and casting
// away the type) was the source of the `rows.reduce is not a function` bug
// when this command hit a real server response.
printKeyValues({ repo, from, to });
printTable(diff as unknown as Array<Record<string, unknown>>);
printTable(diff.tables as unknown as Array<Record<string, unknown>>);
return 0;
} catch (err) {
return handleVersioningError(err, 'Diff failed');
Expand Down Expand Up @@ -1494,4 +1521,4 @@ if (import.meta.main) {
process.exit(exitCode);
}

export { persistLocalPortIfExplicit };
export { parseFlagValue, persistLocalPortIfExplicit };
38 changes: 38 additions & 0 deletions tests/unit/cli/parse-flag-value.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'bun:test';
import { parseFlagValue } from '../../../src/cli';

describe('parseFlagValue (cli/index.ts)', () => {
it('reads --name=value', () => {
expect(parseFlagValue(['--limit=10', 'repo'], 'limit')).toBe('10');
});

it('reads --name value (separate)', () => {
expect(parseFlagValue(['--limit', '10', 'repo'], 'limit')).toBe('10');
});

it('reads -x value (short, single-char)', () => {
expect(parseFlagValue(['-n', '5', 'repo'], 'n')).toBe('5');
});

it('returns undefined when the flag is missing', () => {
expect(parseFlagValue(['repo'], 'limit')).toBeUndefined();
});

it('does not confuse -n with --name', () => {
// `-n` is one dash, `--name` is two. `-n` should NOT match `--name`.
expect(parseFlagValue(['-name', 'value'], 'name')).toBeUndefined();
});

it('accepts --short=value too (long form parser matches single-dash args too)', () => {
// Implementation accepts `--n=value` for short flags — convenient, not
// ambiguous (a single-dash token can't be `--name=value` when flagName
// is 1 char, since `--n=` ≠ `--name=`).
expect(parseFlagValue(['--n=5'], 'n')).toBe('5');
});

it('handles the flag at any position', () => {
expect(parseFlagValue(['repo', 'cmd', '--branch', 'main'], 'branch')).toBe('main');
expect(parseFlagValue(['--branch=main', 'repo', 'cmd'], 'branch')).toBe('main');
expect(parseFlagValue(['-b', 'main', 'repo', 'cmd'], 'b')).toBe('main');
});
});