This commit is contained in:
LC 2026-05-15 10:35:20 +03:00 committed by GitHub
commit a3a07f515d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 1229 additions and 0 deletions

View file

@ -0,0 +1,247 @@
import argparse
import os
import platform
import signal
import shutil
import subprocess
import tarfile
import urllib.request
from pathlib import Path
DEFAULT_ACTION = "install"
START_PICOCLAW = True
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Install or uninstall picoclaw.")
parser.add_argument(
"--action",
choices=["install", "uninstall"],
default=DEFAULT_ACTION,
help=f"Action to perform, defaults to '{DEFAULT_ACTION}'.",
)
return parser.parse_args()
def detect_arch() -> str:
machine = platform.machine().lower()
if machine in {"aarch64", "arm64"}:
return "arm64"
if machine in {"riscv64"}:
return "riscv64"
return f"unknown ({machine})"
def ensure_picoclaw_dir() -> Path:
target_dir = Path("/root/picoclaw")
target_dir.mkdir(parents=True, exist_ok=True)
return target_dir
def get_download_url(arch: str) -> str:
if arch == "riscv64":
return "https://picoclaw-downloads.tos-cn-beijing.volces.com/latest/picoclaw_Linux_riscv64.tar.gz"
if arch == "arm64":
return "https://picoclaw-downloads.tos-cn-beijing.volces.com/latest/picoclaw_aarch64.deb"
raise ValueError(f"Unsupported architecture: {arch}")
def download_package(url: str, target_dir: Path) -> Path:
file_name = Path(url).name
target_path = target_dir / file_name
urllib.request.urlretrieve(url, target_path)
return target_path
def install_arm64(deb_file: Path) -> None:
subprocess.run(["dpkg", "-i", str(deb_file)], check=True)
def find_file_recursive(root: Path, file_name: str) -> Path:
matches = list(root.rglob(file_name))
if not matches:
raise FileNotFoundError(f"Required file not found: {file_name}")
return matches[0]
def install_riscv64(tar_file: Path, work_dir: Path) -> None:
install_dir = Path("/opt/picoclaw")
if install_dir.exists():
shutil.rmtree(install_dir)
install_dir.mkdir(parents=True, exist_ok=True)
with tarfile.open(tar_file, "r:gz") as tar:
tar.extractall(path=install_dir)
for binary_name in ["picoclaw", "picoclaw-launcher", "picoclaw-launcher-tui"]:
installed_binary = find_file_recursive(install_dir, binary_name)
installed_binary.chmod(0o755)
link_path = Path("/usr/bin") / binary_name
if link_path.exists() or link_path.is_symlink():
link_path.unlink()
link_path.symlink_to(installed_binary)
def cleanup_dir(target_dir: Path) -> None:
if target_dir.exists():
shutil.rmtree(target_dir)
def stop_picoclaw() -> None:
process_names = {"picoclaw", "picoclaw-launcher", "picoclaw-launcher-tui"}
stopped_any = False
self_pid = os.getpid()
try:
ps_result = subprocess.run(["ps", "-eo", "pid=,args="], capture_output=True, text=True, check=False)
except FileNotFoundError:
print("ps command not found, skip stopping picoclaw processes.")
return
if ps_result.returncode != 0:
print("Failed to list processes with ps, skip stopping picoclaw processes.")
return
for line in ps_result.stdout.splitlines():
line = line.strip()
if not line:
continue
parts = line.split(maxsplit=1)
if not parts:
continue
try:
pid = int(parts[0])
except ValueError:
continue
if pid == self_pid:
continue
cmdline = parts[1] if len(parts) > 1 else ""
executable = os.path.basename(cmdline.split(maxsplit=1)[0]) if cmdline else ""
if executable not in process_names and not any(f"/{name}" in cmdline for name in process_names):
continue
try:
os.kill(pid, signal.SIGTERM)
stopped_any = True
except (ProcessLookupError, PermissionError):
continue
if stopped_any:
print("Stopped picoclaw related processes.")
else:
print("No running picoclaw related process found.")
def uninstall_arm64() -> None:
result = subprocess.run(["dpkg", "-l"], capture_output=True, text=True, check=True)
package_names = []
for line in result.stdout.splitlines():
if line.startswith("ii") and "picoclaw" in line:
package_names.append(line.split()[1])
if not package_names:
print("No installed picoclaw package found for arm64.")
return
for package_name in package_names:
print(f"Removing package: {package_name}")
subprocess.run(["dpkg", "-r", package_name], check=True)
def uninstall_riscv64() -> None:
for binary_name in ["picoclaw", "picoclaw-launcher", "picoclaw-launcher-tui"]:
link_path = Path("/usr/bin") / binary_name
if link_path.exists() or link_path.is_symlink():
link_path.unlink()
print(f"Removed link: {link_path}")
install_dir = Path("/opt/picoclaw")
if install_dir.exists():
shutil.rmtree(install_dir)
print(f"Removed directory: {install_dir}")
def remove_picoclaw_user_dir() -> None:
user_dir = Path("/root/.picoclaw")
if user_dir.exists():
shutil.rmtree(user_dir)
print(f"Removed directory: {user_dir}")
def start_picoclaw_launcher() -> None:
launcher_path = shutil.which("picoclaw-launcher")
if not launcher_path:
print("picoclaw-launcher not found in PATH, skip start.")
return
env = dict(os.environ)
env["HOME"] = "/root"
env.setdefault("NO_COLOR", "1")
subprocess.Popen(
[launcher_path, "-no-browser", "-public"],
env=env,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
close_fds=True,
)
print("Started picoclaw-launcher in background.")
if __name__ == "__main__":
args = parse_args()
action = args.action
arch = detect_arch()
print(f"Arch: {arch}")
print(f"Action: {action}")
if action == "install":
if arch.startswith("unknown"):
print("Unsupported arch, exiting.")
raise SystemExit(1)
picoclaw_dir = ensure_picoclaw_dir()
print(f"Ensured directory exists: {picoclaw_dir}")
download_url = get_download_url(arch)
print(f"Downloading from: {download_url}")
downloaded_file = download_package(download_url, picoclaw_dir)
print(f"Downloaded file: {downloaded_file}")
if arch == "arm64":
print("Installing with dpkg...")
install_arm64(downloaded_file)
else:
print("Extracting all files to /opt/picoclaw and creating symlinks...")
install_riscv64(downloaded_file, picoclaw_dir)
if START_PICOCLAW:
start_picoclaw_launcher()
cleanup_dir(picoclaw_dir)
print(f"Cleaned up directory: {picoclaw_dir}")
else:
stop_picoclaw()
if arch == "arm64":
uninstall_arm64()
elif arch == "riscv64":
uninstall_riscv64()
else:
print("Unknown architecture, skipping uninstallation.")
remove_picoclaw_user_dir()

View file

@ -0,0 +1,529 @@
[CmdletBinding()]
param(
[ValidateSet('system', 'user')]
[string]$InstallMode = 'user',
[ValidateSet('github', 'cdn')]
[string]$Source = 'github',
[string]$Arch
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$BaseUrls = @{
github = 'https://github.com/sipeed/picoclaw/releases/latest/download/'
cdn = 'https://picoclaw-downloads.tos-cn-beijing.volces.com/latest/'
}
$Assets = @{
windows = @{
x86_64 = @{ file = 'picoclaw_Windows_x86_64.zip' }
arm64 = @{ file = 'picoclaw_Windows_arm64.zip' }
}
linux = @{
x86_64 = @{ file = 'picoclaw_Linux_x86_64.tar.gz'; deb = 'picoclaw_x86_64.deb'; rpm = 'picoclaw_x86_64.rpm' }
arm64 = @{ file = 'picoclaw_Linux_arm64.tar.gz'; deb = 'picoclaw_aarch64.deb'; rpm = 'picoclaw_aarch64.rpm' }
armv7 = @{ file = 'picoclaw_Linux_armv7.tar.gz'; deb = 'picoclaw_armv7.deb'; rpm = 'picoclaw_armv7.rpm' }
armv6 = @{ file = 'picoclaw_Linux_armv6.tar.gz'; deb = 'picoclaw_armv6.deb'; rpm = 'picoclaw_armv6.rpm' }
riscv64 = @{ file = 'picoclaw_Linux_riscv64.tar.gz'; deb = 'picoclaw_riscv64.deb'; rpm = 'picoclaw_riscv64.rpm' }
mipsle = @{ file = 'picoclaw_Linux_mipsle.tar.gz'; deb = 'picoclaw_mipsle.deb'; rpm = 'picoclaw_mipsle.rpm' }
s390x = @{ file = 'picoclaw_Linux_s390x.tar.gz'; deb = 'picoclaw_s390x.deb'; rpm = 'picoclaw_s390x.rpm' }
}
macos = @{
x86_64 = @{ file = 'picoclaw_Darwin_x86_64.tar.gz' }
arm64 = @{ file = 'picoclaw_Darwin_arm64.tar.gz' }
}
freebsd = @{
x86_64 = @{ file = 'picoclaw_Freebsd_x86_64.tar.gz' }
arm64 = @{ file = 'picoclaw_Freebsd_arm64.tar.gz' }
armv7 = @{ file = 'picoclaw_Freebsd_armv7.tar.gz' }
}
netbsd = @{
x86_64 = @{ file = 'picoclaw_Netbsd_x86_64.tar.gz' }
arm64 = @{ file = 'picoclaw_Netbsd_arm64.tar.gz' }
}
}
$ExpectedBins = @('picoclaw', 'picoclaw-launcher', 'picoclaw-launcher-tui')
function Get-CommandExists {
param([Parameter(Mandatory = $true)][string]$Name)
return $null -ne (Get-Command -Name $Name -ErrorAction SilentlyContinue)
}
function Get-OsId {
if ($IsWindows) {
return 'windows'
}
if ($IsLinux) {
return 'linux'
}
if ($IsMacOS) {
return 'macos'
}
$uname = (& uname -s).Trim().ToLowerInvariant()
switch ($uname) {
'freebsd' { return 'freebsd' }
'netbsd' { return 'netbsd' }
default {
throw "Unsupported operating system: $uname"
}
}
}
function Resolve-Arch {
param([Parameter(Mandatory = $true)][string]$OsId)
if (-not [string]::IsNullOrWhiteSpace($Arch)) {
return $Arch.ToLowerInvariant()
}
$machine = if ($IsWindows) {
[System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString().ToLowerInvariant()
}
else {
(& uname -m).Trim().ToLowerInvariant()
}
switch ($machine) {
'x64' { return 'x86_64' }
'amd64' { return 'x86_64' }
'x86_64' { return 'x86_64' }
'arm64' { return 'arm64' }
'aarch64' { return 'arm64' }
'armv7l' { return 'armv7' }
'armv7' { return 'armv7' }
'armv6l' { return 'armv6' }
'armv6' { return 'armv6' }
'riscv64' { return 'riscv64' }
'mipsel' { return 'mipsle' }
'mipsle' { return 'mipsle' }
's390x' { return 's390x' }
default {
throw "Unsupported architecture '$machine' for OS '$OsId'. Use -Arch to override."
}
}
}
function Assert-SystemInstallPrivileges {
param([Parameter(Mandatory = $true)][string]$OsId)
if ($InstallMode -ne 'system') {
return
}
if ($OsId -eq 'windows') {
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = New-Object Security.Principal.WindowsPrincipal($identity)
$isAdmin = $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
if (-not $isAdmin) {
throw 'System install on Windows requires Administrator privileges.'
}
return
}
$uid = (& id -u).Trim()
if ($uid -ne '0') {
throw 'System install requires root privileges (sudo/root).'
}
}
function Get-UnixSystemLayout {
if (Test-Path -LiteralPath '/usr/local/share') {
return @{
installRoot = '/usr/local/share/picoclaw'
linkDir = '/usr/local/bin'
mode = 'symlink'
}
}
if (Test-Path -LiteralPath '/usr/share') {
return @{
installRoot = '/usr/share/picoclaw'
linkDir = '/usr/bin'
mode = 'symlink'
}
}
$tempInstallRoot = "/tmp/picoclaw-$([DateTimeOffset]::UtcNow.ToUnixTimeSeconds())"
try {
Get-ChildItem -Path '/tmp' -Filter 'picoclaw-*' -Directory -ErrorAction SilentlyContinue |
ForEach-Object {
if ($_.FullName -ne $tempInstallRoot) {
Remove-Item -LiteralPath $_.FullName -Recurse -Force -ErrorAction SilentlyContinue
}
}
} catch {
# Best-effort cleanup; ignore any failures to avoid breaking installation.
}
return @{
installRoot = $tempInstallRoot
linkDir = '/usr/bin'
mode = 'copy-executables'
}
}
function Get-InstallLayout {
param([Parameter(Mandatory = $true)][string]$OsId)
if ($InstallMode -eq 'user') {
if ($OsId -eq 'windows') {
$base = [Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData)
if ([string]::IsNullOrWhiteSpace($base) -or -not (Test-Path -LiteralPath $base)) {
throw 'Failed to resolve a valid LocalApplicationData path.'
}
return @{
installRoot = (Join-Path $base 'picoclaw')
linkDir = $null
mode = 'user'
}
}
return @{
installRoot = (Join-Path $HOME '.local/share/picoclaw')
linkDir = (Join-Path $HOME '.local/bin')
mode = 'user'
}
}
if ($OsId -eq 'windows') {
$programFiles = [Environment]::GetFolderPath([Environment+SpecialFolder]::ProgramFiles)
if ([string]::IsNullOrWhiteSpace($programFiles) -or -not (Test-Path -LiteralPath $programFiles)) {
throw 'ProgramFiles path from .NET API is invalid or missing. Installation terminated.'
}
return @{
installRoot = (Join-Path $programFiles 'picoclaw')
linkDir = $null
mode = 'system'
}
}
return Get-UnixSystemLayout
}
function New-DirectoryIfMissing {
param([Parameter(Mandatory = $true)][string]$Path)
if (-not (Test-Path -LiteralPath $Path)) {
New-Item -ItemType Directory -Path $Path -Force | Out-Null
}
}
function Get-DownloadUrl {
param(
[Parameter(Mandatory = $true)][string]$FileName,
[Parameter(Mandatory = $true)][string]$SourceName
)
return "$($BaseUrls[$SourceName])$FileName"
}
function Invoke-ArtifactDownload {
param(
[Parameter(Mandatory = $true)][string]$Url,
[Parameter(Mandatory = $true)][string]$OutFile
)
Write-Host "Downloading: $Url"
Invoke-WebRequest -Uri $Url -OutFile $OutFile
}
function Expand-Artifact {
param(
[Parameter(Mandatory = $true)][string]$ArchivePath,
[Parameter(Mandatory = $true)][string]$Destination
)
New-DirectoryIfMissing -Path $Destination
if ($ArchivePath.EndsWith('.zip', [StringComparison]::OrdinalIgnoreCase)) {
Expand-Archive -LiteralPath $ArchivePath -DestinationPath $Destination -Force
return
}
if ($ArchivePath.EndsWith('.tar.gz', [StringComparison]::OrdinalIgnoreCase)) {
& tar -xzf $ArchivePath -C $Destination
if ($LASTEXITCODE -ne 0) {
throw "Failed to extract tar archive: $ArchivePath"
}
return
}
throw "Unsupported archive type: $ArchivePath"
}
function Get-BinaryPath {
param(
[Parameter(Mandatory = $true)][string]$Root,
[Parameter(Mandatory = $true)][string]$Name
)
$direct = Join-Path $Root $Name
if (Test-Path -LiteralPath $direct -PathType Leaf) {
return $direct
}
$directExe = Join-Path $Root ("$Name.exe")
if (Test-Path -LiteralPath $directExe -PathType Leaf) {
return $directExe
}
return $null
}
function Set-UnixUserPathExport {
param(
[Parameter(Mandatory = $true)][string]$HomeDir,
[Parameter(Mandatory = $true)][string]$BinDir
)
$line = 'export PATH="$HOME/.local/bin:$PATH"'
$rcFiles = @(
(Join-Path $HomeDir '.bashrc'),
(Join-Path $HomeDir '.zshrc'),
(Join-Path $HomeDir '.profile')
)
$updated = @()
foreach ($rcPath in $rcFiles) {
if (-not (Test-Path -LiteralPath $rcPath)) {
New-Item -ItemType File -Path $rcPath -Force | Out-Null
}
$hasLine = Select-String -Path $rcPath -Pattern [regex]::Escape($line) -SimpleMatch -Quiet -ErrorAction SilentlyContinue
if (-not $hasLine) {
Add-Content -Path $rcPath -Value "`n$line"
$updated += $rcPath
}
}
return $updated
}
function Add-PathVariable {
param(
[Parameter(Mandatory = $true)][string]$PathToAdd,
[Parameter(Mandatory = $true)][ValidateSet('User', 'Machine')][string]$Scope
)
$current = [Environment]::GetEnvironmentVariable('Path', $Scope)
$parts = @()
if (-not [string]::IsNullOrWhiteSpace($current)) {
$parts = $current.Split(';') | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
}
if ($parts -contains $PathToAdd) {
return
}
$newValue = if ($parts.Count -eq 0) { $PathToAdd } else { "$current;$PathToAdd" }
[Environment]::SetEnvironmentVariable('Path', $newValue, $Scope)
}
function Install-LinuxPackageFromAsset {
param(
[Parameter(Mandatory = $true)][hashtable]$Asset,
[Parameter(Mandatory = $true)][string]$TempDir
)
$debCmd = Get-CommandExists -Name 'dpkg'
$rpmCmd = Get-CommandExists -Name 'rpm'
if (-not $debCmd -and -not $rpmCmd) {
return $null
}
if ($debCmd -and $Asset.ContainsKey('deb')) {
$pkg = $Asset.deb
$pkgPath = Join-Path $TempDir $pkg
Invoke-ArtifactDownload -Url (Get-DownloadUrl -FileName $pkg -SourceName $Source) -OutFile $pkgPath
& dpkg -i $pkgPath
if ($LASTEXITCODE -ne 0) {
throw 'dpkg install failed.'
}
return @{
manager = 'dpkg'
package = $pkgPath
}
}
if ($rpmCmd -and $Asset.ContainsKey('rpm')) {
$pkg = $Asset.rpm
$pkgPath = Join-Path $TempDir $pkg
Invoke-ArtifactDownload -Url (Get-DownloadUrl -FileName $pkg -SourceName $Source) -OutFile $pkgPath
& rpm -Uvh --replacepkgs $pkgPath
if ($LASTEXITCODE -ne 0) {
throw 'rpm install failed.'
}
return @{
manager = 'rpm'
package = $pkgPath
}
}
return $null
}
function Install-LinksOrCopies {
param(
[Parameter(Mandatory = $true)][string]$InstallRoot,
[Parameter(Mandatory = $true)][string]$LinkDir,
[Parameter(Mandatory = $true)][string]$Mode
)
New-DirectoryIfMissing -Path $LinkDir
if ($Mode -eq 'copy-executables') {
$copied = @()
foreach ($name in $ExpectedBins) {
$binPath = Get-BinaryPath -Root $InstallRoot -Name $name
if ($null -eq $binPath) {
continue
}
$dest = Join-Path $LinkDir (Split-Path -Path $binPath -Leaf)
Copy-Item -LiteralPath $binPath -Destination $dest -Force
$copied += $dest
}
return @{
type = 'copied'
paths = $copied
}
}
$linked = @()
foreach ($name in $ExpectedBins) {
$binPath = Get-BinaryPath -Root $InstallRoot -Name $name
if ($null -eq $binPath) {
continue
}
$target = Join-Path $LinkDir $name
if (Test-Path -LiteralPath $target) {
Remove-Item -LiteralPath $target -Force
}
if ($IsWindows) {
Copy-Item -LiteralPath $binPath -Destination $target -Force
}
else {
& ln -s $binPath $target
if ($LASTEXITCODE -ne 0) {
throw "Failed to create symlink: $target"
}
}
$linked += $target
}
return @{
type = 'linked'
paths = $linked
}
}
function Write-MissingUserLocalWarning {
param([Parameter(Mandatory = $true)][string]$OsId)
if ($InstallMode -ne 'user' -or $OsId -eq 'windows') {
return
}
$localRoot = Join-Path $HOME '.local'
if (-not (Test-Path -LiteralPath $localRoot)) {
Write-Host "Warning: $localRoot does not exist. The installer will create it automatically." -ForegroundColor Yellow
}
}
$osId = Get-OsId
$archId = Resolve-Arch -OsId $osId
Assert-SystemInstallPrivileges -OsId $osId
Write-MissingUserLocalWarning -OsId $osId
if (-not $Assets.ContainsKey($osId)) {
throw "OS '$osId' is not included in assets map."
}
$osAssets = $Assets[$osId]
if (-not $osAssets.ContainsKey($archId)) {
$available = ($osAssets.Keys | Sort-Object) -join ', '
throw "Architecture '$archId' is not available for '$osId'. Available: $available"
}
$asset = $osAssets[$archId]
$layout = Get-InstallLayout -OsId $osId
$tempDir = Join-Path ([IO.Path]::GetTempPath()) ("picoclaw-install-$([Guid]::NewGuid().ToString('N'))")
New-DirectoryIfMissing -Path $tempDir
try {
Write-Host "Install mode: $InstallMode"
Write-Host "OS/Arch: $osId/$archId"
if ($osId -eq 'linux' -and $InstallMode -eq 'system') {
$pkgInfo = Install-LinuxPackageFromAsset -Asset $asset -TempDir $tempDir
if ($null -ne $pkgInfo) {
Write-Host "Installed via package manager: $($pkgInfo.manager)"
Write-Host "Package file: $($pkgInfo.package)"
Write-Host 'Install location is managed by the package manager.'
return
}
}
$archiveName = $asset.file
$archivePath = Join-Path $tempDir $archiveName
$downloadUrl = Get-DownloadUrl -FileName $archiveName -SourceName $Source
Invoke-ArtifactDownload -Url $downloadUrl -OutFile $archivePath
New-DirectoryIfMissing -Path $layout.installRoot
Expand-Artifact -ArchivePath $archivePath -Destination $layout.installRoot
if ($osId -eq 'windows') {
$pathScope = if ($InstallMode -eq 'system') { 'Machine' } else { 'User' }
Add-PathVariable -PathToAdd $layout.installRoot -Scope $pathScope
Write-Host "Installed to: $($layout.installRoot)"
Write-Host "PATH scope updated: $pathScope"
Write-Host 'Please restart your terminal or log out and back in for PATH changes to take effect.'
return
}
$linkSummary = Install-LinksOrCopies -InstallRoot $layout.installRoot -LinkDir $layout.linkDir -Mode $layout.mode
Write-Host "Extracted to: $($layout.installRoot)"
if ($linkSummary.paths.Count -gt 0) {
if ($linkSummary.type -eq 'copied') {
Write-Host "Copied executables to: $($layout.linkDir)"
}
else {
Write-Host "Created links in: $($layout.linkDir)"
}
$linkSummary.paths | ForEach-Object { Write-Host " - $_" }
}
else {
Write-Host 'No launcher binaries were found for link/copy step.'
}
if ($InstallMode -eq 'user' -and $layout.linkDir) {
if ($osId -ne 'windows') {
$shellRcUpdated = Set-UnixUserPathExport -HomeDir $HOME -BinDir $layout.linkDir
if ($shellRcUpdated.Count -gt 0) {
Write-Host 'Updated shell profile files for PATH:'
$shellRcUpdated | ForEach-Object { Write-Host " - $_" }
}
}
Write-Host "User-local binary directory: $($layout.linkDir)"
Write-Host 'Ensure your shell PATH contains the directory above.' -ForegroundColor Yellow
}
}
finally {
if (Test-Path -LiteralPath $tempDir) {
try {
Remove-Item -LiteralPath $tempDir -Recurse -Force -ErrorAction Stop
}
catch {
Write-Host "Warning: failed to remove temp directory $tempDir . Please remove it manually." -ForegroundColor Yellow
}
}
}

View file

@ -0,0 +1,453 @@
#!/usr/bin/env bash
if [ -z "${BASH_VERSION:-}" ]; then
printf 'Error: this installer must run with GNU bash (ash/busybox are not supported).\n' >&2
exit 1
fi
if [ "${BASH_VERSINFO[0]}" -lt 4 ]; then
printf 'Error: GNU bash 4+ is required. Current version: %s\n' "$BASH_VERSION" >&2
exit 1
fi
if ! bash --version 2>/dev/null | head -n 1 | grep -qi 'gnu bash'; then
printf 'Error: this installer requires a full GNU bash runtime.\n' >&2
exit 1
fi
set -euo pipefail
INSTALL_MODE="user"
SOURCE="github"
ARCH_OVERRIDE=""
BASE_GITHUB="https://github.com/sipeed/picoclaw/releases/latest/download/"
BASE_CDN="https://picoclaw-downloads.tos-cn-beijing.volces.com/latest/"
EXPECTED_BINS=("picoclaw" "picoclaw-launcher" "picoclaw-launcher-tui")
usage() {
cat <<'EOF'
Usage:
install-picoclaw.sh [--mode system|user] [--source github|cdn] [--arch ARCH]
Examples:
./install-picoclaw.sh --mode user
sudo ./install-picoclaw.sh --mode system --source cdn
EOF
}
log() {
printf '%s\n' "$*"
}
die() {
printf 'Error: %s\n' "$*" >&2
exit 1
}
command_exists() {
command -v "$1" >/dev/null 2>&1
}
create_secure_tmp_dir() {
local prefix="${1:-picoclaw}"
local d
if d="$(mktemp -d "/tmp/${prefix}.XXXXXX" 2>/dev/null)"; then
printf '%s\n' "$d"
return 0
fi
local i candidate
for i in 1 2 3 4 5 6 7 8 9 10; do
candidate="/tmp/${prefix}.$$.${RANDOM}.$(date +%s)"
if mkdir -m 700 "$candidate" 2>/dev/null; then
printf '%s\n' "$candidate"
return 0
fi
done
return 1
}
detect_os() {
local u
u="$(uname -s | tr '[:upper:]' '[:lower:]')"
case "$u" in
linux) echo "linux" ;;
darwin) echo "macos" ;;
freebsd) echo "freebsd" ;;
netbsd) echo "netbsd" ;;
mingw*|msys*|cygwin*)
die "Windows shell detected ($u). Please run the PowerShell installer instead: pwsh ./static/scripts/picoclaw/install-picoclaw.ps1 -InstallMode user"
;;
*) die "Unsupported OS: $u" ;;
esac
}
detect_arch() {
if [[ -n "$ARCH_OVERRIDE" ]]; then
echo "$ARCH_OVERRIDE"
return
fi
local m
m="$(uname -m | tr '[:upper:]' '[:lower:]')"
case "$m" in
x86_64|amd64) echo "x86_64" ;;
aarch64|arm64) echo "arm64" ;;
armv7l|armv7) echo "armv7" ;;
armv6l|armv6) echo "armv6" ;;
riscv64) echo "riscv64" ;;
mipsel|mipsle) echo "mipsle" ;;
s390x) echo "s390x" ;;
*) die "Unsupported architecture: $m (use --arch to override)" ;;
esac
}
assert_privileges() {
if [[ "$INSTALL_MODE" != "system" ]]; then
return
fi
if [[ "$(id -u)" -ne 0 ]]; then
die "System install requires root privileges (sudo/root)."
fi
}
download_file() {
local url="$1"
local out="$2"
log "Downloading: $url"
if command_exists curl; then
curl -fsSL "$url" -o "$out"
return
fi
if command_exists wget; then
wget -qO "$out" "$url"
return
fi
die "Neither curl nor wget is available."
}
base_url() {
case "$SOURCE" in
github) echo "$BASE_GITHUB" ;;
cdn) echo "$BASE_CDN" ;;
*) die "Invalid source: $SOURCE" ;;
esac
}
asset_for() {
local os="$1"
local arch="$2"
TAR_FILE=""
DEB_FILE=""
RPM_FILE=""
case "$os/$arch" in
windows/x86_64) TAR_FILE="picoclaw_Windows_x86_64.zip" ;;
windows/arm64) TAR_FILE="picoclaw_Windows_arm64.zip" ;;
linux/x86_64)
TAR_FILE="picoclaw_Linux_x86_64.tar.gz"
DEB_FILE="picoclaw_x86_64.deb"
RPM_FILE="picoclaw_x86_64.rpm"
;;
linux/arm64)
TAR_FILE="picoclaw_Linux_arm64.tar.gz"
DEB_FILE="picoclaw_aarch64.deb"
RPM_FILE="picoclaw_aarch64.rpm"
;;
linux/armv7)
TAR_FILE="picoclaw_Linux_armv7.tar.gz"
DEB_FILE="picoclaw_armv7.deb"
RPM_FILE="picoclaw_armv7.rpm"
;;
linux/armv6)
TAR_FILE="picoclaw_Linux_armv6.tar.gz"
DEB_FILE="picoclaw_armv6.deb"
RPM_FILE="picoclaw_armv6.rpm"
;;
linux/riscv64)
TAR_FILE="picoclaw_Linux_riscv64.tar.gz"
DEB_FILE="picoclaw_riscv64.deb"
RPM_FILE="picoclaw_riscv64.rpm"
;;
linux/mipsle)
TAR_FILE="picoclaw_Linux_mipsle.tar.gz"
DEB_FILE="picoclaw_mipsle.deb"
RPM_FILE="picoclaw_mipsle.rpm"
;;
linux/s390x)
TAR_FILE="picoclaw_Linux_s390x.tar.gz"
DEB_FILE="picoclaw_s390x.deb"
RPM_FILE="picoclaw_s390x.rpm"
;;
macos/x86_64) TAR_FILE="picoclaw_Darwin_x86_64.tar.gz" ;;
macos/arm64) TAR_FILE="picoclaw_Darwin_arm64.tar.gz" ;;
freebsd/x86_64) TAR_FILE="picoclaw_Freebsd_x86_64.tar.gz" ;;
freebsd/arm64) TAR_FILE="picoclaw_Freebsd_arm64.tar.gz" ;;
freebsd/armv7) TAR_FILE="picoclaw_Freebsd_armv7.tar.gz" ;;
netbsd/x86_64) TAR_FILE="picoclaw_Netbsd_x86_64.tar.gz" ;;
netbsd/arm64) TAR_FILE="picoclaw_Netbsd_arm64.tar.gz" ;;
*) die "No asset mapping for $os/$arch" ;;
esac
}
resolve_unix_system_layout() {
LAYOUT_MODE="symlink"
if [[ -d "/usr/local/share" ]]; then
INSTALL_ROOT="/usr/local/share/picoclaw"
LINK_DIR="/usr/local/bin"
return
fi
if [[ -d "/usr/share" ]]; then
INSTALL_ROOT="/usr/share/picoclaw"
LINK_DIR="/usr/bin"
return
fi
INSTALL_ROOT="$(create_secure_tmp_dir "picoclaw-install" || true)"
[[ -n "$INSTALL_ROOT" ]] || die "Failed to create secure temporary install directory under /tmp"
LINK_DIR="/usr/bin"
LAYOUT_MODE="copy-executables"
}
resolve_layout() {
local os="$1"
if [[ "$INSTALL_MODE" == "user" ]]; then
INSTALL_ROOT="$HOME/.local/share/picoclaw"
LINK_DIR="$HOME/.local/bin"
LAYOUT_MODE="user"
return
fi
case "$os" in
linux|macos|freebsd|netbsd)
resolve_unix_system_layout
;;
*)
die "Bash installer does not support this OS: $os"
;;
esac
}
install_via_package_manager_linux() {
local tmp_dir="$1"
local base
base="$(base_url)"
if [[ -n "$DEB_FILE" ]] && command_exists dpkg; then
local deb_path="$tmp_dir/$DEB_FILE"
download_file "${base}${DEB_FILE}" "$deb_path"
dpkg -i "$deb_path"
PM_RESULT="dpkg:$deb_path"
return 0
fi
if [[ -n "$RPM_FILE" ]] && command_exists rpm; then
local rpm_path="$tmp_dir/$RPM_FILE"
download_file "${base}${RPM_FILE}" "$rpm_path"
rpm -Uvh --replacepkgs "$rpm_path"
PM_RESULT="rpm:$rpm_path"
return 0
fi
PM_RESULT=""
return 1
}
extract_archive() {
local archive="$1"
local dst="$2"
mkdir -p "$dst"
case "$archive" in
*.tar.gz)
tar -xzf "$archive" -C "$dst"
;;
*.zip)
if command_exists unzip; then
unzip -o "$archive" -d "$dst" >/dev/null
else
die "unzip is required for zip archives"
fi
;;
*)
die "Unsupported archive: $archive"
;;
esac
}
resolve_flat_binary_path() {
local root="$1"
local name="$2"
local candidate="$root/$name"
if [[ -f "$candidate" ]]; then
printf '%s\n' "$candidate"
return 0
fi
return 1
}
link_expected_bins() {
local root="$1"
local link_dir="$2"
mkdir -p "$link_dir"
LINKED_PATHS=()
local name bin target
for name in "${EXPECTED_BINS[@]}"; do
bin="$(resolve_flat_binary_path "$root" "$name" || true)"
if [[ -z "$bin" ]]; then
continue
fi
target="$link_dir/$name"
rm -f "$target"
ln -s "$bin" "$target"
LINKED_PATHS+=("$target")
done
}
copy_executables_to_bin() {
local root="$1"
local bin_dir="$2"
mkdir -p "$bin_dir"
COPIED_PATHS=()
local name file dest
for name in "${EXPECTED_BINS[@]}"; do
file="$(resolve_flat_binary_path "$root" "$name" || true)"
if [[ -z "$file" ]]; then
continue
fi
if [[ ! -x "$file" ]]; then
continue
fi
dest="$bin_dir/$name"
cp -f "$file" "$dest"
chmod +x "$dest" || true
COPIED_PATHS+=("$dest")
done
}
ensure_user_path_export() {
local line='export PATH="$HOME/.local/bin:$PATH"'
local profile
if [[ -f "$HOME/.bashrc" ]]; then
profile="$HOME/.bashrc"
elif [[ -f "$HOME/.bash_profile" ]]; then
profile="$HOME/.bash_profile"
else
profile="$HOME/.profile"
fi
touch "$profile"
if ! grep -Fq "$line" "$profile"; then
printf '\n%s\n' "$line" >> "$profile"
fi
USER_PROFILE="$profile"
}
warn_missing_user_local() {
if [[ "$INSTALL_MODE" != "user" ]]; then
return
fi
if [[ ! -d "$HOME/.local" ]]; then
printf '\033[33mWarning: %s/.local does not exist. The installer will create it automatically.\033[0m\n' "$HOME"
fi
}
while [[ $# -gt 0 ]]; do
case "$1" in
--mode)
INSTALL_MODE="${2:-}"
shift 2
;;
--source)
SOURCE="${2:-}"
shift 2
;;
--arch)
ARCH_OVERRIDE="${2:-}"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
die "Unknown argument: $1"
;;
esac
done
[[ "$INSTALL_MODE" == "system" || "$INSTALL_MODE" == "user" ]] || die "--mode must be system or user"
[[ "$SOURCE" == "github" || "$SOURCE" == "cdn" ]] || die "--source must be github or cdn"
OS_ID="$(detect_os)"
ARCH_ID="$(detect_arch)"
assert_privileges
warn_missing_user_local
asset_for "$OS_ID" "$ARCH_ID"
resolve_layout "$OS_ID"
TMP_DIR="$(create_secure_tmp_dir "picoclaw-download")"
[[ -n "$TMP_DIR" ]] || die "Failed to create temporary working directory"
trap 'rm -rf "$TMP_DIR"' EXIT
log "Install mode: $INSTALL_MODE"
log "OS/Arch: $OS_ID/$ARCH_ID"
if [[ "$OS_ID" == "linux" && "$INSTALL_MODE" == "system" ]]; then
if install_via_package_manager_linux "$TMP_DIR"; then
log "Installed via package manager: ${PM_RESULT%%:*}"
log "Package file: ${PM_RESULT#*:}"
log "Install layout is managed by the package manager."
exit 0
fi
fi
ARCHIVE_PATH="$TMP_DIR/$TAR_FILE"
download_file "$(base_url)${TAR_FILE}" "$ARCHIVE_PATH"
mkdir -p "$INSTALL_ROOT"
extract_archive "$ARCHIVE_PATH" "$INSTALL_ROOT"
if [[ "$LAYOUT_MODE" == "copy-executables" ]]; then
copy_executables_to_bin "$INSTALL_ROOT" "$LINK_DIR"
log "Extracted to: $INSTALL_ROOT"
log "Copied executable files to: $LINK_DIR"
for p in "${COPIED_PATHS[@]:-}"; do
[[ -n "$p" ]] && log " - $p"
done
elif [[ "$LAYOUT_MODE" == "symlink" || "$LAYOUT_MODE" == "user" ]]; then
link_expected_bins "$INSTALL_ROOT" "$LINK_DIR"
log "Extracted to: $INSTALL_ROOT"
log "Created links in: $LINK_DIR"
for p in "${LINKED_PATHS[@]:-}"; do
[[ -n "$p" ]] && log " - $p"
done
fi
if [[ "$INSTALL_MODE" == "user" ]]; then
ensure_user_path_export
log "PATH export ensured in: $USER_PROFILE"
fi