feat(install): add Windows PowerShell installer and copy buttons on site

Add install.ps1 mirroring install.sh for Windows: arch detection,
release zip download, binary extraction, user PATH update.

- copy install.ps1 to site public/ at build time (copy-install.mjs)
- serve /install.ps1 with text/plain Content-Type (vercel.json)
- add PowerShell one-liner to install page and README
- bump install.ps1 default version in bump_version.sh
- add CopyButton component next to every install command line
This commit is contained in:
Christian Visintin
2026-06-07 23:04:39 +02:00
parent d070825dd7
commit f92cb93755
9 changed files with 292 additions and 18 deletions

View File

@@ -173,7 +173,13 @@ curl --proto '=https' --tlsv1.2 -sSLf https://termscp.rs/install.sh | sh
> ❗ MacOs installation requires [Homebrew](https://brew.sh/), otherwise the Rust compiler will be installed
while if you're a Windows user, you can install termscp with [Chocolatey](https://chocolatey.org/):
if you're a Windows user, you can install termscp from PowerShell with a single command:
```ps
irm https://termscp.rs/install.ps1 | iex
```
or, alternatively, with [Chocolatey](https://chocolatey.org/):
```ps
choco install termscp

View File

@@ -16,6 +16,9 @@ sedi "s/^version = \"[0-9][0-9A-Za-z.\\-]*\"/version = \"$VERSION\"/m" "$ROOT/Ca
# install.sh — the literal default assignment only
sedi "s/^TERMSCP_VERSION=\"[0-9][0-9A-Za-z.\\-]*\"/TERMSCP_VERSION=\"$VERSION\"/m" "$ROOT/install.sh"
# install.ps1 — the default -Version parameter value only
sedi "s/\\\$Version = \"[0-9][0-9A-Za-z.\\-]*\"/\\\$Version = \"$VERSION\"/" "$ROOT/install.ps1"
# README.md — version + release date
sedi "s/Current version: [0-9][0-9A-Za-z.\\-]* [0-9]{4}-[0-9]{2}-[0-9]{2}/Current version: $VERSION $DATE/" "$ROOT/README.md"

181
install.ps1 Normal file
View File

@@ -0,0 +1,181 @@
#!/usr/bin/env pwsh
<#
.SYNOPSIS
Installer for termscp on Windows.
.DESCRIPTION
Downloads the latest (or a specified) termscp release for Windows from
GitHub, extracts the binary into an install directory and adds it to the
current user's PATH.
.PARAMETER Version
The termscp version to install (defaults to the latest released version).
.PARAMETER InstallDir
The directory the termscp.exe binary is installed into.
Defaults to "$env:LOCALAPPDATA\Programs\termscp".
.PARAMETER Force
Skip the confirmation prompt during installation. Alias: -Yes.
.EXAMPLE
irm https://termscp.rs/install.ps1 | iex
.EXAMPLE
.\install.ps1 -Version 1.0.0 -Force
#>
[CmdletBinding()]
param(
[string]$Version = "1.0.0",
[string]$InstallDir = "$env:LOCALAPPDATA\Programs\termscp",
[Alias("Yes")]
[switch]$Force
)
$ErrorActionPreference = "Stop"
$GithubUrl = "https://github.com/veeso/termscp/releases/download/v$Version"
# -- output helpers ----------------------------------------------------------
function Write-Info {
param([string]$Message)
Write-Host "> " -ForegroundColor DarkGray -NoNewline
Write-Host $Message
}
function Write-Warn {
param([string]$Message)
Write-Host "! $Message" -ForegroundColor Yellow
}
function Write-Err {
param([string]$Message)
Write-Host "x $Message" -ForegroundColor Red
}
function Write-Completed {
param([string]$Message)
Write-Host "" -ForegroundColor Green -NoNewline
Write-Host $Message
}
function Confirm-Action {
param([string]$Message)
if ($Force) {
return
}
$answer = Read-Host "? $Message [y/N]"
if ($answer -ne "y" -and $answer -ne "yes") {
Write-Err 'Aborting (please answer "yes" to continue)'
exit 1
}
}
# -- platform detection ------------------------------------------------------
# Currently supporting:
# - x86_64 (AMD64)
# - aarch64 (ARM64)
function Get-TermscpTarget {
$arch = $env:PROCESSOR_ARCHITECTURE
if ($env:PROCESSOR_ARCHITEW6432) {
$arch = $env:PROCESSOR_ARCHITEW6432
}
switch ($arch.ToUpper()) {
"AMD64" { return "x86_64-pc-windows-msvc" }
"ARM64" { return "aarch64-pc-windows-msvc" }
default {
Write-Err "Unsupported architecture: $arch"
Write-Info "Only x86_64 (AMD64) and aarch64 (ARM64) are supported by this installer."
Write-Info "Alternatively you can install termscp with Cargo <https://www.rust-lang.org/tools/install>: cargo install termscp --locked"
exit 1
}
}
}
# -- installation ------------------------------------------------------------
function Install-Termscp {
$target = Get-TermscpTarget
$asset = "termscp-v$Version-$target.zip"
$url = "$GithubUrl/$asset"
Write-Host ""
Write-Host " Termscp configuration"
Write-Info "Version: $Version"
Write-Info "Target: $target"
Write-Info "Install dir: $InstallDir"
Write-Host ""
Confirm-Action "Install termscp $Version?"
$tmpDir = Join-Path ([System.IO.Path]::GetTempPath()) "termscp-$([System.IO.Path]::GetRandomFileName())"
New-Item -ItemType Directory -Force -Path $tmpDir | Out-Null
try {
$archive = Join-Path $tmpDir $asset
Write-Info "Downloading termscp from $url"
try {
Invoke-WebRequest -Uri $url -OutFile $archive -UseBasicParsing
} catch {
Write-Err "Failed to download termscp: $($_.Exception.Message)"
Write-Warn "If you believe this is a bug, please report an issue at <https://github.com/veeso/termscp/issues/new>"
exit 1
}
Write-Info "Extracting archive …"
Expand-Archive -Path $archive -DestinationPath $tmpDir -Force
if (-not (Test-Path $InstallDir)) {
New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null
}
$binary = Join-Path $tmpDir "termscp.exe"
if (-not (Test-Path $binary)) {
Write-Err "termscp.exe not found in the downloaded archive"
exit 1
}
Write-Info "Installing termscp to $InstallDir"
Copy-Item -Path $binary -Destination (Join-Path $InstallDir "termscp.exe") -Force
Add-ToUserPath -Directory $InstallDir
} finally {
Remove-Item -Path $tmpDir -Recurse -Force -ErrorAction SilentlyContinue
}
}
function Add-ToUserPath {
param([string]$Directory)
$userPath = [Environment]::GetEnvironmentVariable("Path", "User")
$entries = @()
if ($userPath) {
$entries = $userPath.Split(";") | Where-Object { $_ -ne "" }
}
if ($entries -contains $Directory) {
return
}
Write-Info "Adding $Directory to your user PATH …"
$newPath = (@($entries) + $Directory) -join ";"
[Environment]::SetEnvironmentVariable("Path", $newPath, "User")
# make termscp available in the current session too
$env:Path = "$env:Path;$Directory"
Write-Warn "Restart your terminal for the PATH change to take effect in new sessions."
}
# -- main --------------------------------------------------------------------
Install-Termscp
Write-Completed "Congratulations! Termscp has successfully been installed on your system!"
Write-Info "If you're a new user, you might be interested in reading the user manual <https://docs.termscp.rs>"
Write-Info "Remember that if you encounter any issue, you can report them on Github <https://github.com/veeso/termscp/issues/new>"
Write-Info "Feel free to open an issue also if you have an idea which could improve the project"
Write-Info "I hope you'll enjoy using termscp :D"
exit 0

1
site/.gitignore vendored
View File

@@ -2,3 +2,4 @@ dist/
.astro/
node_modules/
public/install.sh
public/install.ps1

View File

@@ -3,16 +3,24 @@ import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const here = dirname(fileURLToPath(import.meta.url));
const src = join(here, '..', '..', 'install.sh'); // repo-root install.sh (single source of truth)
const dest = join(here, '..', 'public', 'install.sh');
try {
await access(src);
} catch {
console.error(`[copy-install] source not found: ${src}`);
console.error('[copy-install] the repo-root install.sh must be available at build time.');
process.exit(1);
}
// repo-root install scripts (single source of truth)
const scripts = ['install.sh', 'install.ps1'];
await copyFile(src, dest);
console.log('[copy-install] copied install.sh -> public/install.sh');
await Promise.all(
scripts.map(async (name) => {
const src = join(here, '..', '..', name);
const dest = join(here, '..', 'public', name);
try {
await access(src);
} catch {
console.error(`[copy-install] source not found: ${src}`);
console.error(`[copy-install] the repo-root ${name} must be available at build time.`);
process.exit(1);
}
await copyFile(src, dest);
console.log(`[copy-install] copied ${name} -> public/${name}`);
}),
);

View File

@@ -0,0 +1,54 @@
---
interface Props {
text: string;
class?: string;
}
const { text, class: className } = Astro.props;
---
<button
type="button"
class:list={[
"copy-btn rounded p-1.5 text-overlay transition-colors hover:bg-mantle hover:text-text",
className,
]}
data-copy={text}
aria-label="Copy command to clipboard"
title="Copy"
>
<svg class="copy-icon h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
<svg class="check-icon hidden h-4 w-4 text-green" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M20 6 9 17l-5-5"></path>
</svg>
</button>
<script>
const RESET_DELAY_MS = 1500;
const handleCopy = async (button: HTMLElement) => {
const text = button.getAttribute("data-copy") ?? "";
try {
await navigator.clipboard.writeText(text);
} catch {
return;
}
const copyIcon = button.querySelector(".copy-icon");
const checkIcon = button.querySelector(".check-icon");
copyIcon?.classList.add("hidden");
checkIcon?.classList.remove("hidden");
window.setTimeout(() => {
copyIcon?.classList.remove("hidden");
checkIcon?.classList.add("hidden");
}, RESET_DELAY_MS);
};
document.addEventListener("click", (event) => {
const button = (event.target as HTMLElement)?.closest<HTMLElement>(".copy-btn");
if (button) {
handleCopy(button);
}
});
</script>

View File

@@ -1,4 +1,6 @@
---
import CopyButton from "./CopyButton.astro";
interface Method { id: string; label: string; cmd: string; }
interface Props { methods: Method[]; }
const { methods } = Astro.props;
@@ -14,11 +16,10 @@ const { methods } = Astro.props;
))}
</div>
{methods.map((m, i) => (
<pre
class="install-panel m-0 overflow-auto bg-base px-4 py-4 text-sm text-text"
data-id={m.id}
hidden={i !== 0}
><code>{m.cmd}</code></pre>
<div class="install-panel relative" data-id={m.id} hidden={i !== 0}>
<pre class="m-0 overflow-auto bg-base px-4 py-4 pr-12 text-sm text-text"><code>{m.cmd}</code></pre>
<CopyButton text={m.cmd} class="absolute right-2 top-2" />
</div>
))}
</div>
<script is:inline>

View File

@@ -3,9 +3,11 @@ import Base from "../layouts/Base.astro";
import Nav from "../components/Nav.astro";
import Footer from "../components/Footer.astro";
import InstallTabs from "../components/InstallTabs.astro";
import CopyButton from "../components/CopyButton.astro";
import { DOCS_URL } from "../consts";
const script = "curl --proto '=https' --tlsv1.2 -sSLf https://termscp.rs/install.sh | sh";
const psScript = "irm https://termscp.rs/install.ps1 | iex";
const methods = [
{ id: "cargo", label: "Cargo", cmd: "cargo install termscp --locked" },
@@ -28,10 +30,24 @@ const methods = [
<div class="flex items-center gap-2 border-b border-line px-3 py-2 text-xs text-overlay">
<span class="text-green">$</span> Linux · macOS · BSD
</div>
<pre class="m-0 overflow-auto bg-base px-4 py-4 text-sm text-text"><code>{script}</code></pre>
<div class="relative">
<pre class="m-0 overflow-auto bg-base px-4 py-4 pr-12 text-sm text-text"><code>{script}</code></pre>
<CopyButton text={script} class="absolute right-2 top-2" />
</div>
</div>
<p class="mt-2 text-xs text-overlay">Requires <span class="text-text">curl</span>. macOS uses Homebrew if available, otherwise builds from source.</p>
<div class="mt-6 rounded-lg border border-line bg-mantle p-1">
<div class="flex items-center gap-2 border-b border-line px-3 py-2 text-xs text-overlay">
<span class="text-green">&gt;</span> Windows (PowerShell)
</div>
<div class="relative">
<pre class="m-0 overflow-auto bg-base px-4 py-4 pr-12 text-sm text-text"><code>{psScript}</code></pre>
<CopyButton text={psScript} class="absolute right-2 top-2" />
</div>
</div>
<p class="mt-2 text-xs text-overlay">Downloads the latest release and adds <span class="text-text">termscp</span> to your user PATH.</p>
<h2 class="mt-10 mb-3 text-lg text-text">Package managers</h2>
<InstallTabs methods={methods} />

View File

@@ -17,6 +17,10 @@
{
"source": "/install.sh",
"headers": [{ "key": "Content-Type", "value": "text/x-shellscript; charset=utf-8" }]
},
{
"source": "/install.ps1",
"headers": [{ "key": "Content-Type", "value": "text/plain; charset=utf-8" }]
}
]
}