diff --git a/CHANGELOG.md b/CHANGELOG.md index f39241d..8ae586c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.1.2] - 2026-02-11 + +### Added + +- Colored install script output with FLN_SILENT to disable +- Install script validation (version, directory, file size limits) +- PATH detection and shell-specific instructions in install scripts + +### Fixed + +- Correct gitignore check for directories in scanTree (trailing slash) +- Extend NO_COLOR support to any non-empty value per spec + +### Changed + +- Replace TypeScript path aliases with relative imports +- CLI entry point moved from `main.ts` to `index.ts` +- Improved error handling with optional DEBUG stack trace +- Install URLs updated to fln.nesvet.dev +- Add `pom.xml` to manifest list in docs + ## [1.1.1] - 2026-02-10 ### Added @@ -41,6 +62,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Cross-platform shell installers with SHA256 verification (macOS, Linux, Windows) - Comprehensive test suite -[Unreleased]: https://github.com/nesvet/fln/compare/1.1.1...HEAD +[Unreleased]: https://github.com/nesvet/fln/compare/1.1.2...HEAD +[1.1.2]: https://github.com/nesvet/fln/compare/1.1.1...1.1.2 [1.1.1]: https://github.com/nesvet/fln/compare/1.0.0...1.1.1 [1.0.0]: https://github.com/nesvet/fln/releases/tag/1.0.0 diff --git a/README.md b/README.md index cf0dfb6..8717cd6 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ npx fln . -o codebase.md Works with **Claude**, **ChatGPT**, **Gemini**, **Grok**, **Cursor**, **Copilot**, and *any* AI tool. -**`fln`** (short for *flatten*) is **language-agnostic** by design: TypeScript, Python, Java, Go, Rust, Bash, SQL, mixed monorepos β€” it treats everything as plain text, detects project metadata from common manifests (`package.json`, `pyproject.toml`, `go.mod`, `Cargo.toml`, `CMakeLists.txt`, `vcpkg.json`), respects `.gitignore`, and skips binaries by default. +**`fln`** (short for *flatten*) is **language-agnostic** by design: TypeScript, Python, Java, Go, Rust, Bash, SQL, mixed monorepos β€” it treats everything as plain text, detects project metadata from common manifests (`package.json`, `pyproject.toml`, `pom.xml`, `go.mod`, `Cargo.toml`, `CMakeLists.txt`, `vcpkg.json`), respects `.gitignore`, and skips binaries by default. ## Why fln exists @@ -80,17 +80,23 @@ Zero dependencies on external services. Zero tracking. Just a tool that does its ## Install +##### npm ```bash -# npm npm install -g fln +``` -# one-liner (macOS/Linux) -curl -fsSL "https://raw.githubusercontent.com/nesvet/fln/main/install.sh" | sh +##### Linux & macOS ([view install script](./install.sh)) +```bash +curl -fsSL https://fln.nesvet.dev/install | sh +``` -# one-liner (Windows) -irm "https://raw.githubusercontent.com/nesvet/fln/main/install.ps1" | iex +##### Windows ([view install script](./install.ps1)) +```bash +powershell -c "irm fln.nesvet.dev/install.ps1 | iex" +``` -# or just run without installing +##### Or just run without installing +```bash npx fln . -o codebase.md ``` @@ -102,7 +108,7 @@ npx fln . -o codebase.md Pin a version or custom install directory: ```bash -curl -fsSL "https://raw.githubusercontent.com/nesvet/fln/main/install.sh" | FLN_VERSION="" INSTALL_DIR="$HOME/.local/bin" sh +curl -fsSL "https://fln.nesvet.dev/install" | FLN_VERSION="" INSTALL_DIR="$HOME/.local/bin" sh ``` ### One-line installer options (Windows PowerShell) @@ -110,7 +116,7 @@ curl -fsSL "https://raw.githubusercontent.com/nesvet/fln/main/install.sh" | FLN_ ```powershell $env:FLN_VERSION = "" $env:INSTALL_DIR = "$env:LOCALAPPDATA\\fln\\bin" -irm "https://raw.githubusercontent.com/nesvet/fln/main/install.ps1" | iex +powershell -c "irm fln.nesvet.dev/install.ps1 | iex" ``` ### Manual download (GitHub Releases) @@ -118,7 +124,6 @@ irm "https://raw.githubusercontent.com/nesvet/fln/main/install.ps1" | iex ```bash curl -L "https://github.com/nesvet/fln/releases/latest/download/fln-macos-x64.tar.gz" | tar -xz -C /usr/local/bin chmod +x /usr/local/bin/fln -fln --help ``` @@ -169,7 +174,7 @@ fln . -o codebase.md -w - `--follow-symlinks` Follow symlinks - `--no-ansi` Disable ANSI colors - `--no-sponsor-message` Hide support message (also: `FLN_NO_SPONSOR=1`) -- `--generated-date ` Use this date in the "Generated" header (format: `YYYY-MM-DD HH:mm`) +- `--generated-date ` Use this date in the β€œGenerated” header (format: `YYYY-MM-DD HH:mm`) - `--banner ` Add text at the beginning - `--footer ` Add text at the end of the output - `-q, --quiet` Minimal output @@ -288,7 +293,7 @@ All CLI options are available via `FlnOptions`.
Output naming & formats -- Uses project name + version if available (`package.json`, `pyproject.toml`, `Cargo.toml`, `vcpkg.json`, `go.mod`, or `CMakeLists.txt`) +- Uses project name + version if available (`package.json`, `pyproject.toml`, `pom.xml`, `go.mod`, `Cargo.toml`, `CMakeLists.txt` or `vcpkg.json`) - `md` includes tree + contents - `json` includes `rootDirectory`, `tree`, `stats` diff --git a/examples/go-app.md b/examples/go-app.md index 805aacb..01e3f05 100644 --- a/examples/go-app.md +++ b/examples/go-app.md @@ -1,4 +1,4 @@ - + # Codebase Snapshot: go-app diff --git a/examples/java-app.md b/examples/java-app.md index 3586932..3df3ecf 100644 --- a/examples/java-app.md +++ b/examples/java-app.md @@ -1,4 +1,4 @@ - + # Codebase Snapshot: java-app diff --git a/examples/python-app.md b/examples/python-app.md index 1a267ee..cae8463 100644 --- a/examples/python-app.md +++ b/examples/python-app.md @@ -1,4 +1,4 @@ - + # Codebase Snapshot: python-app diff --git a/examples/rust-app.md b/examples/rust-app.md index d07ac4c..fc0815d 100644 --- a/examples/rust-app.md +++ b/examples/rust-app.md @@ -1,4 +1,4 @@ - + # Codebase Snapshot: rust-app diff --git a/examples/ts-app.md b/examples/ts-app.md index 29bb74a..98fb0d3 100644 --- a/examples/ts-app.md +++ b/examples/ts-app.md @@ -1,4 +1,4 @@ - + # Codebase Snapshot: ts-app diff --git a/install.ps1 b/install.ps1 index 8d01fd5..6a27a92 100644 --- a/install.ps1 +++ b/install.ps1 @@ -1,4 +1,8 @@ $ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 $repository = "nesvet/fln" $version = $env:FLN_VERSION @@ -8,22 +12,243 @@ if (-not $version) { $installDirectory = $env:INSTALL_DIR if (-not $installDirectory) { - $installDirectory = Join-Path $env:LOCALAPPDATA "fln\\bin" + $installDirectory = Join-Path $env:LOCALAPPDATA "fln\bin" +} + +$maxAssetSizeBytes = 100MB +$maxChecksumSizeBytes = 8KB + +$ansiReset = [char]27 + "[0m" +$ansiGreen = [char]27 + "[32m" +$ansiGreenBold = [char]27 + "[1;32m" +$ansiBold = [char]27 + "[1m" + +function Write-FlnMessage { + param( + [string]$Prefix, + [string]$Message, + [string]$Color = "White" + ) + + if ($env:FLN_SILENT -eq "1") { + Write-Output $Message + return + } + + if ($Prefix) { + Write-Host $Prefix -ForegroundColor $Color -NoNewline + Write-Host " $Message" + } else { + Write-Host $Message -ForegroundColor $Color + } +} + +function Write-FlnInfo { + param([string]$Message) + Write-FlnMessage "β„Ή" $Message "Cyan" +} + +function Write-FlnSuccess { + param([string]$Message) + Write-FlnMessage "βœ“" $Message "Green" +} + +function Write-FlnWarning { + param([string]$Message) + Write-FlnMessage "⚠" $Message "Yellow" +} + +function Write-FlnError { + param([string]$Message) + Write-FlnMessage "βœ—" $Message "Red" +} + +function Stop-FlnInstall { + param([string]$Message) + Write-FlnError $Message + exit 1 +} + +function Test-FlnVersion { + param([string]$Value) + return $Value -match "^(latest|[A-Za-z0-9._-]+)$" +} + +function Resolve-FlnInstallDirectory { + param([string]$Directory) + + if ($Directory -match '[`";|&<>\r\n]') { + Stop-FlnInstall "Invalid INSTALL_DIR value: contains unsafe characters." + } + + try { + $resolved = [System.IO.Path]::GetFullPath($Directory) + New-Item -ItemType Directory -Path $resolved -Force | Out-Null + return $resolved + } catch { + Stop-FlnInstall "Unable to prepare install directory: $Directory" + } +} + +function Get-FlnLatestVersion { + param([string]$Repository) + + try { + $response = Invoke-RestMethod -Uri "https://api.github.com/repos/$Repository/releases/latest" -Headers @{ "User-Agent" = "fln-installer" } -ErrorAction Stop + $tagName = "$($response.tag_name)" -replace "^v", "" + if ([string]::IsNullOrWhiteSpace($tagName)) { + return "latest" + } + if ($tagName -notmatch "^[A-Za-z0-9._-]+$") { + return "latest" + } + return $tagName + } catch { + return "latest" + } +} + +function Get-FlnDisplayVersion { + param( + [string]$Repository, + [string]$Version + ) + + if ($Version -eq "latest") { + $actualVersion = Get-FlnLatestVersion -Repository $Repository + if ($actualVersion -eq "latest") { + if ($env:FLN_SILENT -ne "1") { + Write-FlnWarning "Unable to resolve latest version from GitHub API. Continuing with latest channel." + } + return "(latest)" + } + return $actualVersion + } + + return $Version +} + +function Normalize-FlnPath { + param([string]$Value) + return $Value.Trim().TrimEnd("\").ToLowerInvariant() +} + +function Test-FlnPathContains { + param([string]$Directory) + + $normalizedDirectory = Normalize-FlnPath -Value $Directory + $userPath = [Environment]::GetEnvironmentVariable("Path", "User") + $machinePath = [Environment]::GetEnvironmentVariable("Path", "Machine") + $combinedPath = ($userPath, $machinePath) -join ";" + + $pathItems = $combinedPath.Split(";") | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } + foreach ($pathItem in $pathItems) { + if ((Normalize-FlnPath -Value $pathItem) -eq $normalizedDirectory) { + return $true + } + } + return $false +} + +function Show-FlnPathInstructions { + param([string]$Directory) + + Write-FlnInfo "fln is not in your PATH." + Write-FlnInfo "Add the install directory to your PATH environment variable." + Write-Host "" + Write-Host "To add fln to your user PATH, run in PowerShell:" -ForegroundColor Cyan + Write-Host "" + Write-Host " [Environment]::SetEnvironmentVariable(" -ForegroundColor White + Write-Host " ""Path""," -ForegroundColor White + Write-Host " [Environment]::GetEnvironmentVariable(""Path"", ""User"") + "";$Directory""," -ForegroundColor White + Write-Host " ""User""" -ForegroundColor White + Write-Host " )" -ForegroundColor White + Write-Host "" + Write-Host "Then restart your terminal." -ForegroundColor Cyan + Write-Host "" +} + +function Test-FlnFileSizeLimit { + param( + [string]$Path, + [long]$MaxBytes, + [string]$Label = $null + ) + + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { + $displayLabel = if ($Label) { $Label } else { "Downloaded file" } + Stop-FlnInstall "$displayLabel is missing after download." + } + + $fileSize = (Get-Item -LiteralPath $Path).Length + if ($fileSize -gt $MaxBytes) { + $displayLabel = if ($Label) { $Label } else { "Downloaded file" } + Stop-FlnInstall "$displayLabel is too large ($fileSize bytes)." + } +} + +function Invoke-FlnDownloadWithProgress { + param( + [string]$Uri, + [string]$DestinationPath, + [string]$Label = $null, + [long]$MaxBytes + ) + + if ($Label) { + Write-FlnInfo $Label + } + + try { + $curlExe = Get-Command curl.exe -ErrorAction SilentlyContinue + if ($curlExe) { + & $curlExe.Source "-#SfLo" $DestinationPath $Uri + if ($LASTEXITCODE -ne 0) { + throw "curl.exe exited with code $LASTEXITCODE" + } + } else { + throw "curl.exe not found" + } + } catch { + try { + Invoke-RestMethod -Uri $Uri -OutFile $DestinationPath -ErrorAction Stop + } catch { + $displayLabel = if ($Label) { $Label } else { "File" } + Stop-FlnInstall "Download failed for $displayLabel." + } + } + Test-FlnFileSizeLimit -Path $DestinationPath -MaxBytes $MaxBytes -Label $Label +} + +if (-not (Test-FlnVersion -Value $version)) { + Stop-FlnInstall "Invalid FLN_VERSION value: $version" +} + +$installDirectory = Resolve-FlnInstallDirectory -Directory $installDirectory +$displayVersion = Get-FlnDisplayVersion -Repository $repository -Version $version + +try { + $architecture = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString().ToLowerInvariant() +} catch { + $proc = $env:PROCESSOR_ARCHITECTURE + $architecture = switch ($proc) { + "AMD64" { "x64" } + "ARM64" { "arm64" } + "x86" { if ([Environment]::Is64BitOperatingSystem) { "x64" } else { "x86" } } + default { "x64" } + } } -$architecture = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString().ToLowerInvariant() switch ($architecture) { "x64" { $asset = "fln-windows-x64.zip" } "arm64" { - Write-Output "Note: Installing x64 build on ARM64 Windows (will run via emulation)" $asset = "fln-windows-x64.zip" + Write-FlnWarning "ARM64 detected. Using x64 build (will run through emulation)." } default { - Write-Output "Unsupported architecture: $architecture" - Write-Output "Only x64 builds are available for Windows." - exit 1 + Stop-FlnInstall "Unsupported architecture: $architecture. Only x64 builds are available for Windows." } } @@ -34,39 +259,71 @@ if ($version -eq "latest") { $baseUrl = "https://github.com/$repository/releases/download/$releaseTag" } -$temporaryDirectory = New-Item -ItemType Directory -Path (Join-Path $env:TEMP ("fln-" + [Guid]::NewGuid().ToString("n"))) +$temporaryDirectory = Join-Path $env:TEMP "fln-$([Guid]::NewGuid().ToString('n'))" +New-Item -ItemType Directory -Path $temporaryDirectory -Force | Out-Null + try { $archivePath = Join-Path $temporaryDirectory $asset $checksumPath = "$archivePath.sha256" - Invoke-WebRequest -Uri "$baseUrl/$asset" -OutFile $archivePath - Invoke-WebRequest -Uri "$baseUrl/$asset.sha256" -OutFile $checksumPath + Invoke-FlnDownloadWithProgress -Uri "$baseUrl/$asset" -DestinationPath $archivePath -MaxBytes $maxAssetSizeBytes + + try { + Invoke-RestMethod -Uri "$baseUrl/$asset.sha256" -OutFile $checksumPath -ErrorAction Stop + } catch { + Stop-FlnInstall "Download failed for checksum file." + } + + Test-FlnFileSizeLimit -Path $checksumPath -MaxBytes $maxChecksumSizeBytes -Label "Checksum file" - $expectedHash = (Get-Content $checksumPath).Split(" ")[0].ToLowerInvariant() + $expectedHash = (Get-Content -LiteralPath $checksumPath -TotalCount 1).Split(" ")[0].Trim().ToLowerInvariant() $actualHash = (Get-FileHash -Algorithm SHA256 $archivePath).Hash.ToLowerInvariant() if ($expectedHash -ne $actualHash) { - Write-Output "Checksum mismatch. Aborting." - exit 1 + Stop-FlnInstall "Checksum mismatch. Expected: $expectedHash, got: $actualHash" } - Expand-Archive -Path $archivePath -DestinationPath $temporaryDirectory -Force + $previousProgressPreference = $global:ProgressPreference + try { + $global:ProgressPreference = "SilentlyContinue" + Expand-Archive -LiteralPath $archivePath -DestinationPath $temporaryDirectory -Force + $global:ProgressPreference = $previousProgressPreference + } catch { + $global:ProgressPreference = $previousProgressPreference + Add-Type -AssemblyName "System.IO.Compression.FileSystem" + [System.IO.Compression.ZipFile]::ExtractToDirectory($archivePath, $temporaryDirectory) + } + $binaryPath = Join-Path $temporaryDirectory "fln.exe" if (-not (Test-Path $binaryPath)) { - Write-Output "Binary not found in archive." - exit 1 + Stop-FlnInstall "Binary not found in archive." } New-Item -ItemType Directory -Path $installDirectory -Force | Out-Null $destinationPath = Join-Path $installDirectory "fln.exe" - Move-Item -Path $binaryPath -Destination $destinationPath -Force - - Write-Output "Installed fln to $destinationPath" + Move-Item -LiteralPath $binaryPath -Destination $destinationPath -Force - if (Get-Command fln -ErrorAction SilentlyContinue) { - Write-Output "Run: fln --help" + $shortPath = $destinationPath.Replace($env:USERPROFILE, "~") + if ($env:FLN_SILENT -eq "1") { + Write-Output "πŸ₯ž fln $displayVersion was installed successfully to $shortPath" + Write-Output "Run 'fln --help' to get started" } else { - Write-Output "Add $installDirectory to your PATH to use fln." + Write-Host "${ansiGreen}πŸ₯ž ${ansiGreenBold}fln${ansiReset}${ansiGreen} $displayVersion was installed successfully to ${ansiBold}$shortPath${ansiReset}" + Write-Host "Run 'fln --help' to get started" } + + if (-not (Get-Command fln -ErrorAction SilentlyContinue)) { + if (Test-FlnPathContains -Directory $installDirectory) { + Write-FlnInfo "fln is installed, but the current session does not see it yet." + Write-FlnInfo "Restart your terminal to refresh PATH." + Write-Host "" + } else { + Show-FlnPathInstructions -Directory $installDirectory + } + } +} catch { + Stop-FlnInstall $_.Exception.Message } finally { - Remove-Item -Path $temporaryDirectory -Recurse -Force + if ($null -ne $temporaryDirectory -and (Test-Path -LiteralPath $temporaryDirectory)) { + Remove-Item -LiteralPath $temporaryDirectory -Recurse -Force + } } diff --git a/install.sh b/install.sh index f40096e..e9d9648 100644 --- a/install.sh +++ b/install.sh @@ -4,9 +4,161 @@ set -eu repository="nesvet/fln" version="${FLN_VERSION:-latest}" installDirectory="${INSTALL_DIR:-$HOME/.local/bin}" +maxAssetSizeBytes="104857600" +maxChecksumSizeBytes="8192" +latestReleaseApiUrl="https://api.github.com/repos/${repository}/releases/latest" + +umask 077 + +disableColors() { + colorSuccess="" + colorWarning="" + colorError="" + colorInfo="" + colorBold="" + colorReset="" +} + +if [ -t 1 ] && [ "${FLN_SILENT:-0}" != "1" ]; then + if command -v tput >/dev/null 2>&1 && [ "$(tput colors 2>/dev/null || echo 0)" -ge 8 ]; then + colorSuccess="$(tput setaf 2)" + colorWarning="$(tput setaf 3)" + colorError="$(tput setaf 1)" + colorInfo="$(tput setaf 6)" + colorBold="$(tput bold)" + colorReset="$(tput sgr0)" + else + disableColors + fi +else + disableColors +fi + +printInfo() { + message="$1" + printf "%sβ„Ή%s %s\n" "$colorInfo" "$colorReset" "$message" +} + +printSuccess() { + message="$1" + printf "%sβœ“%s %s\n" "$colorSuccess" "$colorReset" "$message" +} + +printWarning() { + message="$1" + printf "%s⚠%s %s\n" "$colorWarning" "$colorReset" "$message" +} + +printError() { + message="$1" + printf "%sβœ—%s %s\n" "$colorError" "$colorReset" "$message" +} + +fail() { + message="$1" + printError "$message" + exit 1 +} + +validateVersion() { + case "$version" in + latest) + return + ;; + *[!A-Za-z0-9._-]*|"") + fail "Invalid FLN_VERSION value: ${version}" + ;; + esac +} + +validateInstallDirectory() { + sanitizedDirectory="$(printf "%s" "$installDirectory" | tr -d '`"$;&|<>()')" + if [ "$sanitizedDirectory" != "$installDirectory" ]; then + fail "Invalid INSTALL_DIR value: contains unsafe characters." + fi +} + +resolveInstallDirectory() { + targetDirectory="$1" + mkdir -p "$targetDirectory" >/dev/null 2>&1 || fail "Unable to create install directory: ${targetDirectory}" + resolvedPath="$(cd "$targetDirectory" 2>/dev/null && pwd -P)" || fail "Unable to resolve install directory: ${targetDirectory}" + printf "%s\n" "$resolvedPath" +} + +selectDownloader() { + if command -v curl >/dev/null 2>&1; then + printf "curl\n" + return + fi + if command -v wget >/dev/null 2>&1; then + printf "wget\n" + return + fi + fail "Missing downloader: curl or wget is required." +} + +fetchTextFromUrl() { + url="$1" + + case "$downloader" in + curl) + curl -fsSL --proto "=https" --tlsv1.2 "$url" 2>/dev/null || return 1 + ;; + wget) + wget -qO- "$url" 2>/dev/null || return 1 + ;; + esac +} + +parseLatestVersionTag() { + jsonPayload="$1" + + if command -v jq >/dev/null 2>&1; then + tagName="$(printf "%s" "$jsonPayload" | jq -r ".tag_name // empty" 2>/dev/null || echo "")" + else + tagName="$(printf "%s" "$jsonPayload" | tr -d "\r\n" | sed -n -E "s/.*\"tag_name\"[[:space:]]*:[[:space:]]*\"([^\"]+)\".*/\1/p")" + fi + + if [ -z "$tagName" ]; then + printf "latest\n" + return + fi + + case "$tagName" in + v*) + printf "%s\n" "${tagName#v}" + ;; + *) + printf "%s\n" "$tagName" + ;; + esac +} + +fetchLatestVersion() { + apiResponse="$(fetchTextFromUrl "$latestReleaseApiUrl" || echo "")" + if [ -z "$apiResponse" ]; then + printf "latest\n" + return + fi + parseLatestVersionTag "$apiResponse" +} + +enforceFileSizeLimit() { + path="$1" + maxBytes="$2" + label="${3:-Downloaded file}" + downloadedBytes="$(wc -c < "$path" | tr -d " ")" + if [ "$downloadedBytes" -gt "$maxBytes" ]; then + fail "${label} is too large (${downloadedBytes} bytes)." + fi +} operatingSystem="$(uname -s)" architecture="$(uname -m)" +validateVersion +validateInstallDirectory +installDirectory="$(resolveInstallDirectory "$installDirectory")" +downloader="$(selectDownloader)" case "$operatingSystem" in Darwin) @@ -16,8 +168,7 @@ case "$operatingSystem" in platform="linux" ;; *) - echo "Unsupported OS: $operatingSystem" - exit 1 + fail "Unsupported OS: $operatingSystem" ;; esac @@ -29,8 +180,8 @@ case "$architecture" in architecture="arm64" ;; *) - echo "Unsupported architecture: $architecture" - echo "Only x64 and arm64 builds are available." + printError "Unsupported architecture: $architecture" + printWarning "Only x64 and arm64 builds are available." exit 1 ;; esac @@ -39,9 +190,17 @@ asset="fln-${platform}-${architecture}.tar.gz" if [ "$version" = "latest" ]; then baseUrl="https://github.com/${repository}/releases/latest/download" + actualVersion="$(fetchLatestVersion)" + if [ "$actualVersion" = "latest" ]; then + displayVersion="(latest)" + printWarning "Unable to resolve latest version from GitHub API. Continuing with latest channel." + else + displayVersion="${actualVersion}" + fi else releaseTag="$version" baseUrl="https://github.com/${repository}/releases/download/${releaseTag}" + displayVersion="${version}" fi temporaryDirectory="$(mktemp -d)" @@ -53,51 +212,148 @@ trap cleanup EXIT download() { url="$1" destinationPath="$2" - if command -v curl >/dev/null 2>&1; then - curl -fsSL "$url" -o "$destinationPath" - return + maxBytes="$3" + + case "$downloader" in + curl) + if [ "${FLN_SILENT:-0}" = "1" ] || [ ! -t 1 ]; then + curl -fsSL --proto "=https" --tlsv1.2 "$url" -o "$destinationPath" || fail "Download failed via curl." + else + curl -fL --progress-bar --proto "=https" --tlsv1.2 "$url" -o "$destinationPath" || { + printf "\n" + fail "Download failed via curl." + } + fi + ;; + wget) + if [ "${FLN_SILENT:-0}" = "1" ] || [ ! -t 1 ]; then + wget -qO "$destinationPath" "$url" || fail "Download failed via wget." + else + wget --progress=bar:force:noscroll -O "$destinationPath" "$url" 2>&1 || fail "Download failed via wget." + fi + ;; + esac + + [ -f "$destinationPath" ] || fail "Downloaded file is missing: ${destinationPath}" + [ ! -L "$destinationPath" ] || fail "Refusing symlink file: ${destinationPath}" + enforceFileSizeLimit "$destinationPath" "$maxBytes" +} + +isInPath() { + directory="$1" + oldIFS=$IFS + IFS=":" + set -- $PATH + IFS=$oldIFS + + for pathEntry; do + if [ "$pathEntry" = "$directory" ]; then + return 0 + fi + done + + return 1 +} + +detectShellConfig() { + shellName="${SHELL##*/}" + + case "$shellName" in + zsh) + if [ -f "$HOME/.zshrc" ]; then + echo "$HOME/.zshrc" + elif [ -f "$HOME/.zprofile" ]; then + echo "$HOME/.zprofile" + else + echo "$HOME/.zshrc" + fi + ;; + bash) + if [ -f "$HOME/.bashrc" ]; then + echo "$HOME/.bashrc" + elif [ -f "$HOME/.bash_profile" ]; then + echo "$HOME/.bash_profile" + elif [ -f "$HOME/.profile" ]; then + echo "$HOME/.profile" + else + echo "$HOME/.bashrc" + fi + ;; + fish) + echo "$HOME/.config/fish/config.fish" + ;; + *) + echo "$HOME/.profile" + ;; + esac +} + +printPathInstructions() { + targetDirectory="$1" + configFile="$(detectShellConfig)" + shellName="${SHELL##*/}" + + printInfo "fln is not in your PATH." + printf "\n" + + if [ -f "$configFile" ]; then + printf "Add this line to %s:\n" "$configFile" + else + printf "Create %s and add this line:\n" "$configFile" fi - if command -v wget >/dev/null 2>&1; then - wget -qO "$destinationPath" "$url" - return + + if [ "$shellName" = "fish" ]; then + printf " set -gx PATH %s \$PATH\n" "$targetDirectory" + else + printf " export PATH=\"%s:\$PATH\"\n" "$targetDirectory" fi - echo "Missing downloader: curl or wget is required." - exit 1 + + printf "\n" + printf "Then reload your shell:\n source %s\n" "$configFile" + printf "\n" } -download "${baseUrl}/${asset}" "${temporaryDirectory}/${asset}" -download "${baseUrl}/${asset}.sha256" "${temporaryDirectory}/${asset}.sha256" +download "${baseUrl}/${asset}" "${temporaryDirectory}/${asset}" "$maxAssetSizeBytes" +download "${baseUrl}/${asset}.sha256" "${temporaryDirectory}/${asset}.sha256" "$maxChecksumSizeBytes" if command -v sha256sum >/dev/null 2>&1; then - set -- $(sha256sum "${temporaryDirectory}/${asset}") + actualHash="$(sha256sum "${temporaryDirectory}/${asset}" | awk "NR==1 {print \$1}")" else - set -- $(shasum -a 256 "${temporaryDirectory}/${asset}") + actualHash="$(shasum -a 256 "${temporaryDirectory}/${asset}" | awk "NR==1 {print \$1}")" fi -actualHash="$1" - -set -- $(cat "${temporaryDirectory}/${asset}.sha256") -expectedHash="$1" +expectedHash="$(awk "NR==1 {print \$1}" "${temporaryDirectory}/${asset}.sha256")" if [ "$actualHash" != "$expectedHash" ]; then - echo "Checksum mismatch. Aborting." - exit 1 + fail "Checksum mismatch. Expected: ${expectedHash}, got: ${actualHash}" fi -tar -xzf "${temporaryDirectory}/${asset}" -C "$temporaryDirectory" +tar -xzf "${temporaryDirectory}/${asset}" -C "$temporaryDirectory" || fail "Failed to extract archive." if [ ! -f "${temporaryDirectory}/fln" ]; then - echo "Binary not found in archive." - exit 1 + fail "Binary not found in archive." fi -mkdir -p "$installDirectory" -mv "${temporaryDirectory}/fln" "${installDirectory}/fln" -chmod +x "${installDirectory}/fln" +mkdir -p "$installDirectory" || fail "Failed to ensure install directory exists." +mv "${temporaryDirectory}/fln" "${installDirectory}/fln" || fail "Failed to move binary to install directory." +chmod +x "${installDirectory}/fln" || fail "Failed to make binary executable." -echo "Installed fln to ${installDirectory}/fln" +case "$installDirectory" in "$HOME"*) shortPath="~${installDirectory#$HOME}/fln";; *) shortPath="${installDirectory}/fln";; esac + +if [ "${FLN_SILENT:-0}" = "1" ]; then + printf "πŸ₯ž fln %s was installed successfully to %s\n" "$displayVersion" "$shortPath" +else + printf "%sπŸ₯ž %sfln%s%s %s was installed successfully to %s%s%s\n" "$colorSuccess" "$colorBold" "$colorReset" "$colorSuccess" "$displayVersion" "$colorBold" "$shortPath" "$colorReset" +fi +printf "Run 'fln --help' to get started\n" if command -v fln >/dev/null 2>&1; then - echo "Run: fln --help" + : else - echo "Add ${installDirectory} to your PATH to use fln." + if isInPath "$installDirectory"; then + printInfo "fln installed, but current shell does not see it yet." + printInfo "Restart your terminal session or re-source your shell configuration file." + printf "\n" + else + printPathInstructions "$installDirectory" + fi fi diff --git a/package.json b/package.json index 5374b2b..6f86f54 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "fln", - "version": "1.1.1", + "version": "1.1.2", "description": "Feed your entire codebase to any LLM in one shot. No attachment limits, no upload hassles.", "keywords": [ "cli", @@ -40,18 +40,18 @@ "main": "./dist/index.js", "types": "./dist/index.d.ts", "bin": { - "fln": "./dist/cli/main.js" + "fln": "./dist/cli/index.js" }, "files": [ "dist" ], "scripts": { "prebuild": "bun run generate:version", - "build": "bun build --compile --minify --sourcemap ./src/cli/main.ts --outfile fln", + "build": "bun build --compile --minify --sourcemap ./src/cli/index.ts --outfile fln", "prebuild:npm": "bun run generate:version", "build:npm": "tsc -p tsconfig.build.json", "clean": "rm -rf fln dist", - "dev": "bun run src/cli/main.ts", + "dev": "bun run src/cli/index.ts", "generate:examples": "bun run scripts/generate-examples.ts", "generate:version": "bun run scripts/generate-version.ts", "install:system": "bun run build && echo 'Requesting sudo to move binary to /usr/local/bin...' && sudo mv ./fln /usr/local/bin/fln && echo 'Installed successfully. Run \"fln --help\" to test.'", diff --git a/scripts/generate-examples.ts b/scripts/generate-examples.ts index 1cd99dd..6a04e6d 100644 --- a/scripts/generate-examples.ts +++ b/scripts/generate-examples.ts @@ -2,7 +2,7 @@ import { readdirSync } from "node:fs"; import { join, resolve } from "node:path"; -import { fln } from "$api"; +import { fln } from "../src/api"; const examplesDir = resolve(import.meta.dir, "..", "examples"); diff --git a/src/api/fln.ts b/src/api/fln.ts index a44b20c..910f0aa 100644 --- a/src/api/fln.ts +++ b/src/api/fln.ts @@ -1,6 +1,4 @@ import { relative, resolve, sep } from "node:path"; -import { parseByteSize, scanTree, writeOutput } from "$core"; -import { createLogger } from "$infra"; import { defaultConfigFileName, getProjectMetadata, @@ -9,6 +7,8 @@ import { resolveConfig, resolveOutputPath } from "../config"; +import { parseByteSize, scanTree, writeOutput } from "../core"; +import { createLogger } from "../infra"; import type { FlnOptions, FlnResult } from "./types"; /** diff --git a/src/api/types.ts b/src/api/types.ts index 3e3957f..3a52ac9 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -1,4 +1,4 @@ -import type { LogLevel, ProgressCallback } from "$core"; +import type { LogLevel, ProgressCallback } from "../core"; /** diff --git a/src/cli/commandLine.ts b/src/cli/commandLine.ts index d333a85..8166478 100644 --- a/src/cli/commandLine.ts +++ b/src/cli/commandLine.ts @@ -1,19 +1,19 @@ import { resolve } from "node:path"; import { parseArgs } from "node:util"; -import { fln } from "$api"; +import { fln } from "../api"; import { collectExtensionStats, collectProcessedFiles, parseByteSize, type FileNode -} from "$core"; +} from "../core"; import { getTerminalInfo, incrementUsageCount, shouldShowSponsorMessage, shouldUseColors -} from "$infra"; -import { VERSION } from "$version"; +} from "../infra"; +import { VERSION } from "../version"; import { formatHelpMessage } from "./help"; import { OutputRenderer } from "./output"; diff --git a/src/cli/help.ts b/src/cli/help.ts index ca3e75d..248d7e3 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -1,4 +1,4 @@ -import { applyColor, colors } from "./output/styles"; +import { applyColor, colors } from "./output"; export function formatHelpMessage(useAnsi: boolean): string { diff --git a/src/cli/index.ts b/src/cli/index.ts index 1756745..5a151eb 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1 +1,17 @@ -export * from "./commandLine"; +#!/usr/bin/env node + +import { runCommandLine } from "./commandLine"; + + +try { + await runCommandLine(); +} catch (error) { + console.error(`Error: ${error instanceof Error ? error.message : String(error)}`); + + if (process.env.DEBUG && error instanceof Error && error.stack) { + console.error("\nStack trace:"); + console.error(error.stack); + } + + process.exit(1); +} diff --git a/src/cli/main.ts b/src/cli/main.ts deleted file mode 100644 index b9b8303..0000000 --- a/src/cli/main.ts +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env node - -import { runCommandLine } from "./commandLine"; - - -try { - await runCommandLine(); -} catch (error) { - console.error(`ERROR ${String(error)}`); - process.exit(1); -} diff --git a/src/cli/output/components/index.ts b/src/cli/output/components/index.ts new file mode 100644 index 0000000..7d410cb --- /dev/null +++ b/src/cli/output/components/index.ts @@ -0,0 +1,5 @@ +export * from "./breakdown"; +export * from "./errors"; +export * from "./progressBar"; +export * from "./summary"; +export * from "./warnings"; diff --git a/src/cli/output/index.ts b/src/cli/output/index.ts index e5ae682..f34f351 100644 --- a/src/cli/output/index.ts +++ b/src/cli/output/index.ts @@ -1,7 +1,4 @@ -export type { FileError } from "./components/errors"; -export { ProgressBar } from "./components/progressBar"; -export type { Warning } from "./components/warnings"; +export * from "./components"; export * from "./formatter"; -export { OutputRenderer } from "./renderer"; -export type { RendererOptions, SuccessData } from "./renderer"; +export * from "./renderer"; export * from "./styles"; diff --git a/src/cli/output/renderer.ts b/src/cli/output/renderer.ts index 36834c4..fa5f65b 100644 --- a/src/cli/output/renderer.ts +++ b/src/cli/output/renderer.ts @@ -1,10 +1,13 @@ -import type { FlnResult } from "$api"; -import type { LogLevel } from "$core"; -import { renderBreakdown } from "./components/breakdown"; -import { renderErrors } from "./components/errors"; -import { ProgressBar } from "./components/progressBar"; -import { renderSummary } from "./components/summary"; -import { renderWarnings, type Warning } from "./components/warnings"; +import type { FlnResult } from "../../api"; +import type { LogLevel } from "../../core"; +import { + ProgressBar, + renderBreakdown, + renderErrors, + renderSummary, + renderWarnings, + type Warning +} from "./components"; import { formatTime } from "./formatter"; import { applyColor, colors, symbols } from "./styles"; diff --git a/src/config/resolver.ts b/src/config/resolver.ts index d61d3f2..c11dba0 100644 --- a/src/config/resolver.ts +++ b/src/config/resolver.ts @@ -1,5 +1,5 @@ -import { parseByteSize, type OutputFormat } from "$core"; -import { parseGeneratedDate } from "$infra"; +import { parseByteSize, type OutputFormat } from "../core"; +import { parseGeneratedDate } from "../infra"; import { defaultMaximumFileSizeBytes } from "./defaults"; import type { FlnConfig, RawConfigFile } from "./types"; diff --git a/src/config/types.ts b/src/config/types.ts index 70d0fe7..59b9854 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -1,4 +1,4 @@ -import type { LogLevel, OutputFormat } from "$core"; +import type { LogLevel, OutputFormat } from "../core"; export type RawConfigFile = Partial<{ diff --git a/src/core/ignoreMatcher.ts b/src/core/ignoreMatcher.ts index eb9336a..26441b1 100644 --- a/src/core/ignoreMatcher.ts +++ b/src/core/ignoreMatcher.ts @@ -2,7 +2,7 @@ import { constants } from "node:fs"; import { access, readFile } from "node:fs/promises"; import { join, relative, sep } from "node:path"; import ignore from "ignore"; -import type { Logger } from "$infra"; +import type { Logger } from "../infra"; type IgnoreMatcherOptions = { diff --git a/src/core/renderOutput.ts b/src/core/renderOutput.ts index c6a8c0d..4396cd0 100644 --- a/src/core/renderOutput.ts +++ b/src/core/renderOutput.ts @@ -1,9 +1,9 @@ import { createReadStream } from "node:fs"; import { readFile } from "node:fs/promises"; import { extname, join } from "node:path"; -import { createOutputWriter, formatDateTime } from "$infra"; -import { VERSION } from "$version"; -import type { FlnConfig } from "$config"; +import { createOutputWriter, formatDateTime } from "../infra"; +import { VERSION } from "../version"; +import type { FlnConfig } from "../config"; import { renderTree } from "./renderTree"; import { formatByteSize } from "./size"; import type { FileNode, ScanResult } from "./types"; diff --git a/src/core/scanTree.ts b/src/core/scanTree.ts index e60197d..f10d650 100644 --- a/src/core/scanTree.ts +++ b/src/core/scanTree.ts @@ -10,7 +10,7 @@ import { cpus } from "node:os"; import { relative, sep } from "node:path"; import type { Dirent } from "node:fs"; import ignore from "ignore"; -import type { Logger } from "$infra"; +import type { Logger } from "../infra"; import { IgnoreMatcher } from "./ignoreMatcher"; import type { FileNode, @@ -177,8 +177,9 @@ export async function scanTree(options: ScanOptions, logger: Logger): Promise