From 75439c1702767474c021286e566ce49498a83ec4 Mon Sep 17 00:00:00 2001 From: SammyBits Date: Mon, 31 Aug 2026 18:40:18 -0400 Subject: [PATCH] fix(cli): diff row data + multi-form flag parser + Windows installer Three operator-reported fixes bundled into one release because they all surfaced in the same debug session: 1. 'deltix diff' crashed on every call with 'TypeError: rows.reduce is not a function'. The server returns { fromRef, toRef, tables: [...] } and the CLI passed 'diff' directly to printTable (which then called .reduce on an object instead of an array). Pass diff.tables. 2. 'deltix log -n 5' mis-parsed '-n' as the repo name. The flag helper parseFlagValue only matched '--name=value'. Rewrote it to accept all three POSIX forms: --name=value, --name value, and -x value for single-char short flags. flagValue for --password= and --from= now use the same helper. 7 unit tests covering all three forms. 3. Windows users now get a one-liner installer too: scripts/get-deltix-client.ps1 mirrors get-deltix-client.sh. Detects AMD64/ARM64, downloads via Invoke-WebRequest, verifies SHA-256 via Get-FileHash, defaults to $HOME\.local\bin\deltix.exe, -System flag auto-elevates via runas to C:\Program Files\Deltix. Same VERSION= and INSTALL_DIR= env vars as the bash script. Documented in the README Windows install section. 140 unit tests pass; lint clean. --- CHANGELOG.md | 32 +++++ README.md | 18 +++ package.json | 2 +- scripts/get-deltix-client.ps1 | 184 ++++++++++++++++++++++++ src/cli/index.ts | 33 ++++- tests/unit/cli/parse-flag-value.test.ts | 38 +++++ 6 files changed, 303 insertions(+), 4 deletions(-) create mode 100644 scripts/get-deltix-client.ps1 create mode 100644 tests/unit/cli/parse-flag-value.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 89e217c..5a9e985 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `[]` is optional diff --git a/README.md b/README.md index f8476e0..d6c1a14 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/package.json b/package.json index 4e13e1a..1dff0ee 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "deltix-client", - "version": "0.7.16", + "version": "0.7.17", "private": true, "license": "MIT", "type": "module", diff --git a/scripts/get-deltix-client.ps1 b/scripts/get-deltix-client.ps1 new file mode 100644 index 0000000..b63c0e4 --- /dev/null +++ b/scripts/get-deltix-client.ps1 @@ -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" diff --git a/src/cli/index.ts b/src/cli/index.ts index ce94b4d..4243c59 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -538,8 +538,31 @@ async function maybePromptForDsnPassword(dsn: string): Promise { 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 { @@ -806,8 +829,12 @@ async function runDiff(args: string[]): Promise { 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>); + printTable(diff.tables as unknown as Array>); return 0; } catch (err) { return handleVersioningError(err, 'Diff failed'); @@ -1494,4 +1521,4 @@ if (import.meta.main) { process.exit(exitCode); } -export { persistLocalPortIfExplicit }; +export { parseFlagValue, persistLocalPortIfExplicit }; diff --git a/tests/unit/cli/parse-flag-value.test.ts b/tests/unit/cli/parse-flag-value.test.ts new file mode 100644 index 0000000..25fdabe --- /dev/null +++ b/tests/unit/cli/parse-flag-value.test.ts @@ -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'); + }); +});