diff --git a/README.md b/README.md index 76f5f7e16..b24dee511 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,19 @@ The server component written in Go. This handles data synchronization, storage, ### **synkronus-cli** A command-line utility to interact with the Synkronus server. Use it to manage custom app data, handle user administration, export data to Parquet format, and perform various administrative tasks. +You can install the CLI by running this command: + +#### Powershell +```powershell +irm https://raw.githubusercontent.com/OpenDataEnsemble/ode/main/scripts/install-synkronus-cli.ps1 | iex +``` + +#### Mac OS / zsh +```sh +curl -fsSL https://raw.githubusercontent.com/OpenDataEnsemble/ode/main/scripts/install-synkronus-cli.sh | bash +``` + + **License note:** this component is currently **GPL-2.0-or-later** (see `synkronus-cli/LICENSE`) while the QR PNG stack depends on GPL-classified libraries; we plan to swap in a stdlib-only renderer and return the CLI to **MIT**. See `synkronus-cli/FOLLOWUP-custom-qrcode-writer.md` and `THIRD_PARTY_NOTICES.md`. ### **synkronus-portal** diff --git a/scripts/install-synkronus-cli.ps1 b/scripts/install-synkronus-cli.ps1 new file mode 100644 index 000000000..a2591b3e8 --- /dev/null +++ b/scripts/install-synkronus-cli.ps1 @@ -0,0 +1,182 @@ +[CmdletBinding()] +param( + [string]$Version = "latest", + [string]$Owner = "OpenDataEnsemble", + [string]$Repo = "ode", + [string]$AssetName = "synkronus-cli_Windows_x86_64.zip", + [string]$BinaryName = "synk.exe", + [string]$CommandName = "synk", + [string]$InstallDir = "$env:LOCALAPPDATA\Programs\synkronus-cli" +) + +$ErrorActionPreference = "Stop" + +function Write-Step($Message) { + Write-Host "==> $Message" +} + +function Ensure-CommandAvailable($CommandName) { + if (-not (Get-Command $CommandName -ErrorAction SilentlyContinue)) { + throw "Required command not found: $CommandName" + } +} + +function Get-DownloadUrl { + param( + [string]$Owner, + [string]$Repo, + [string]$Version, + [string]$AssetName + ) + + if ($Version -eq "latest") { + return "https://github.com/$Owner/$Repo/releases/latest/download/$AssetName" + } + + return "https://github.com/$Owner/$Repo/releases/download/$Version/$AssetName" +} + +function Add-ToUserPathIfMissing { + param([string]$PathToAdd) + + $currentUserPath = [Environment]::GetEnvironmentVariable("Path", "User") + $pathEntries = @() + + if ($currentUserPath) { + $pathEntries = $currentUserPath.Split(";") | Where-Object { $_.Trim() -ne "" } + } + + $alreadyPresent = $pathEntries | Where-Object { + [System.StringComparer]::OrdinalIgnoreCase.Equals($_.TrimEnd("\"), $PathToAdd.TrimEnd("\")) + } + + if (-not $alreadyPresent) { + $newPath = if ($currentUserPath -and $currentUserPath.Trim() -ne "") { + "$currentUserPath;$PathToAdd" + } else { + $PathToAdd + } + + [Environment]::SetEnvironmentVariable("Path", $newPath, "User") + Write-Host "Added to user PATH: $PathToAdd" + Write-Host "Open a new terminal for the PATH change to take effect." + } + else { + Write-Host "Install directory already present in user PATH." + } +} + +function Add-SynkProfileBlock { + param( + [string]$ProfilePath, + [string]$InstallDir, + [string]$BinaryName, + [string]$CommandName + ) + + $profileDir = Split-Path $ProfilePath -Parent + if (-not (Test-Path $profileDir)) { + New-Item -ItemType Directory -Path $profileDir -Force | Out-Null + } + + if (-not (Test-Path $ProfilePath)) { + New-Item -ItemType File -Path $ProfilePath -Force | Out-Null + } + + $existing = Get-Content $ProfilePath -Raw -ErrorAction SilentlyContinue + if ($null -eq $existing) { + $existing = "" + } + + $markerStart = "# >>> synkronus-cli >>>" + $markerEnd = "# <<< synkronus-cli <<<" + + $block = @" + +$markerStart +`$synkExe = Join-Path "$InstallDir" "$BinaryName" +if (Test-Path `$synkExe) { + Set-Alias -Name $CommandName -Value `$synkExe -Scope Global + try { + $CommandName completion powershell | Out-String | Invoke-Expression + } catch { + Write-Verbose "Failed to load $CommandName PowerShell completion." + } +} +$markerEnd +"@ + + if ($existing -match [regex]::Escape($markerStart)) { + $pattern = [regex]::Escape($markerStart) + '.*?' + [regex]::Escape($markerEnd) + $updated = [regex]::Replace( + $existing, + $pattern, + [System.Text.RegularExpressions.MatchEvaluator]{ param($m) $block }, + [System.Text.RegularExpressions.RegexOptions]::Singleline + ) + Set-Content -Path $ProfilePath -Value $updated + Write-Host "Updated existing synkronus-cli profile block in $ProfilePath" + } + else { + if ($existing.Length -gt 0 -and -not $existing.EndsWith([Environment]::NewLine)) { + Add-Content -Path $ProfilePath -Value "" + } + Add-Content -Path $ProfilePath -Value $block + Write-Host "Added synkronus-cli alias and completion to $ProfilePath" + } +} + +Ensure-CommandAvailable "Invoke-WebRequest" +Ensure-CommandAvailable "Expand-Archive" + +$downloadUrl = Get-DownloadUrl -Owner $Owner -Repo $Repo -Version $Version -AssetName $AssetName +$tempRoot = Join-Path $env:TEMP ("synkronus-cli-install-" + [guid]::NewGuid().ToString("N")) +$archivePath = Join-Path $tempRoot $AssetName +$extractDir = Join-Path $tempRoot "extract" +$binaryPath = Join-Path $InstallDir $BinaryName + +try { + Write-Step "Preparing temporary workspace" + New-Item -ItemType Directory -Path $tempRoot -Force | Out-Null + New-Item -ItemType Directory -Path $extractDir -Force | Out-Null + + Write-Step "Downloading $downloadUrl" + Invoke-WebRequest -Uri $downloadUrl -OutFile $archivePath + + Write-Step "Extracting archive" + Expand-Archive -Path $archivePath -DestinationPath $extractDir -Force + + Write-Step "Finding executable" + $exe = Get-ChildItem -Path $extractDir -Recurse -File | Where-Object { $_.Name -eq $BinaryName } | Select-Object -First 1 + if (-not $exe) { + throw "Could not find $BinaryName inside archive $AssetName" + } + + Write-Step "Installing to $InstallDir" + if (Test-Path $InstallDir) { + Remove-Item -Path $InstallDir -Recurse -Force + } + New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null + Copy-Item -Path $exe.FullName -Destination $binaryPath -Force + + Write-Step "Updating PATH" + Add-ToUserPathIfMissing -PathToAdd $InstallDir + + Write-Step "Updating PowerShell profile" + Add-SynkProfileBlock ` + -ProfilePath $PROFILE.CurrentUserAllHosts ` + -InstallDir $InstallDir ` + -BinaryName $BinaryName ` + -CommandName $CommandName + + Write-Step "Done" + Write-Host "" + Write-Host "Installed: $binaryPath" + Write-Host "Open a new PowerShell window, then try:" + Write-Host " $CommandName --help" +} +finally { + if (Test-Path $tempRoot) { + Remove-Item -Path $tempRoot -Recurse -Force -ErrorAction SilentlyContinue + } +} \ No newline at end of file diff --git a/scripts/install-synkronus-cli.sh b/scripts/install-synkronus-cli.sh new file mode 100644 index 000000000..48a9bc859 --- /dev/null +++ b/scripts/install-synkronus-cli.sh @@ -0,0 +1,215 @@ +#!/usr/bin/env bash +set -euo pipefail + +VERSION="latest" +OWNER="OpenDataEnsemble" +REPO="ode" +ASSET_NAME="synkronus-cli_Darwin_arm64.tar.gz" +BINARY_NAME="synk" +COMMAND_NAME="synk" +INSTALL_DIR="${HOME}/.local/bin" +ZSH_COMPLETIONS_DIR="${HOME}/.zsh/completions" + +usage() { + cat < Release tag to install (default: latest) + --owner GitHub owner/org (default: OpenDataEnsemble) + --repo GitHub repo (default: ode) + --asset Release asset name (default: synkronus-cli_Darwin_arm64.tar.gz) + --install-dir Install directory (default: ~/.local/bin) + --help Show this help +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --version) + VERSION="$2" + shift 2 + ;; + --owner) + OWNER="$2" + shift 2 + ;; + --repo) + REPO="$2" + shift 2 + ;; + --asset) + ASSET_NAME="$2" + shift 2 + ;; + --install-dir) + INSTALL_DIR="$2" + shift 2 + ;; + --help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +step() { + echo "==> $1" +} + +need_cmd() { + command -v "$1" >/dev/null 2>&1 || { + echo "Required command not found: $1" >&2 + exit 1 + } +} + +get_download_url() { + if [[ "$VERSION" == "latest" ]]; then + echo "https://github.com/$OWNER/$REPO/releases/latest/download/$ASSET_NAME" + else + echo "https://github.com/$OWNER/$REPO/releases/download/$VERSION/$ASSET_NAME" + fi +} + +ensure_install_dir_on_path_in_zshrc() { + local zshrc="$HOME/.zshrc" + local marker_start="# >>> synkronus-cli path >>>" + local marker_end="# <<< synkronus-cli path <<<" + + mkdir -p "$(dirname "$zshrc")" + touch "$zshrc" + + local block + block=$(cat <> "$zshrc" + echo "Added PATH block to $zshrc" + fi +} + +setup_zsh_completion() { + local zshrc="$HOME/.zshrc" + local completion_file="$ZSH_COMPLETIONS_DIR/_${COMMAND_NAME}" + local marker_start="# >>> synkronus-cli completion >>>" + local marker_end="# <<< synkronus-cli completion <<<" + + mkdir -p "$ZSH_COMPLETIONS_DIR" + touch "$zshrc" + + step "Generating zsh completion" + "$INSTALL_DIR/$BINARY_NAME" completion zsh > "$completion_file" + + local block + block=$(cat <> "$zshrc" + echo "Added completion block to $zshrc" + fi + + echo "Installed zsh completion to $completion_file" +} + +need_cmd curl +need_cmd tar +need_cmd mktemp +need_cmd python3 + +DOWNLOAD_URL="$(get_download_url)" +TMP_DIR="$(mktemp -d)" +ARCHIVE_PATH="$TMP_DIR/$ASSET_NAME" + +cleanup() { + rm -rf "$TMP_DIR" +} +trap cleanup EXIT + +step "Preparing install directory" +mkdir -p "$INSTALL_DIR" + +step "Downloading $DOWNLOAD_URL" +curl -fsSL "$DOWNLOAD_URL" -o "$ARCHIVE_PATH" + +step "Extracting archive" +tar -xzf "$ARCHIVE_PATH" -C "$TMP_DIR" + +step "Finding executable" +BIN_PATH="$(find "$TMP_DIR" -type f -name "$BINARY_NAME" | head -n 1 || true)" +if [[ -z "$BIN_PATH" ]]; then + echo "Could not find $BINARY_NAME inside archive $ASSET_NAME" >&2 + exit 1 +fi + +step "Installing $BINARY_NAME to $INSTALL_DIR" +install -m 0755 "$BIN_PATH" "$INSTALL_DIR/$BINARY_NAME" + +step "Updating zsh PATH config" +ensure_install_dir_on_path_in_zshrc + +step "Updating zsh completion config" +setup_zsh_completion + +step "Done" +echo +echo "Installed: $INSTALL_DIR/$BINARY_NAME" +echo +echo "Open a new terminal, or run:" +echo " source ~/.zshrc" +echo +echo "Then try:" +echo " $COMMAND_NAME --help" \ No newline at end of file