From be28ddc5a0ff5d8488b286cf1ffbd403b39969a9 Mon Sep 17 00:00:00 2001 From: A Ibrahim Date: Wed, 18 Feb 2026 20:08:41 +0100 Subject: [PATCH 1/2] feat: single binary using bun --- .github/workflows/release.yaml | 58 +++- .gitignore | 6 +- package.json | 3 + scripts/build-binaries.ts | 74 +++++ scripts/generate-registry.ts | 147 +++++++++ scripts/install.ps1 | 156 ++++++++++ scripts/install.sh | 276 +++++++++++++++++ src/cli-binary.ts | 56 ++++ src/cli-core.ts | 519 ++++++++++++++++++++++++++++++++ src/cli.ts | 532 ++------------------------------- src/specs-embedded.ts | 15 + src/utils/specs.ts | 7 + src/utils/update-check.ts | 14 +- tsup.config.ts | 9 +- 14 files changed, 1354 insertions(+), 518 deletions(-) create mode 100644 scripts/build-binaries.ts create mode 100644 scripts/generate-registry.ts create mode 100644 scripts/install.ps1 create mode 100755 scripts/install.sh create mode 100644 src/cli-binary.ts create mode 100644 src/cli-core.ts create mode 100644 src/specs-embedded.ts diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index d87069f..abb5936 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -15,6 +15,9 @@ permissions: jobs: release: runs-on: ubuntu-latest + outputs: + new_release_published: ${{ steps.semantic.outputs.new_release_published }} + new_release_version: ${{ steps.semantic.outputs.new_release_version }} steps: # Checkout code with full history to get tags @@ -37,6 +40,59 @@ jobs: # Release to GitHub and NPM - name: Release + id: semantic env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: npm run semantic-release + run: | + npm run semantic-release + git fetch --tags + TAG=$(git tag --points-at HEAD | grep "^v" | head -1) + echo "new_release_version=${TAG#v}" >> $GITHUB_OUTPUT + echo "new_release_published=$( [ -n "$TAG" ] && echo true || echo false )" >> $GITHUB_OUTPUT + + build-binaries: + needs: release + if: needs.release.outputs.new_release_published == 'true' + runs-on: ubuntu-latest + + steps: + # Checkout the release tag + - uses: actions/checkout@v4 + with: + ref: v${{ needs.release.outputs.new_release_version }} + + - uses: actions/setup-node@v4 + with: + node-version: '22' + + - uses: oven-sh/setup-bun@v2 + + - run: npm ci + + - name: Set version in package.json + run: npm version ${{ needs.release.outputs.new_release_version }} --no-git-tag-version + + - name: Build binaries for all platforms + run: npm run build:binary + + - name: Package archives + run: | + cd bin + for f in tigris-darwin-arm64 tigris-darwin-x64 tigris-linux-x64 tigris-linux-arm64; do + tar czf "${f}.tar.gz" "$f" + done + zip tigris-windows-x64.zip tigris-windows-x64.exe + + - name: Upload to GitHub release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + TAG="v${{ needs.release.outputs.new_release_version }}" + gh release upload "$TAG" \ + bin/tigris-darwin-arm64.tar.gz \ + bin/tigris-darwin-x64.tar.gz \ + bin/tigris-linux-x64.tar.gz \ + bin/tigris-linux-arm64.tar.gz \ + bin/tigris-windows-x64.zip \ + scripts/install.sh \ + scripts/install.ps1 diff --git a/.gitignore b/.gitignore index 5e68388..f4662d6 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,8 @@ dist/ .env.local .env.development.local .env.test.local -.env.production.local \ No newline at end of file +.env.production.local +bin/ + +# Auto-generated +src/command-registry.ts \ No newline at end of file diff --git a/package.json b/package.json index 8d44d04..5bf6391 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,9 @@ "publint": "publint", "updatedocs": "tsx scripts/update-docs.ts", "postinstall": "node postinstall.cjs", + "generate:registry": "tsx scripts/generate-registry.ts", + "build:binary": "npm run generate:registry && tsx scripts/build-binaries.ts", + "build:binary:current": "bun build src/cli-binary.ts --compile --outfile=bin/tigris", "prepublishOnly": "npm run build", "clean": "rm -rf dist", "semantic-release": "semantic-release", diff --git a/scripts/build-binaries.ts b/scripts/build-binaries.ts new file mode 100644 index 0000000..29553ba --- /dev/null +++ b/scripts/build-binaries.ts @@ -0,0 +1,74 @@ +#!/usr/bin/env tsx + +/** + * Build standalone binaries for all supported platforms using `bun build --compile`. + * + * Usage: + * npx tsx scripts/build-binaries.ts # build all targets + * npx tsx scripts/build-binaries.ts linux-x64 # build one target + */ + +import { execSync } from 'child_process'; +import { mkdirSync } from 'fs'; +import { join } from 'path'; + +const ENTRY = 'src/cli-binary.ts'; +const OUT_DIR = join(process.cwd(), 'bin'); + +const targets: Record = { + 'darwin-arm64': { + bunTarget: 'bun-darwin-arm64', + outName: 'tigris-darwin-arm64', + }, + 'darwin-x64': { + bunTarget: 'bun-darwin-x64', + outName: 'tigris-darwin-x64', + }, + 'linux-x64': { + bunTarget: 'bun-linux-x64', + outName: 'tigris-linux-x64', + }, + 'linux-arm64': { + bunTarget: 'bun-linux-arm64', + outName: 'tigris-linux-arm64', + }, + 'windows-x64': { + bunTarget: 'bun-windows-x64', + outName: 'tigris-windows-x64.exe', + }, +}; + +// Allow filtering to specific targets via CLI args +const requestedTargets = process.argv.slice(2); +const selectedTargets = + requestedTargets.length > 0 + ? Object.fromEntries( + Object.entries(targets).filter(([key]) => + requestedTargets.includes(key) + ) + ) + : targets; + +if (Object.keys(selectedTargets).length === 0) { + console.error( + `No matching targets. Available: ${Object.keys(targets).join(', ')}` + ); + process.exit(1); +} + +mkdirSync(OUT_DIR, { recursive: true }); + +for (const [name, { bunTarget, outName }] of Object.entries(selectedTargets)) { + const outFile = join(OUT_DIR, outName); + const cmd = `bun build ${ENTRY} --compile --target=${bunTarget} --outfile=${outFile}`; + console.log(`\n[${name}] ${cmd}`); + try { + execSync(cmd, { stdio: 'inherit' }); + console.log(`[${name}] ✓ ${outFile}`); + } catch { + console.error(`[${name}] ✗ build failed`); + process.exit(1); + } +} + +console.log('\nAll builds complete.'); diff --git a/scripts/generate-registry.ts b/scripts/generate-registry.ts new file mode 100644 index 0000000..6735b8b --- /dev/null +++ b/scripts/generate-registry.ts @@ -0,0 +1,147 @@ +#!/usr/bin/env tsx + +/** + * Auto-generate command-registry.ts from specs.yaml. + * + * specs.yaml is the single source of truth for command structure. + * This script generates static imports for commands that have implementations. + * + * Run: npm run generate:registry + */ + +import { readFileSync, existsSync, writeFileSync } from 'fs'; +import { join } from 'path'; +import * as YAML from 'yaml'; + +const ROOT = process.cwd(); +const SPECS_PATH = join(ROOT, 'src/specs.yaml'); +const OUTPUT_PATH = join(ROOT, 'src/command-registry.ts'); + +interface CommandSpec { + name: string; + alias?: string; + commands?: CommandSpec[]; + default?: string; +} + +interface Specs { + commands: CommandSpec[]; +} + +interface RegistryEntry { + key: string; + importName: string; + importPath: string; +} + +/** + * Convert kebab-case to camelCase + */ +function toCamelCase(str: string): string { + return str.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()); +} + +/** + * Find the implementation file for a command path + */ +function findImplementationPath(commandPath: string[]): string | null { + const basePath = join(ROOT, 'src/lib', ...commandPath); + + // Check for direct file: src/lib/{path}.ts + const directPath = `${basePath}.ts`; + if (existsSync(directPath)) { + return `./lib/${commandPath.join('/')}.js`; + } + + // Check for index file: src/lib/{path}/index.ts + const indexPath = join(basePath, 'index.ts'); + if (existsSync(indexPath)) { + return `./lib/${commandPath.join('/')}/index.js`; + } + + return null; +} + +/** + * Generate import name from command path + * e.g., ["buckets", "list"] -> "bucketsList" + * e.g., ["iam", "policies", "create"] -> "iamPoliciesCreate" + */ +function toImportName(path: string[]): string { + return path + .map((part, index) => { + const camel = toCamelCase(part); + return index === 0 + ? camel + : camel.charAt(0).toUpperCase() + camel.slice(1); + }) + .join(''); +} + +/** + * Recursively collect all registry entries from the command tree + */ +function collectEntries( + commands: CommandSpec[], + parentPath: string[] = [] +): RegistryEntry[] { + const entries: RegistryEntry[] = []; + + for (const cmd of commands) { + const currentPath = [...parentPath, cmd.name]; + + if (cmd.commands && cmd.commands.length > 0) { + // Has sub-commands - recurse into them + entries.push(...collectEntries(cmd.commands, currentPath)); + } else { + // Leaf command - check if implementation exists + const implPath = findImplementationPath(currentPath); + if (implPath) { + entries.push({ + key: currentPath.join('/'), + importName: toImportName(currentPath), + importPath: implPath, + }); + } + } + } + + return entries; +} + +/** + * Generate the command-registry.ts file content + */ +function generateRegistry(entries: RegistryEntry[]): string { + const imports = entries + .map((e) => `import * as ${e.importName} from '${e.importPath}';`) + .join('\n'); + + const registryEntries = entries + .map((e) => ` '${e.key}': ${e.importName},`) + .join('\n'); + + return `// Auto-generated from specs.yaml - DO NOT EDIT +// Run: npm run generate:registry + +${imports} + +export const commandRegistry: Record> = { +${registryEntries} +}; +`; +} + +// Main +const specsContent = readFileSync(SPECS_PATH, 'utf8'); +const specs: Specs = YAML.parse(specsContent, { schema: 'core' }); + +const entries = collectEntries(specs.commands); + +console.log(`Found ${entries.length} command implementations:`); +entries.forEach((e) => console.log(` ${e.key}`)); + +const output = generateRegistry(entries); +writeFileSync(OUTPUT_PATH, output); + +console.log(`\nGenerated: ${OUTPUT_PATH}`); diff --git a/scripts/install.ps1 b/scripts/install.ps1 new file mode 100644 index 0000000..a99f5d9 --- /dev/null +++ b/scripts/install.ps1 @@ -0,0 +1,156 @@ +# Tigris CLI installer for Windows +# Usage: irm https://raw.githubusercontent.com/tigrisdata/cli/main/scripts/install.ps1 | iex +# +# Environment variables: +# TIGRIS_INSTALL_DIR - Installation directory (default: $HOME\.tigris\bin) +# TIGRIS_VERSION - Specific version to install (default: latest) +# TIGRIS_REPO - GitHub repo (default: tigrisdata/cli) +# TIGRIS_DOWNLOAD_URL - Direct download URL (skips version detection, for testing) + +$ErrorActionPreference = "Stop" + +$Repo = if ($env:TIGRIS_REPO) { $env:TIGRIS_REPO } else { "tigrisdata/cli" } +$BinaryName = "tigris" +$DefaultInstallDir = "$HOME\.tigris\bin" + +function Write-Info { param($Message) Write-Host "info " -ForegroundColor Blue -NoNewline; Write-Host $Message } +function Write-Success { param($Message) Write-Host "success " -ForegroundColor Green -NoNewline; Write-Host $Message } +function Write-Warn { param($Message) Write-Host "warn " -ForegroundColor Yellow -NoNewline; Write-Host $Message } +function Write-Err { param($Message) Write-Host "error " -ForegroundColor Red -NoNewline; Write-Host $Message; exit 1 } + +function Get-LatestVersion { + $response = Invoke-RestMethod -Uri "https://api.github.com/repos/$Repo/releases/latest" + return $response.tag_name +} + +function Add-ToPath { + param($InstallDir) + + $userPath = [Environment]::GetEnvironmentVariable("Path", "User") + + # Check if already in PATH + if ($userPath -like "*$InstallDir*") { + return + } + + # Add to user PATH permanently + $newPath = "$InstallDir;$userPath" + [Environment]::SetEnvironmentVariable("Path", $newPath, "User") + Write-Info "Added $InstallDir to user PATH" + + # Also update current session + $env:Path = "$InstallDir;$env:Path" +} + +function Show-Banner { + Write-Host @" + + +-------------------------------------------------------------------+ + | | + | _____ ___ ___ ___ ___ ___ ___ _ ___ | + | |_ _|_ _/ __| _ \_ _/ __| / __| | |_ _| | + | | | | | (_ | /| |\__ \ | (__| |__ | | | + | |_| |___\___|_|_\___|___/ \___|____|___| | + | | + | To get started: | + | > tigris login | + | | + | For help: | + | > tigris help | + | | + | Tip - You can use 't3' as a shorthand for 'tigris': | + | > t3 login | + | | + | Docs: https://www.tigrisdata.com/docs/cli/ | + | | + +-------------------------------------------------------------------+ + +"@ +} + +function Main { + # Detect architecture + $arch = if ([Environment]::Is64BitOperatingSystem) { "x64" } else { Write-Err "32-bit Windows is not supported" } + $platform = "windows-$arch" + Write-Info "Detected platform: $platform" + + # Determine install directory + $installDir = if ($env:TIGRIS_INSTALL_DIR) { $env:TIGRIS_INSTALL_DIR } else { $DefaultInstallDir } + if (-not (Test-Path $installDir)) { + New-Item -ItemType Directory -Path $installDir -Force | Out-Null + } + + # Construct archive name + $archiveName = "tigris-$platform.zip" + + # Determine download URL + if ($env:TIGRIS_DOWNLOAD_URL) { + # Direct URL provided (for testing) + $downloadUrl = $env:TIGRIS_DOWNLOAD_URL + $version = "local" + Write-Info "Using direct download URL (testing mode)" + } else { + # Fetch from GitHub releases + $version = $env:TIGRIS_VERSION + if (-not $version) { + Write-Info "Fetching latest version..." + $version = Get-LatestVersion + if (-not $version) { + Write-Err "Failed to determine latest version" + } + } + $downloadUrl = "https://github.com/$Repo/releases/download/$version/$archiveName" + } + + Write-Info "Installing version: $version" + Write-Info "Downloading from: $downloadUrl" + + # Create temp directory + $tempDir = Join-Path $env:TEMP "tigris-install-$(Get-Random)" + New-Item -ItemType Directory -Path $tempDir -Force | Out-Null + + try { + # Download archive + $archivePath = Join-Path $tempDir $archiveName + Invoke-WebRequest -Uri $downloadUrl -OutFile $archivePath + + # Extract archive + Write-Info "Extracting..." + Expand-Archive -Path $archivePath -DestinationPath $tempDir -Force + + # Find and install binary + $extractedBinary = Join-Path $tempDir "tigris-$platform.exe" + if (-not (Test-Path $extractedBinary)) { + $extractedBinary = Join-Path $tempDir "$BinaryName.exe" + if (-not (Test-Path $extractedBinary)) { + Write-Err "Could not find binary in archive" + } + } + + # Install binary + $targetPath = Join-Path $installDir "$BinaryName.exe" + Copy-Item $extractedBinary $targetPath -Force + + # Create t3.exe copy (Windows doesn't support symlinks without admin) + $t3Path = Join-Path $installDir "t3.exe" + Copy-Item $targetPath $t3Path -Force + + Write-Success "Installed $BinaryName to $targetPath" + + # Add to PATH + Add-ToPath $installDir + + # Show welcome banner + Show-Banner + + Write-Success "Installation complete!" + } + finally { + # Cleanup + if (Test-Path $tempDir) { + Remove-Item -Recurse -Force $tempDir + } + } +} + +Main diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..16e1ff1 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,276 @@ +#!/bin/sh +# Tigris CLI installer +# Usage: curl -fsSL https://raw.githubusercontent.com/tigrisdata/cli/main/scripts/install.sh | sh +# +# Environment variables: +# TIGRIS_INSTALL_DIR - Installation directory (default: ~/.tigris/bin) +# TIGRIS_VERSION - Specific version to install (default: latest) +# TIGRIS_REPO - GitHub repo (default: tigrisdata/cli) +# TIGRIS_DOWNLOAD_URL - Direct download URL (skips version detection, for testing) +# TIGRIS_SKIP_PATH - Set to 1 to skip PATH modification (for testing) + +set -e + +REPO="${TIGRIS_REPO:-tigrisdata/cli}" +BINARY_NAME="tigris" +DEFAULT_INSTALL_DIR="$HOME/.tigris/bin" + +# Colors (disabled if not a terminal) +if [ -t 1 ]; then + RED='\033[0;31m' + GREEN='\033[0;32m' + YELLOW='\033[0;33m' + BLUE='\033[0;34m' + CYAN='\033[0;36m' + BOLD='\033[1m' + NC='\033[0m' # No Color +else + RED='' + GREEN='' + YELLOW='' + BLUE='' + CYAN='' + BOLD='' + NC='' +fi + +info() { + printf "${BLUE}info${NC} %s\n" "$1" +} + +success() { + printf "${GREEN}success${NC} %s\n" "$1" +} + +warn() { + printf "${YELLOW}warn${NC} %s\n" "$1" +} + +error() { + printf "${RED}error${NC} %s\n" "$1" >&2 + exit 1 +} + +detect_platform() { + OS="$(uname -s)" + ARCH="$(uname -m)" + + case "$OS" in + Linux) OS="linux" ;; + Darwin) OS="darwin" ;; + MINGW*|MSYS*|CYGWIN*) OS="windows" ;; + *) error "Unsupported operating system: $OS" ;; + esac + + case "$ARCH" in + x86_64|amd64) ARCH="x64" ;; + arm64|aarch64) ARCH="arm64" ;; + *) error "Unsupported architecture: $ARCH" ;; + esac + + PLATFORM="${OS}-${ARCH}" +} + +get_latest_version() { + if command -v curl > /dev/null 2>&1; then + curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" | grep '"tag_name"' | sed -E 's/.*"([^"]+)".*/\1/' + elif command -v wget > /dev/null 2>&1; then + wget -qO- "https://api.github.com/repos/${REPO}/releases/latest" | grep '"tag_name"' | sed -E 's/.*"([^"]+)".*/\1/' + else + error "Neither curl nor wget found. Please install one of them." + fi +} + +download_file() { + URL="$1" + OUTPUT="$2" + + if command -v curl > /dev/null 2>&1; then + curl -fsSL "$URL" -o "$OUTPUT" + elif command -v wget > /dev/null 2>&1; then + wget -q "$URL" -O "$OUTPUT" + else + error "Neither curl nor wget found. Please install one of them." + fi +} + +detect_shell() { + SHELL_NAME="$(basename "$SHELL")" +} + +add_to_path() { + INSTALL_DIR="$1" + + # Detect config file based on shell + PROFILE="" + + case "$SHELL_NAME" in + zsh) + PROFILE="$HOME/.zshrc" + ;; + bash) + if [ -f "$HOME/.bashrc" ]; then + PROFILE="$HOME/.bashrc" + elif [ -f "$HOME/.bash_profile" ]; then + PROFILE="$HOME/.bash_profile" + else + PROFILE="$HOME/.profile" + fi + ;; + fish) + # Fish uses a different method + PROFILE="" + ;; + *) + PROFILE="$HOME/.profile" + ;; + esac + + # Check if already in PATH + case ":$PATH:" in + *":$INSTALL_DIR:"*) + return 0 + ;; + esac + + # Add to PATH + if [ "$SHELL_NAME" = "fish" ]; then + # Fish shell + fish -c "set -Ux fish_user_paths $INSTALL_DIR \$fish_user_paths" 2>/dev/null || true + info "Added $INSTALL_DIR to fish PATH" + elif [ -n "$PROFILE" ]; then + # Check if already in profile + if ! grep -q "$INSTALL_DIR" "$PROFILE" 2>/dev/null; then + echo "" >> "$PROFILE" + echo "# Tigris CLI" >> "$PROFILE" + echo "export PATH=\"$INSTALL_DIR:\$PATH\"" >> "$PROFILE" + info "Added $INSTALL_DIR to $PROFILE" + fi + fi + + # Export for current session + export PATH="$INSTALL_DIR:$PATH" +} + +show_banner() { + cat << 'EOF' + + ┌───────────────────────────────────────────────────────────────────┐ + │ │ + │ _____ ___ ___ ___ ___ ___ ___ _ ___ │ + │ |_ _|_ _/ __| _ \_ _/ __| / __| | |_ _| │ + │ | | | | (_ | /| |\__ \ | (__| |__ | | │ + │ |_| |___\___|_|_\___|___/ \___|____|___| │ + │ │ + │ To get started: │ + │ $ tigris login │ + │ │ + │ For help: │ + │ $ tigris help │ + │ │ + │ Tip - You can use 't3' as a shorthand for 'tigris': │ + │ $ t3 login │ + │ │ + │ Docs: https://www.tigrisdata.com/docs/cli/ │ + │ │ + └───────────────────────────────────────────────────────────────────┘ + +EOF +} + +main() { + detect_platform + detect_shell + info "Detected platform: $PLATFORM" + + # Determine install directory + INSTALL_DIR="${TIGRIS_INSTALL_DIR:-$DEFAULT_INSTALL_DIR}" + mkdir -p "$INSTALL_DIR" + + # Construct archive/binary names + if [ "$OS" = "windows" ]; then + ARCHIVE_NAME="tigris-${PLATFORM}.zip" + BINARY_FILE="${BINARY_NAME}.exe" + else + ARCHIVE_NAME="tigris-${PLATFORM}.tar.gz" + BINARY_FILE="$BINARY_NAME" + fi + + # Determine download URL + if [ -n "${TIGRIS_DOWNLOAD_URL:-}" ]; then + # Direct URL provided (for testing) + DOWNLOAD_URL="$TIGRIS_DOWNLOAD_URL" + VERSION="local" + info "Using direct download URL (testing mode)" + else + # Fetch from GitHub releases + VERSION="${TIGRIS_VERSION:-}" + if [ -z "$VERSION" ]; then + info "Fetching latest version..." + VERSION="$(get_latest_version)" + if [ -z "$VERSION" ]; then + error "Failed to determine latest version" + fi + fi + DOWNLOAD_URL="https://github.com/${REPO}/releases/download/${VERSION}/${ARCHIVE_NAME}" + fi + + info "Installing version: $VERSION" + info "Downloading from: $DOWNLOAD_URL" + + # Create temp directory + TMP_DIR="$(mktemp -d)" + trap 'rm -rf "$TMP_DIR"' EXIT + + # Download archive + ARCHIVE_PATH="${TMP_DIR}/${ARCHIVE_NAME}" + download_file "$DOWNLOAD_URL" "$ARCHIVE_PATH" + + # Extract archive + info "Extracting..." + cd "$TMP_DIR" + if [ "$OS" = "windows" ]; then + unzip -q "$ARCHIVE_PATH" + else + tar -xzf "$ARCHIVE_PATH" + fi + + # Find and install binary + EXTRACTED_BINARY="tigris-${PLATFORM}" + if [ "$OS" = "windows" ]; then + EXTRACTED_BINARY="${EXTRACTED_BINARY}.exe" + fi + + if [ ! -f "$EXTRACTED_BINARY" ]; then + if [ -f "$BINARY_NAME" ] || [ -f "${BINARY_NAME}.exe" ]; then + EXTRACTED_BINARY="$BINARY_NAME" + [ "$OS" = "windows" ] && EXTRACTED_BINARY="${BINARY_NAME}.exe" + else + error "Could not find binary in archive. Contents: $(ls -la)" + fi + fi + + # Install binary + mv "$EXTRACTED_BINARY" "${INSTALL_DIR}/${BINARY_FILE}" + chmod +x "${INSTALL_DIR}/${BINARY_FILE}" + + # Create t3 symlink + ln -sf "${INSTALL_DIR}/${BINARY_FILE}" "${INSTALL_DIR}/t3" 2>/dev/null || true + + success "Installed $BINARY_NAME to ${INSTALL_DIR}/${BINARY_FILE}" + + # Add to PATH (skip if testing) + if [ "${TIGRIS_SKIP_PATH:-}" != "1" ]; then + add_to_path "$INSTALL_DIR" + fi + + # Show welcome banner + show_banner + + # Remind about new shell if PATH was modified + if ! command -v tigris > /dev/null 2>&1; then + warn "You may need to restart your shell or run: source ~/.${SHELL_NAME}rc" + fi +} + +main diff --git a/src/cli-binary.ts b/src/cli-binary.ts new file mode 100644 index 0000000..9df8cc9 --- /dev/null +++ b/src/cli-binary.ts @@ -0,0 +1,56 @@ +#!/usr/bin/env node + +// Binary entry point — uses static imports instead of dynamic import(). +// For npm distribution, use src/cli.ts instead. + +(globalThis as { __TIGRIS_BINARY?: boolean }).__TIGRIS_BINARY = true; + +import { loadSpecs } from './specs-embedded.js'; +import { setSpecs } from './utils/specs.js'; +import { commandRegistry } from './command-registry.js'; +import { checkForUpdates } from './utils/update-check.js'; +import { version } from '../package.json'; +import { + setupErrorHandlers, + createProgram, + type ModuleLoader, + type ImplementationChecker, +} from './cli-core.js'; + +// Pre-populate the shared specs cache so command modules work without filesystem access +const specs = loadSpecs(); +setSpecs(specs); + +setupErrorHandlers(); + +/** + * Check if a command has an implementation (registry-based) + */ +const hasImplementation: ImplementationChecker = (commandPath) => { + const key = commandPath.join('/'); + return key in commandRegistry; +}; + +/** + * Load module from static registry (for binary distribution) + */ +const loadModule: ModuleLoader = async (commandPath) => { + const key = commandPath.join('/'); + const module = commandRegistry[key]; + + if (module) { + return { module, error: null }; + } + + return { module: null, error: `Command not found: ${commandPath.join(' ')}` }; +}; + +const program = createProgram({ + specs, + version, + loadModule, + hasImplementation, +}); + +program.parse(); +checkForUpdates(); diff --git a/src/cli-core.ts b/src/cli-core.ts new file mode 100644 index 0000000..e825109 --- /dev/null +++ b/src/cli-core.ts @@ -0,0 +1,519 @@ +/** + * Shared CLI core functionality used by both cli.ts (npm) and cli-binary.ts (binary) + */ + +import { Command as CommanderCommand } from 'commander'; +import type { Argument, CommandSpec, Specs } from './types.js'; + +export interface ModuleLoader { + (commandPath: string[]): Promise<{ + module: Record | null; + error: string | null; + }>; +} + +export interface ImplementationChecker { + (commandPath: string[]): boolean; +} + +export interface CLIConfig { + specs: Specs; + version: string; + loadModule: ModuleLoader; + hasImplementation: ImplementationChecker; +} + +/** + * Setup global error handlers + */ +export function setupErrorHandlers() { + process.on('unhandledRejection', (reason) => { + if (reason === '' || reason === undefined) { + console.error('\nOperation cancelled'); + process.exit(1); + } + console.error( + '\nError:', + reason instanceof Error ? reason.message : reason + ); + process.exit(1); + }); + + process.on('uncaughtException', (error) => { + console.error('\nError:', error.message); + process.exit(1); + }); +} + +/** + * Validate command name to prevent path traversal attacks + */ +export function isValidCommandName(name: string): boolean { + return /^[a-zA-Z0-9_-]+$/.test(name); +} + +export function formatArgumentHelp(arg: Argument): string { + let optionPart: string; + + if (arg.type === 'positional') { + optionPart = ` ${arg.name}`; + } else { + optionPart = ` --${arg.name}`; + if (arg.alias && typeof arg.alias === 'string' && arg.alias.length === 1) { + optionPart += `, -${arg.alias}`; + } + } + + const minPadding = 26; + const paddedOptionPart = + optionPart.length >= minPadding + ? optionPart + ' ' + : optionPart.padEnd(minPadding); + let description = arg.description; + + if (arg.options) { + if (Array.isArray(arg.options) && typeof arg.options[0] === 'string') { + description += ` (options: ${(arg.options as string[]).join(', ')})`; + } else { + description += ` (options: ${(arg.options as Array<{ name: string; value: string }>).map((o) => o.value).join(', ')})`; + } + } + + if (arg.default) { + description += ` [default: ${arg.default}]`; + } + + if (arg.required) { + description += ' [required]'; + } + + if (arg['required-when']) { + description += ` [required when: ${arg['required-when']}]`; + } + + if (arg.multiple) { + description += ' [multiple values: comma-separated]'; + } + + if (arg.type === 'positional') { + description += ' [positional argument]'; + } + + if (arg.examples && arg.examples.length > 0) { + description += ` (examples: ${arg.examples.join(', ')})`; + } + + return `${paddedOptionPart}${description}`; +} + +export function commandHasAnyImplementation( + command: CommandSpec, + pathParts: string[], + hasImplementation: ImplementationChecker +): boolean { + if (hasImplementation(pathParts)) { + return true; + } + + if (command.commands) { + return command.commands.some((child) => + commandHasAnyImplementation( + child, + [...pathParts, child.name], + hasImplementation + ) + ); + } + + return false; +} + +export function showCommandHelp( + specs: Specs, + command: CommandSpec, + pathParts: string[], + hasImplementation: ImplementationChecker +) { + const fullPath = pathParts.join(' '); + console.log(`\n${specs.name} ${fullPath} - ${command.description}\n`); + + if (command.commands && command.commands.length > 0) { + const availableCmds = command.commands.filter((cmd) => + commandHasAnyImplementation( + cmd, + [...pathParts, cmd.name], + hasImplementation + ) + ); + + if (availableCmds.length > 0) { + console.log('Commands:'); + availableCmds.forEach((cmd) => { + let cmdPart = ` ${cmd.name}`; + if (cmd.alias) { + const aliases = Array.isArray(cmd.alias) ? cmd.alias : [cmd.alias]; + cmdPart += ` (${aliases.join(', ')})`; + } + const paddedCmdPart = cmdPart.padEnd(24); + console.log(`${paddedCmdPart}${cmd.description}`); + }); + console.log(); + } + } + + if (command.arguments && command.arguments.length > 0) { + console.log('Arguments:'); + command.arguments.forEach((arg) => { + console.log(formatArgumentHelp(arg)); + }); + console.log(); + } + + if (command.examples && command.examples.length > 0) { + console.log('Examples:'); + command.examples.forEach((ex) => { + console.log(` ${ex}`); + }); + console.log(); + } + + if (command.commands && command.commands.length > 0) { + console.log( + `Use "${specs.name} ${fullPath} help" for more information about a command.` + ); + } +} + +export function showMainHelp( + specs: Specs, + version: string, + hasImplementation: ImplementationChecker +) { + console.log(`Tigris CLI Version: ${version}\n`); + console.log('Usage: tigris [command] [options]\n'); + console.log('Commands:'); + + const availableCommands = specs.commands.filter((cmd) => + commandHasAnyImplementation(cmd, [cmd.name], hasImplementation) + ); + + availableCommands.forEach((command: CommandSpec) => { + let commandPart = ` ${command.name}`; + if (command.alias) { + const aliases = Array.isArray(command.alias) + ? command.alias + : [command.alias]; + commandPart += ` (${aliases.join(', ')})`; + } + const paddedCommandPart = commandPart.padEnd(24); + console.log(`${paddedCommandPart}${command.description}`); + }); + console.log( + `\nUse "${specs.name} help" for more information about a command.` + ); +} + +export function addArgumentsToCommand( + cmd: CommanderCommand, + args: Argument[] = [] +) { + args.forEach((arg) => { + if (arg.type === 'positional') { + const argumentName = arg.required ? `<${arg.name}>` : `[${arg.name}]`; + cmd.argument(argumentName, arg.description); + } else { + const hasValidShortOption = + arg.alias && typeof arg.alias === 'string' && arg.alias.length === 1; + let optionString = hasValidShortOption + ? `-${arg.alias}, --${arg.name}` + : `--${arg.name}`; + + if (arg.type === 'flag') { + // Flags don't take values + } else if (arg.type === 'boolean') { + optionString += ' [value]'; + } else if (arg.options) { + optionString += ' '; + } else { + optionString += + arg.required || arg['required-when'] ? ' ' : ' [value]'; + } + + cmd.option(optionString, arg.description, arg.default); + } + }); +} + +function camelCase(str: string): string { + return str.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()); +} + +function getOptionValue( + options: Record, + argName: string, + args?: Argument[] +): unknown { + if (args) { + const argDef = args.find((a) => a.name === argName); + if (argDef && argDef.alias && typeof argDef.alias === 'string') { + const aliasKey = + argDef.alias.charAt(0).toUpperCase() + argDef.alias.slice(1); + if (options[aliasKey] !== undefined) { + return options[aliasKey]; + } + } + } + + const possibleKeys = [ + argName, + argName.replace(/-/g, ''), + argName.replace(/-/g, '').toLowerCase(), + argName.charAt(0).toUpperCase(), + camelCase(argName), + ]; + + for (const key of possibleKeys) { + if (options[key] !== undefined) { + return options[key]; + } + } + return undefined; +} + +export function validateRequiredWhen( + args: Argument[], + options: Record +): boolean { + for (const arg of args) { + if (arg['required-when']) { + const [dependentArg, expectedValue] = arg['required-when'].split('='); + const dependentValue = getOptionValue(options, dependentArg, args); + const currentValue = getOptionValue(options, arg.name, args); + + if (dependentValue === expectedValue && !currentValue) { + console.error( + `--${arg.name} is required when --${dependentArg} is ${expectedValue}` + ); + return false; + } + } + + if (arg.required && !getOptionValue(options, arg.name, args)) { + console.error(`--${arg.name} is required`); + return false; + } + } + return true; +} + +export function extractArgumentValues( + args: Argument[], + positionalArgs: string[], + commandOrOptions: Record +): Record { + let options: Record; + + if ( + 'optsWithGlobals' in commandOrOptions && + typeof commandOrOptions.optsWithGlobals === 'function' + ) { + options = ( + commandOrOptions.optsWithGlobals as () => Record + )(); + } else if ( + 'opts' in commandOrOptions && + typeof commandOrOptions.opts === 'function' + ) { + options = (commandOrOptions.opts as () => Record)(); + } else { + options = commandOrOptions; + } + + const result = { ...options }; + + const positionalArgDefs = args.filter((arg) => arg.type === 'positional'); + positionalArgDefs.forEach((arg, index) => { + if (positionalArgs[index] !== undefined) { + if (arg.multiple) { + result[arg.name] = positionalArgs[index] + .split(',') + .map((s) => s.trim()); + } else { + result[arg.name] = positionalArgs[index]; + } + } + }); + + args.forEach((arg) => { + if (arg.multiple && arg.type !== 'positional' && result[arg.name]) { + if (typeof result[arg.name] === 'string') { + result[arg.name] = (result[arg.name] as string) + .split(',') + .map((s) => s.trim()); + } + } + }); + + return result; +} + +async function loadAndExecuteCommand( + loadModule: ModuleLoader, + pathParts: string[], + positionalArgs: string[] = [], + options: Record = {} +) { + const { module, error: loadError } = await loadModule(pathParts); + + if (loadError || !module) { + console.error(loadError); + process.exit(1); + } + + const functionName = pathParts[pathParts.length - 1]; + const commandFunction = module.default || module[functionName]; + + if (typeof commandFunction !== 'function') { + console.error(`Command not implemented: ${pathParts.join(' ')}`); + process.exit(1); + } + + await commandFunction({ ...options, _positional: positionalArgs }); +} + +/** + * Register commands recursively from specs + */ +export function registerCommands( + config: CLIConfig, + parent: CommanderCommand, + commandSpecs: CommandSpec[], + pathParts: string[] = [] +) { + const { specs, loadModule, hasImplementation } = config; + + for (const spec of commandSpecs) { + if (!isValidCommandName(spec.name)) { + console.error( + `Invalid command name "${spec.name}": only alphanumeric, hyphens, and underscores allowed` + ); + process.exit(1); + } + + const currentPath = [...pathParts, spec.name]; + + // Skip commands with no implementations + if (!commandHasAnyImplementation(spec, currentPath, hasImplementation)) { + continue; + } + + const cmd = parent.command(spec.name).description(spec.description); + + if (spec.alias) { + const aliases = Array.isArray(spec.alias) ? spec.alias : [spec.alias]; + aliases.forEach((alias) => cmd.alias(alias)); + } + + if (spec.commands && spec.commands.length > 0) { + // Has children - recurse + registerCommands(config, cmd, spec.commands, currentPath); + + if (spec.default) { + const defaultCmd = spec.commands.find((c) => c.name === spec.default); + if (defaultCmd) { + addArgumentsToCommand(cmd, spec.arguments); + addArgumentsToCommand(cmd, defaultCmd.arguments); + + const allArguments = [ + ...(spec.arguments || []), + ...(defaultCmd.arguments || []), + ]; + + cmd.action(async (...args) => { + const options = args.pop(); + const positionalArgs = args; + + if ( + allArguments.length > 0 && + !validateRequiredWhen( + allArguments, + extractArgumentValues(allArguments, positionalArgs, options) + ) + ) { + return; + } + + await loadAndExecuteCommand( + loadModule, + [...currentPath, defaultCmd.name], + positionalArgs, + extractArgumentValues(allArguments, positionalArgs, options) + ); + }); + } + } else { + cmd.action(() => { + showCommandHelp(specs, spec, currentPath, hasImplementation); + }); + } + } else { + // Leaf command + addArgumentsToCommand(cmd, spec.arguments); + + cmd.action(async (...args) => { + const options = args.pop(); + const positionalArgs = args; + + if ( + spec.arguments && + !validateRequiredWhen( + spec.arguments, + extractArgumentValues(spec.arguments, positionalArgs, options) + ) + ) { + return; + } + + await loadAndExecuteCommand( + loadModule, + currentPath, + positionalArgs, + extractArgumentValues(spec.arguments || [], positionalArgs, options) + ); + }); + } + + // Add help subcommand + cmd + .command('help') + .description('Show help for this command') + .action(() => { + showCommandHelp(specs, spec, currentPath, hasImplementation); + }); + } +} + +/** + * Create and configure the CLI program + */ +export function createProgram(config: CLIConfig): CommanderCommand { + const { specs, version, hasImplementation } = config; + + const program = new CommanderCommand(); + program.name(specs.name).description(specs.description).version(version); + + registerCommands(config, program, specs.commands); + + program + .command('help') + .description('Show general help') + .action(() => { + showMainHelp(specs, version, hasImplementation); + }); + + program.action(() => { + showMainHelp(specs, version, hasImplementation); + }); + + return program; +} diff --git a/src/cli.ts b/src/cli.ts index c0d091f..1dd2cb6 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,30 +1,19 @@ #!/usr/bin/env node -import { Command as CommanderCommand } from 'commander'; import { existsSync } from 'fs'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; -import type { Argument, CommandSpec } from './types.js'; import { loadSpecs } from './utils/specs.js'; import { checkForUpdates } from './utils/update-check.js'; import { version } from '../package.json'; +import { + setupErrorHandlers, + createProgram, + type ModuleLoader, + type ImplementationChecker, +} from './cli-core.js'; -// Global handler for user cancellation (Ctrl+C) and unhandled errors -process.on('unhandledRejection', (reason) => { - // Enquirer throws empty string or undefined when user cancels with Ctrl+C - if (reason === '' || reason === undefined) { - console.error('\nOperation cancelled'); - process.exit(1); - } - // For other unhandled rejections, show the error - console.error('\nError:', reason instanceof Error ? reason.message : reason); - process.exit(1); -}); - -process.on('uncaughtException', (error) => { - console.error('\nError:', error.message); - process.exit(1); -}); +setupErrorHandlers(); const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -32,287 +21,24 @@ const __dirname = dirname(__filename); const specs = loadSpecs(); /** - * Validate command name to prevent path traversal attacks - * Only allows alphanumeric, hyphens, and underscores + * Check if a command path has an implementation (filesystem-based) */ -function isValidCommandName(name: string): boolean { - return /^[a-zA-Z0-9_-]+$/.test(name); -} - -/** - * Check if a command path has an implementation - * @param pathParts - Array of command path parts, e.g., ['iam', 'policies', 'get'] - */ -function hasImplementation(pathParts: string[]): boolean { +const hasImplementation: ImplementationChecker = (pathParts) => { if (pathParts.length === 0) return false; - // Try direct file: lib/iam/policies/get.js const directPath = join(__dirname, 'lib', ...pathParts) + '.js'; if (existsSync(directPath)) return true; - // Try index file: lib/iam/policies/get/index.js const indexPath = join(__dirname, 'lib', ...pathParts, 'index.js'); if (existsSync(indexPath)) return true; return false; -} - -function formatArgumentHelp(arg: Argument): string { - let optionPart: string; - - if (arg.type === 'positional') { - optionPart = ` ${arg.name}`; - } else { - optionPart = ` --${arg.name}`; - // Only show short option if it's a single character (Commander requirement) - if (arg.alias && typeof arg.alias === 'string' && arg.alias.length === 1) { - optionPart += `, -${arg.alias}`; - } - } - - // Pad option part to ensure consistent alignment, adjust for longer aliases - const minPadding = 26; - const paddedOptionPart = - optionPart.length >= minPadding - ? optionPart + ' ' - : optionPart.padEnd(minPadding); - let description = arg.description; - - if (arg.options) { - if (Array.isArray(arg.options) && typeof arg.options[0] === 'string') { - description += ` (options: ${(arg.options as string[]).join(', ')})`; - } else { - description += ` (options: ${(arg.options as Array<{ name: string; value: string }>).map((o) => o.value).join(', ')})`; - } - } - - if (arg.default) { - description += ` [default: ${arg.default}]`; - } - - if (arg.required) { - description += ' [required]'; - } - - if (arg['required-when']) { - description += ` [required when: ${arg['required-when']}]`; - } - - if (arg.multiple) { - description += ' [multiple values: comma-separated]'; - } - - if (arg.type === 'positional') { - description += ' [positional argument]'; - } - - if (arg.examples && arg.examples.length > 0) { - description += ` (examples: ${arg.examples.join(', ')})`; - } - - return `${paddedOptionPart}${description}`; -} +}; /** - * Show help for a command at any nesting level + * Load module dynamically (for npm distribution) */ -function showCommandHelp(command: CommandSpec, pathParts: string[]) { - const fullPath = pathParts.join(' '); - console.log(`\n${specs.name} ${fullPath} - ${command.description}\n`); - - if (command.commands && command.commands.length > 0) { - const availableCmds = command.commands.filter((cmd) => - commandHasAnyImplementation(cmd, [...pathParts, cmd.name]) - ); - - if (availableCmds.length > 0) { - console.log('Commands:'); - availableCmds.forEach((cmd) => { - let cmdPart = ` ${cmd.name}`; - if (cmd.alias) { - const aliases = Array.isArray(cmd.alias) ? cmd.alias : [cmd.alias]; - cmdPart += ` (${aliases.join(', ')})`; - } - const paddedCmdPart = cmdPart.padEnd(24); - console.log(`${paddedCmdPart}${cmd.description}`); - }); - console.log(); - } - } - - if (command.arguments && command.arguments.length > 0) { - console.log('Arguments:'); - command.arguments.forEach((arg) => { - console.log(formatArgumentHelp(arg)); - }); - console.log(); - } - - if (command.examples && command.examples.length > 0) { - console.log('Examples:'); - command.examples.forEach((ex) => { - console.log(` ${ex}`); - }); - console.log(); - } - - if (command.commands && command.commands.length > 0) { - console.log( - `Use "${specs.name} ${fullPath} help" for more information about a command.` - ); - } -} - -/** - * Recursively check if a command or any of its children have implementations - */ -function commandHasAnyImplementation( - command: CommandSpec, - pathParts: string[] -): boolean { - // Check if this command itself has implementation (leaf node) - if (hasImplementation(pathParts)) { - return true; - } - - // Check if any child command has implementation - if (command.commands) { - return command.commands.some((child) => - commandHasAnyImplementation(child, [...pathParts, child.name]) - ); - } - - return false; -} - -function showMainHelp() { - console.log(`Tigris CLI Version: ${version}\n`); - console.log('Usage: tigris [command] [options]\n'); - console.log('Commands:'); - - const availableCommands = specs.commands.filter((cmd) => - commandHasAnyImplementation(cmd, [cmd.name]) - ); - - availableCommands.forEach((command: CommandSpec) => { - let commandPart = ` ${command.name}`; - if (command.alias) { - const aliases = Array.isArray(command.alias) - ? command.alias - : [command.alias]; - commandPart += ` (${aliases.join(', ')})`; - } - const paddedCommandPart = commandPart.padEnd(24); - console.log(`${paddedCommandPart}${command.description}`); - }); - console.log( - `\nUse "${specs.name} help" for more information about a command.` - ); -} - -function addArgumentsToCommand(cmd: CommanderCommand, args: Argument[] = []) { - args.forEach((arg) => { - if (arg.type === 'positional') { - // Handle positional arguments - const argumentName = arg.required ? `<${arg.name}>` : `[${arg.name}]`; - cmd.argument(argumentName, arg.description); - } else { - // Handle regular flag/option arguments - // Commander expects single-character short options: -p, --prefix - // Multi-character aliases are not supported by Commander - const hasValidShortOption = - arg.alias && typeof arg.alias === 'string' && arg.alias.length === 1; - let optionString = hasValidShortOption - ? `-${arg.alias}, --${arg.name}` - : `--${arg.name}`; - - if (arg.type === 'flag') { - // Flags don't take values - presence means true - } else if (arg.type === 'boolean') { - // Boolean options take optional value, defaulting to true when present without value - optionString += ' [value]'; - } else if (arg.options) { - optionString += ' '; - } else { - optionString += - arg.required || arg['required-when'] ? ' ' : ' [value]'; - } - - cmd.option(optionString, arg.description, arg.default); - } - }); -} - -function validateRequiredWhen( - args: Argument[], - options: Record -): boolean { - for (const arg of args) { - if (arg['required-when']) { - const [dependentArg, expectedValue] = arg['required-when'].split('='); - - const dependentValue = getOptionValue(options, dependentArg, args); - const currentValue = getOptionValue(options, arg.name, args); - - if (dependentValue === expectedValue && !currentValue) { - console.error( - `--${arg.name} is required when --${dependentArg} is ${expectedValue}` - ); - return false; - } - } - - if (arg.required && !getOptionValue(options, arg.name, args)) { - console.error(`--${arg.name} is required`); - return false; - } - } - return true; -} - -function getOptionValue( - options: Record, - argName: string, - args?: Argument[] -): unknown { - if (args) { - const argDef = args.find((a) => a.name === argName); - if (argDef && argDef.alias && typeof argDef.alias === 'string') { - const aliasKey = - argDef.alias.charAt(0).toUpperCase() + argDef.alias.slice(1); - if (options[aliasKey] !== undefined) { - return options[aliasKey]; - } - } - } - - const possibleKeys = [ - argName, - argName.replace(/-/g, ''), - argName.replace(/-/g, '').toLowerCase(), - argName.charAt(0).toUpperCase(), - camelCase(argName), - ]; - - for (const key of possibleKeys) { - if (options[key] !== undefined) { - return options[key]; - } - } - return undefined; -} - -function camelCase(str: string): string { - return str.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()); -} - -/** - * Load module from path parts - * @param pathParts - Array of command path parts, e.g., ['iam', 'policies', 'get'] - */ -async function loadModule( - pathParts: string[] -): Promise<{ module: Record | null; error: string | null }> { +const loadModule: ModuleLoader = async (pathParts) => { const paths = [ `./lib/${pathParts.join('/')}.js`, `./lib/${pathParts.join('/')}/index.js`, @@ -327,235 +53,13 @@ async function loadModule( const cmdDisplay = pathParts.join(' '); return { module: null, error: `Command not found: ${cmdDisplay}` }; -} - -/** - * Load and execute a command - * @param pathParts - Array of command path parts - */ -async function loadAndExecuteCommand( - pathParts: string[], - positionalArgs: string[] = [], - options: Record = {}, - message?: string -) { - // Display message if available - if (message) { - const formattedMessage = message.replace(/\\n/g, '\n'); - console.log(formattedMessage); - } - - // Load module - const { module, error: loadError } = await loadModule(pathParts); - - if (loadError || !module) { - console.error(loadError); - process.exit(1); - } - - // Get command function - use default export or named export matching last path part - const functionName = pathParts[pathParts.length - 1]; - const commandFunction = module.default || module[functionName]; - - if (typeof commandFunction !== 'function') { - console.error(`Command not implemented: ${pathParts.join(' ')}`); - process.exit(1); - } - - // Execute command - let errors propagate naturally - await commandFunction({ ...options, _positional: positionalArgs }); -} - -function extractArgumentValues( - args: Argument[], - positionalArgs: string[], - commandOrOptions: Record -): Record { - // If this is a Commander Command object, extract options - // Use optsWithGlobals() to include parent command options (for subcommands) - let options: Record; - - if ( - 'optsWithGlobals' in commandOrOptions && - typeof commandOrOptions.optsWithGlobals === 'function' - ) { - // optsWithGlobals includes options from parent commands - options = ( - commandOrOptions.optsWithGlobals as () => Record - )(); - } else if ( - 'opts' in commandOrOptions && - typeof commandOrOptions.opts === 'function' - ) { - options = (commandOrOptions.opts as () => Record)(); - } else { - options = commandOrOptions; - } - - const result = { ...options }; - - // Map positional arguments to their names - const positionalArgDefs = args.filter((arg) => arg.type === 'positional'); - positionalArgDefs.forEach((arg, index) => { - if (positionalArgs[index] !== undefined) { - if (arg.multiple) { - // For multiple arguments, split by comma - result[arg.name] = positionalArgs[index] - .split(',') - .map((s) => s.trim()); - } else { - result[arg.name] = positionalArgs[index]; - } - } - }); - - // Handle multiple flag arguments - args.forEach((arg) => { - if (arg.multiple && arg.type !== 'positional' && result[arg.name]) { - if (typeof result[arg.name] === 'string') { - result[arg.name] = (result[arg.name] as string) - .split(',') - .map((s) => s.trim()); - } - } - }); - - return result; -} - -/** - * Recursively register commands from spec - */ -function registerCommands( - parent: CommanderCommand, - commandSpecs: CommandSpec[], - pathParts: string[] = [] -) { - for (const spec of commandSpecs) { - // Validate command name to prevent path traversal - if (!isValidCommandName(spec.name)) { - console.error( - `Invalid command name "${spec.name}": only alphanumeric, hyphens, and underscores allowed` - ); - process.exit(1); - } - - const currentPath = [...pathParts, spec.name]; - const cmd = parent.command(spec.name).description(spec.description); - - // Handle aliases - if (spec.alias) { - const aliases = Array.isArray(spec.alias) ? spec.alias : [spec.alias]; - aliases.forEach((alias) => cmd.alias(alias)); - } - - // Check if this command has children - if (spec.commands && spec.commands.length > 0) { - // Has children - recurse - registerCommands(cmd, spec.commands, currentPath); - - // Check for default command - if (spec.default) { - const defaultCmd = spec.commands.find((c) => c.name === spec.default); - if (defaultCmd) { - // Add arguments from both parent and default child - addArgumentsToCommand(cmd, spec.arguments); - addArgumentsToCommand(cmd, defaultCmd.arguments); - - const allArguments = [ - ...(spec.arguments || []), - ...(defaultCmd.arguments || []), - ]; - - cmd.action(async (...args) => { - const options = args.pop(); - const positionalArgs = args; - - if ( - allArguments.length > 0 && - !validateRequiredWhen( - allArguments, - extractArgumentValues(allArguments, positionalArgs, options) - ) - ) { - return; - } - - await loadAndExecuteCommand( - [...currentPath, defaultCmd.name], - positionalArgs, - extractArgumentValues(allArguments, positionalArgs, options), - spec.message || defaultCmd.message - ); - }); - } - } else { - // No default - show help when command is called without subcommand - cmd.action(() => { - showCommandHelp(spec, currentPath); - }); - } - - // Add help subcommand - cmd - .command('help') - .description('Show help for this command') - .action(() => { - showCommandHelp(spec, currentPath); - }); - } else { - // Leaf node - this is an executable command - addArgumentsToCommand(cmd, spec.arguments); - - cmd.action(async (...args) => { - const options = args.pop(); - const positionalArgs = args; - - if ( - spec.arguments && - !validateRequiredWhen( - spec.arguments, - extractArgumentValues(spec.arguments, positionalArgs, options) - ) - ) { - return; - } - - await loadAndExecuteCommand( - currentPath, - positionalArgs, - extractArgumentValues(spec.arguments || [], positionalArgs, options), - spec.message - ); - }); - - // Add help for leaf commands too - cmd - .command('help') - .description('Show help for this command') - .action(() => { - showCommandHelp(spec, currentPath); - }); - } - } -} - -const program = new CommanderCommand(); - -program.name(specs.name).description(specs.description).version(specs.version); - -// Register all commands recursively -registerCommands(program, specs.commands); - -program - .command('help') - .description('Show general help') - .action(() => { - showMainHelp(); - }); +}; -program.action(() => { - showMainHelp(); +const program = createProgram({ + specs, + version, + loadModule, + hasImplementation, }); program.parse(); diff --git a/src/specs-embedded.ts b/src/specs-embedded.ts new file mode 100644 index 0000000..f10807b --- /dev/null +++ b/src/specs-embedded.ts @@ -0,0 +1,15 @@ +// Embedded specs for binary builds — avoids readFileSync at runtime. +// Only exports loadSpecs(). Helper functions are in utils/specs.ts. + +import specsYaml from './specs.yaml' with { type: 'text' }; +import * as YAML from 'yaml'; +import type { Specs } from './types.js'; + +let cachedSpecs: Specs | null = null; + +export function loadSpecs(): Specs { + if (!cachedSpecs) { + cachedSpecs = YAML.parse(specsYaml, { schema: 'core' }); + } + return cachedSpecs!; +} diff --git a/src/utils/specs.ts b/src/utils/specs.ts index 16d8ac0..0c3b4a5 100644 --- a/src/utils/specs.ts +++ b/src/utils/specs.ts @@ -11,6 +11,13 @@ let cachedSpecs: Specs | null = null; const specsPath = join(__dirname, 'specs.yaml'); +/** + * Pre-populate the specs cache (used by binary builds where readFileSync is unavailable). + */ +export function setSpecs(specs: Specs): void { + cachedSpecs = specs; +} + export function loadSpecs(): Specs { if (!cachedSpecs) { const specsContent = readFileSync(specsPath, 'utf8'); diff --git a/src/utils/update-check.ts b/src/utils/update-check.ts index 36c94a9..3b4a7a2 100644 --- a/src/utils/update-check.ts +++ b/src/utils/update-check.ts @@ -117,8 +117,20 @@ export function checkForUpdates(): void { !cache.lastNotified || Date.now() - cache.lastNotified > notifyIntervalMs ) { + const isBinary = + (globalThis as { __TIGRIS_BINARY?: boolean }).__TIGRIS_BINARY === true; + const isWindows = process.platform === 'win32'; const line1 = `Update available: ${currentVersion} → ${cache.latestVersion}`; - const line2 = 'Run `npm install -g @tigrisdata/cli` to upgrade.'; + let line2: string; + if (!isBinary) { + line2 = 'Run `npm install -g @tigrisdata/cli` to upgrade.'; + } else if (isWindows) { + line2 = + 'Run `irm https://raw.githubusercontent.com/tigrisdata/cli/main/scripts/install.ps1 | iex`'; + } else { + line2 = + 'Run `curl -fsSL https://raw.githubusercontent.com/tigrisdata/cli/main/scripts/install.sh | sh`'; + } const width = Math.max(line1.length, line2.length) + 4; const top = '┌' + '─'.repeat(width - 2) + '┐'; const bot = '└' + '─'.repeat(width - 2) + '┘'; diff --git a/tsup.config.ts b/tsup.config.ts index fe6a5f6..bb3109b 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -10,7 +10,14 @@ const copySpecs = () => { }; export default defineConfig((options) => ({ - entry: ['src/cli.ts', 'src/**/*.ts'], + entry: [ + 'src/cli.ts', + 'src/**/*.ts', + // Exclude binary-only files (use bun-specific import syntax) + '!src/cli-binary.ts', + '!src/specs-embedded.ts', + '!src/command-registry.ts', + ], format: ['esm'], dts: false, splitting: true, From cc3ccb74d4a522897bdbe0e451cec6128a3e1701 Mon Sep 17 00:00:00 2001 From: A Ibrahim Date: Thu, 19 Feb 2026 10:47:46 +0100 Subject: [PATCH 2/2] feat: single binary using bun --- .github/workflows/release.yaml | 55 ++++++++++++++++++++++++++++++--- src/utils/update-check.ts | 42 ++++++++++++++++++++----- test/utils/update-check.test.ts | 33 ++++++++++++++++++++ 3 files changed, 119 insertions(+), 11 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index abb5936..6f73d2a 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -30,12 +30,12 @@ jobs: with: node-version: '22' - # Install dependencies and build Node.js project + # Install dependencies, test, and build - run: npm ci - run: npm run lint - run: npm run build + - run: npm run test - run: npm run publint - - run: npm audit signatures # Release to GitHub and NPM @@ -46,7 +46,7 @@ jobs: run: | npm run semantic-release git fetch --tags - TAG=$(git tag --points-at HEAD | grep "^v" | head -1) + TAG=$(git tag --points-at HEAD | grep "^v" | head -1 || true) echo "new_release_version=${TAG#v}" >> $GITHUB_OUTPUT echo "new_release_published=$( [ -n "$TAG" ] && echo true || echo false )" >> $GITHUB_OUTPUT @@ -75,6 +75,28 @@ jobs: - name: Build binaries for all platforms run: npm run build:binary + - name: Verify binaries exist + run: | + cd bin + MISSING=0 + for f in tigris-darwin-arm64 tigris-darwin-x64 tigris-linux-x64 tigris-linux-arm64; do + if [ ! -f "$f" ]; then + echo "ERROR: Missing binary: $f" + MISSING=1 + fi + done + if [ ! -f "tigris-windows-x64.exe" ]; then + echo "ERROR: Missing binary: tigris-windows-x64.exe" + MISSING=1 + fi + if [ "$MISSING" -eq 1 ]; then + echo "Binary build incomplete. Contents:" + ls -la + exit 1 + fi + echo "All binaries present:" + ls -la + - name: Package archives run: | cd bin @@ -88,7 +110,15 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | TAG="v${{ needs.release.outputs.new_release_version }}" - gh release upload "$TAG" \ + + # Verify release exists + if ! gh release view "$TAG" > /dev/null 2>&1; then + echo "ERROR: Release $TAG does not exist" + exit 1 + fi + + # Upload with retry + for asset in \ bin/tigris-darwin-arm64.tar.gz \ bin/tigris-darwin-x64.tar.gz \ bin/tigris-linux-x64.tar.gz \ @@ -96,3 +126,20 @@ jobs: bin/tigris-windows-x64.zip \ scripts/install.sh \ scripts/install.ps1 + do + echo "Uploading $asset..." + for attempt in 1 2 3; do + if gh release upload "$TAG" "$asset" --clobber; then + echo " Uploaded successfully" + break + fi + if [ "$attempt" -eq 3 ]; then + echo " ERROR: Failed to upload after 3 attempts" + exit 1 + fi + echo " Retry $((attempt + 1))..." + sleep 5 + done + done + + echo "All assets uploaded to $TAG" diff --git a/src/utils/update-check.ts b/src/utils/update-check.ts index 3b4a7a2..4cb6143 100644 --- a/src/utils/update-check.ts +++ b/src/utils/update-check.ts @@ -43,23 +43,51 @@ function writeUpdateCache(cache: UpdateCheckCache): void { } export function isNewerVersion(current: string, latest: string): boolean { - const parse = (v: string): number[] | null => { - const cleaned = v.startsWith('v') ? v.slice(1) : v; + const parse = ( + v: string + ): { + major: number; + minor: number; + patch: number; + prerelease: string | null; + } | null => { + let cleaned = v.startsWith('v') ? v.slice(1) : v; + + // Split off prerelease suffix (e.g., "1.2.3-alpha.1" -> "1.2.3" + "alpha.1") + let prerelease: string | null = null; + const dashIndex = cleaned.indexOf('-'); + if (dashIndex !== -1) { + prerelease = cleaned.slice(dashIndex + 1); + cleaned = cleaned.slice(0, dashIndex); + } + const parts = cleaned.split('.'); if (parts.length !== 3) return null; + const nums = parts.map(Number); if (nums.some(isNaN)) return null; - return nums; + + return { major: nums[0], minor: nums[1], patch: nums[2], prerelease }; }; const cur = parse(current); const lat = parse(latest); if (!cur || !lat) return false; - for (let i = 0; i < 3; i++) { - if (lat[i] > cur[i]) return true; - if (lat[i] < cur[i]) return false; - } + // Compare major.minor.patch + if (lat.major > cur.major) return true; + if (lat.major < cur.major) return false; + if (lat.minor > cur.minor) return true; + if (lat.minor < cur.minor) return false; + if (lat.patch > cur.patch) return true; + if (lat.patch < cur.patch) return false; + + // Same version number - compare prerelease + // A stable release (no prerelease) is newer than a prerelease + if (cur.prerelease && !lat.prerelease) return true; + // A prerelease is not newer than a stable release + if (!cur.prerelease && lat.prerelease) return false; + // Both are prereleases or both are stable with same version return false; } diff --git a/test/utils/update-check.test.ts b/test/utils/update-check.test.ts index bc1c52f..41158f6 100644 --- a/test/utils/update-check.test.ts +++ b/test/utils/update-check.test.ts @@ -73,4 +73,37 @@ describe('isNewerVersion', () => { expect(isNewerVersion('1.0.0', '1.0.100')).toBe(true); expect(isNewerVersion('1.99.0', '2.0.0')).toBe(true); }); + + // Prerelease version tests + it('should parse prerelease versions correctly', () => { + expect(isNewerVersion('1.0.0-alpha.1', '1.0.1')).toBe(true); + expect(isNewerVersion('1.0.0-beta.2', '2.0.0')).toBe(true); + }); + + it('should consider stable newer than same-version prerelease', () => { + expect(isNewerVersion('1.0.0-alpha.1', '1.0.0')).toBe(true); + expect(isNewerVersion('1.0.0-beta.5', '1.0.0')).toBe(true); + expect(isNewerVersion('1.2.3-rc.1', '1.2.3')).toBe(true); + }); + + it('should not consider prerelease newer than stable of same version', () => { + expect(isNewerVersion('1.0.0', '1.0.0-alpha.1')).toBe(false); + expect(isNewerVersion('1.0.0', '1.0.0-beta.5')).toBe(false); + }); + + it('should handle prerelease to prerelease of same version', () => { + // Same base version, both prereleases - neither is "newer" + expect(isNewerVersion('1.0.0-alpha.1', '1.0.0-alpha.2')).toBe(false); + expect(isNewerVersion('1.0.0-alpha.1', '1.0.0-beta.1')).toBe(false); + }); + + it('should handle newer base version even with prerelease', () => { + expect(isNewerVersion('1.0.0-alpha.1', '1.0.1-alpha.1')).toBe(true); + expect(isNewerVersion('1.0.0', '1.0.1-alpha.1')).toBe(true); + }); + + it('should handle v prefix with prereleases', () => { + expect(isNewerVersion('v1.0.0-alpha.1', 'v1.0.0')).toBe(true); + expect(isNewerVersion('v1.0.0-beta.1', '1.0.0')).toBe(true); + }); });