# stali-cli - Windows install (PowerShell) # # Canonical (Win / macOS / Linux giống nhau): # npm install -g stali-cli@latest # # One-liner (npm bên trong; fallback GitHub+Bun nếu npm lỗi): # irm https://api.stali.vn/install/stali-cli.ps1 | iex # # Env: # STALI_CLI_INSTALL_METHOD — auto | npm | git | standalone (default: auto) # STALI_CLI_VERSION — pin version (npm tag/version; default latest; reject 3.45.2) # STALI_HOME — default %USERPROFILE%\.stali $ErrorActionPreference = "Stop" $Method = if ($env:STALI_CLI_INSTALL_METHOD) { $env:STALI_CLI_INSTALL_METHOD } else { "auto" } $NpmPkg = "stali-cli" $Version = $env:STALI_CLI_VERSION $Repo = if ($env:STALI_CLI_REPO) { $env:STALI_CLI_REPO } else { "https://github.com/ngocvu/stali-cli.git" } $Branch = if ($env:STALI_CLI_BRANCH) { $env:STALI_CLI_BRANCH } else { "main" } $StaliHome = if ($env:STALI_HOME) { $env:STALI_HOME } else { Join-Path $env:USERPROFILE ".stali" } $InstallRoot = if ($env:STALI_CLI_INSTALL_DIR) { $env:STALI_CLI_INSTALL_DIR } else { Join-Path $StaliHome "cli" } $StaliBinDir = Join-Path $StaliHome "bin" $LocalBinDir = if ($env:STALI_CLI_LOCAL_BIN) { $env:STALI_CLI_LOCAL_BIN } else { Join-Path $env:USERPROFILE ".local\bin" } $LegacyInstall = if ($env:LOCALAPPDATA) { Join-Path $env:LOCALAPPDATA "stali-cli" } else { $null } $LegacyBunBin = if ($env:USERPROFILE) { Join-Path $env:USERPROFILE ".bun\bin" } else { $null } function Write-Step([string]$Msg) { Write-Host "> $Msg" } function Test-PathSafe { param([AllowNull()][string]$Path) # Test-Path rejects empty Path (terminating when $ErrorActionPreference=Stop). # Do NOT wrap Test-Path in ( ) on the RHS of -and — parentheses force evaluation first. if ([string]::IsNullOrWhiteSpace($Path)) { return $false } return (Test-Path -LiteralPath $Path) } function Get-NpmGlobalPrefix { if (-not (Get-Command npm -ErrorAction SilentlyContinue)) { return $null } try { # npm 9+ removed `npm bin`; global shims on Windows live in `npm prefix -g`. $raw = & npm prefix -g 2>$null if ($null -eq $raw) { return $null } if ($raw -is [System.Array]) { $raw = @($raw | ForEach-Object { "$_".Trim() } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) | Select-Object -Last 1 } else { $raw = "$raw".Trim() } if ([string]::IsNullOrWhiteSpace($raw)) { return $null } return $raw } catch { return $null } } function Refresh-Path { $npmGlobal = Get-NpmGlobalPrefix $bunBin = if ($env:USERPROFILE) { Join-Path $env:USERPROFILE ".bun\bin" } else { $null } $addParts = New-Object System.Collections.Generic.List[string] foreach ($p in @($LocalBinDir, $StaliBinDir, $npmGlobal, $bunBin)) { if (-not (Test-PathSafe $p)) { continue } $exists = $false foreach ($existing in $addParts) { if ([string]::Equals($existing, $p, [System.StringComparison]::OrdinalIgnoreCase)) { $exists = $true break } } if (-not $exists) { [void]$addParts.Add($p) } } if ($addParts.Count -gt 0) { $env:Path = ($addParts -join ";") + ";" + $env:Path } } function Ensure-Node { Refresh-Path if (Get-Command node -ErrorAction SilentlyContinue) { return } throw "Cần Node.js >= 18: https://nodejs.org" } function Ensure-Npm { Ensure-Node if (Get-Command npm -ErrorAction SilentlyContinue) { return } throw "Cần npm (đi kèm Node.js). Kiểm tra PATH hoặc cài lại từ https://nodejs.org" } function Ensure-Bun { Refresh-Path if (Get-Command bun -ErrorAction SilentlyContinue) { return } Write-Step "Installing Bun runtime (https://bun.sh)..." irm https://bun.sh/install.ps1 | iex Refresh-Path if (-not (Get-Command bun -ErrorAction SilentlyContinue)) { throw "Bun install failed. Restart terminal or install from https://bun.sh" } } function Migrate-LegacyInstall { if ((Test-PathSafe $LegacyInstall) -and -not (Test-PathSafe $InstallRoot)) { Write-Step "Migrating legacy install: $LegacyInstall -> $InstallRoot" New-Item -ItemType Directory -Path (Split-Path $InstallRoot -Parent) -Force | Out-Null Move-Item -Path $LegacyInstall -Destination $InstallRoot } if (Test-PathSafe $LegacyBunBin) { Remove-Item -Force (Join-Path $LegacyBunBin "stali.cmd") -ErrorAction SilentlyContinue Remove-Item -Force (Join-Path $LegacyBunBin "stali.exe") -ErrorAction SilentlyContinue Remove-Item -Force (Join-Path $LegacyBunBin "stali.bunx") -ErrorAction SilentlyContinue } } function Get-GitHubZipUrl { $repoUrl = $Repo -replace '\.git$', '' if ($repoUrl -match 'github\.com[:/]([^/]+)/([^/]+)$') { return "https://github.com/$($Matches[1])/$($Matches[2])/archive/refs/heads/$Branch.zip" } throw "Cannot build GitHub zip URL from STALI_CLI_REPO=$Repo" } function Test-NpmStaliSmoke { # Fail fast so auto mode can fall back to git when a broken npm pack # ships relative imports that throw ERR_MODULE_NOT_FOUND at runtime. Refresh-Path $out = $null $err = $null try { if (Get-Command stali -ErrorAction SilentlyContinue) { $out = & stali --version 2>&1 | Out-String if ($LASTEXITCODE -ne 0) { throw "stali --version exit $LASTEXITCODE" } } else { $npmRoot = $null try { $npmRoot = (& npm root -g 2>$null | Select-Object -Last 1).ToString().Trim() } catch { $npmRoot = $null } $staliJs = if ($npmRoot) { Join-Path $npmRoot "stali-cli\bin\stali.js" } else { $null } if (-not (Test-PathSafe $staliJs)) { throw "stali shim missing after npm install" } Ensure-Node $out = & node $staliJs --version 2>&1 | Out-String if ($LASTEXITCODE -ne 0) { throw "node stali.js --version exit $LASTEXITCODE" } } } catch { $err = $_.Exception.Message } $combined = "$out`n$err" if ($combined -match 'ERR_MODULE_NOT_FOUND|Cannot find module|MODULE_NOT_FOUND') { throw "npm stali smoke failed (MODULE_NOT_FOUND) — broken pack; auto will try git" } if ($err) { throw "npm stali smoke failed: $err" } # 3.45.2 shipped broken wizard-only side-effect imports — refuse and fall back. if ($out -match '(^|\s)3\.45\.2(\s|$)') { throw "stali 3.45.2 is broken (wizard-only MODULE_NOT_FOUND); refusing npm install — auto will try git" } Write-Step "npm smoke OK: $($out.Trim())" } function Install-FromNpm { Ensure-Npm # Prefer @latest (>= 3.45.3). Known-bad 3.45.2 refused here + in smoke. $pin = if ($Version) { $Version } else { "latest" } if ($pin -eq "3.45.2" -or $pin -eq "v3.45.2") { throw "stali-cli@3.45.2 is broken; refuse pin — use latest (>= 3.45.3)" } $spec = "${NpmPkg}@${pin}" Write-Step "Installing $spec from npm (prebuilt, no build)..." # npm 10+ may skip package postinstall unless allow-scripts; CLI still works from prebuilt dist. & npm install -g $spec --no-fund --no-audit --allow-scripts=stali-cli if ($LASTEXITCODE -ne 0) { # Older npm without --allow-scripts & npm install -g $spec --no-fund --no-audit if ($LASTEXITCODE -ne 0) { throw "npm install failed (exit $LASTEXITCODE)" } } Link-UserBinsFromNpm Refresh-Path Test-NpmStaliSmoke } function Write-StaliCmdWrapper { param([string]$DestDir, [string]$StaliJs, [string]$NodeExe) New-Item -ItemType Directory -Path $DestDir -Force | Out-Null $cmdPath = Join-Path $DestDir "stali.cmd" $shim = @( '@echo off', 'setlocal', "`"$NodeExe`" `"$StaliJs`" %*" ) -join "`r`n" Set-Content -Path $cmdPath -Value $shim -Encoding ASCII } function Link-UserBinsFromNpm { $npmRoot = $null try { $npmRoot = & npm root -g 2>$null if ($npmRoot -is [System.Array]) { $npmRoot = @($npmRoot | ForEach-Object { "$_".Trim() } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) | Select-Object -Last 1 } else { $npmRoot = "$npmRoot".Trim() } } catch { $npmRoot = $null } if ([string]::IsNullOrWhiteSpace($npmRoot)) { Write-Step "Warning: npm root -g trống — bỏ qua link ~/.local/bin" return } $staliJs = Join-Path $npmRoot "stali-cli\bin\stali.js" if (-not (Test-PathSafe $staliJs)) { Write-Step "Warning: không thấy $staliJs — bỏ qua link ~/.local/bin" return } Ensure-Node $nodeExe = (Get-Command node).Source Write-StaliCmdWrapper -DestDir $StaliBinDir -StaliJs $staliJs -NodeExe $nodeExe Write-StaliCmdWrapper -DestDir $LocalBinDir -StaliJs $staliJs -NodeExe $nodeExe Ensure-UserPathDirs Write-Step "Linked bin: $LocalBinDir\stali.cmd (và $StaliBinDir) → $staliJs" } function Ensure-UserPathDirs { $current = [Environment]::GetEnvironmentVariable("Path", "User") if ($null -eq $current) { $current = "" } $changed = $false foreach ($dir in @($LocalBinDir, $StaliBinDir)) { if ([string]::IsNullOrWhiteSpace($dir)) { continue } if ($current -notlike "*$dir*") { $current = "$dir;$current" $changed = $true } } if ($changed) { [Environment]::SetEnvironmentVariable("Path", $current, "User") Write-Step "Added $LocalBinDir (+ $StaliBinDir) to User PATH (mở lại terminal nếu cần)" } } function Install-Standalone { $ver = $Version if (-not $ver) { throw "STALI_CLI_VERSION required for standalone (e.g. v3.13.0)" } if ($ver -notmatch "^v") { $ver = "v$ver" } $scriptUrl = "https://raw.githubusercontent.com/ngocvu/stali-cli/main/scripts/install.ps1" Write-Step "Standalone install $ver via $scriptUrl" $env:STALI_CLI_VERSION = $ver $script = Invoke-WebRequest -Uri $scriptUrl -UseBasicParsing Invoke-Expression $script.Content } function Fetch-SourceTo([string]$Dest) { if (Test-PathSafe $Dest) { Remove-Item -Recurse -Force $Dest } if (Get-Command git -ErrorAction SilentlyContinue) { Write-Step "Cloning $Repo ($Branch)..." & git clone --depth 1 --branch $Branch $Repo $Dest if ($LASTEXITCODE -ne 0) { throw "git clone failed (exit $LASTEXITCODE)" } return } $zipUrl = Get-GitHubZipUrl $stage = Join-Path $env:TEMP ("stali-stage-" + [guid]::NewGuid().ToString("n")) New-Item -ItemType Directory -Path $stage -Force | Out-Null try { $zipFile = Join-Path $stage "repo.zip" Write-Step "Downloading source zip (git not required): $zipUrl" Invoke-WebRequest -Uri $zipUrl -OutFile $zipFile -UseBasicParsing Expand-Archive -Path $zipFile -DestinationPath $stage -Force $extracted = Get-ChildItem -Path $stage -Directory | Where-Object { $_.Name -like 'stali-cli-*' } | Select-Object -First 1 if (-not $extracted) { throw "Unexpected zip layout in $stage" } Move-Item -Path $extracted.FullName -Destination $Dest } finally { Remove-Item -Recurse -Force $stage -ErrorAction SilentlyContinue } if (-not (Test-PathSafe (Join-Path $Dest "package.json"))) { throw "package.json missing in $Dest" } } function Register-GlobalStali { param([string]$Root) $absRoot = (Resolve-Path -LiteralPath $Root).Path $staliJs = Join-Path $absRoot "bin\stali.js" if (-not (Test-PathSafe $staliJs)) { throw "missing $staliJs" } Ensure-Node $nodeExe = (Get-Command node).Source Write-Step "Installing stali.cmd -> $LocalBinDir + $StaliBinDir (node)" Write-StaliCmdWrapper -DestDir $StaliBinDir -StaliJs $staliJs -NodeExe $nodeExe Write-StaliCmdWrapper -DestDir $LocalBinDir -StaliJs $staliJs -NodeExe $nodeExe Ensure-UserPathDirs Refresh-Path } function Ensure-StaliUserPath { Ensure-UserPathDirs } function Install-FromGit { Ensure-Bun Migrate-LegacyInstall Write-Step "Stali home: $StaliHome" Write-Step "Install directory: $InstallRoot" Fetch-SourceTo $InstallRoot Push-Location $InstallRoot try { & bun install if ($LASTEXITCODE -ne 0) { throw "bun install failed" } & bun run build if ($LASTEXITCODE -ne 0) { throw "bun run build failed" } if (-not (Test-PathSafe (Join-Path $InstallRoot "bin\stali.js"))) { throw "build missing bin/stali.js" } Register-GlobalStali -Root $InstallRoot } finally { Pop-Location -ErrorAction SilentlyContinue } } switch ($Method) { "npm" { Install-FromNpm } "standalone" { Install-Standalone } "git" { Install-FromGit } "auto" { try { Install-FromNpm } catch { Write-Host "> npm failed ($($_.Exception.Message)) - trying GitHub source..." Install-FromGit } } default { throw "Unknown STALI_CLI_INSTALL_METHOD=$Method (use auto|npm|git|standalone)" } } Refresh-Path if (-not (Get-Command stali -ErrorAction SilentlyContinue)) { throw @" stali not found in PATH after install. Add to PATH: $LocalBinDir Then run: stali --help "@ } Write-Step "Done. Run: stali" Write-Step "Paths: bin=$LocalBinDir (cũng $StaliBinDir)" Write-Step "Quick: stali --models | stali doctor | stali configure claude -k sk-stali-..." if ($env:STALI_CLI_NO_RUN -ne "1") { stali }