diff --git a/scripts/install/maixcam/install_picoclaw.py b/scripts/install/maixcam/install_picoclaw.py new file mode 100644 index 0000000000..f3a082a731 --- /dev/null +++ b/scripts/install/maixcam/install_picoclaw.py @@ -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() diff --git a/scripts/install/picoclaw/install-picoclaw.ps1 b/scripts/install/picoclaw/install-picoclaw.ps1 new file mode 100644 index 0000000000..1b7e890b26 --- /dev/null +++ b/scripts/install/picoclaw/install-picoclaw.ps1 @@ -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 + } + } +} \ No newline at end of file diff --git a/scripts/install/picoclaw/install-picoclaw.sh b/scripts/install/picoclaw/install-picoclaw.sh new file mode 100644 index 0000000000..58caebc166 --- /dev/null +++ b/scripts/install/picoclaw/install-picoclaw.sh @@ -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