feat: harden local shell, SFTP, and desktop session reliability
Finder-launched terminals now inherit a login PATH so tools like ollama resolve, destructive SFTP/editor actions require confirmation, and terminal search plus window dragging work again. SSH forwards and RDP sessions keep listeners and survive reconnect without FastPath crashes. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
188f3556ed
commit
08696896f9
223 files changed
+13493
-3423
No files matched your search
@@ -17,7 +17,7 @@ const manifest = 'src-tauri/Cargo.toml'
|
||||
const quickSteps = [
|
||||
['Renderer type contract', npm, ['run', 'typecheck:ui']],
|
||||
['Test and mock contracts', npm, ['run', 'typecheck:test']],
|
||||
['Unit and foundation smoke tests', npm, ['test']],
|
||||
['Unit + SSH/SFTP/RDP/VNC patrol', npm, ['test']],
|
||||
['Rust formatting', cargo, ['fmt', '--manifest-path', manifest, '--', '--check']],
|
||||
['Rust compile check', cargo, ['check', '--manifest-path', manifest]],
|
||||
]
|
||||
|
||||
@@ -10,10 +10,10 @@ import { inspectVersions, setVersion } from './set-version.mjs'
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
|
||||
test('build.sh is valid Bash and documents supported native targets', () => {
|
||||
const syntax = spawnSync('bash', ['-n', 'build.sh'], { cwd: root, encoding: 'utf8' })
|
||||
const syntax = spawnSync('bash', ['-n', 'scripts/build.sh'], { cwd: root, encoding: 'utf8' })
|
||||
assert.equal(syntax.status, 0, syntax.stderr)
|
||||
|
||||
const help = spawnSync('bash', ['build.sh', '--help'], { cwd: root, encoding: 'utf8' })
|
||||
const help = spawnSync('bash', ['scripts/build.sh', '--help'], { cwd: root, encoding: 'utf8' })
|
||||
assert.equal(help.status, 0, help.stderr)
|
||||
assert.match(help.stdout, /mac-arm64/)
|
||||
assert.match(help.stdout, /win-x64/)
|
||||
@@ -21,7 +21,7 @@ test('build.sh is valid Bash and documents supported native targets', () => {
|
||||
})
|
||||
|
||||
test('build.bat provides an equivalent native Windows x64 release path', () => {
|
||||
const script = readFileSync(resolve(root, 'build.bat'), 'utf8')
|
||||
const script = readFileSync(resolve(root, 'scripts/build.bat'), 'utf8')
|
||||
assert.match(script, /x86_64-pc-windows-msvc/)
|
||||
assert.match(script, /acceptance:full/)
|
||||
assert.match(script, /release-preflight\.mjs windows/)
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
@echo off
|
||||
setlocal EnableExtensions EnableDelayedExpansion
|
||||
chcp 65001 >nul
|
||||
|
||||
rem OpsXShell v2.1.x native Windows release builder.
|
||||
rem Run this script from Windows x64 with the MSVC Rust toolchain installed.
|
||||
|
||||
cd /d "%~dp0.."
|
||||
set "VERSION_ARG="
|
||||
set "CHECK_MODE=full"
|
||||
set "RUST_TARGET=x86_64-pc-windows-msvc"
|
||||
set "OUTPUT_PLATFORM=windows-x64"
|
||||
|
||||
:parse_args
|
||||
if "%~1"=="" goto args_done
|
||||
set "ARG=%~1"
|
||||
|
||||
if /I "!ARG!"=="-h" goto usage_ok
|
||||
if /I "!ARG!"=="--help" goto usage_ok
|
||||
if /I "!ARG!"=="--quick" (
|
||||
set "CHECK_MODE=quick"
|
||||
shift
|
||||
goto parse_args
|
||||
)
|
||||
if /I "!ARG!"=="--skip-checks" (
|
||||
set "CHECK_MODE=none"
|
||||
shift
|
||||
goto parse_args
|
||||
)
|
||||
if /I "!ARG!"=="win-x64" (
|
||||
shift
|
||||
goto parse_args
|
||||
)
|
||||
if /I "!ARG:~0,4!"=="v2.1" goto set_version_arg
|
||||
if /I "!ARG:~0,4!"=="2.1." goto set_version_arg
|
||||
call :fail "未知参数:!ARG!(使用 scripts\build.bat --help 查看用法)"
|
||||
exit /b 1
|
||||
|
||||
:set_version_arg
|
||||
if defined VERSION_ARG (
|
||||
call :fail "只能指定一个版本号"
|
||||
exit /b 1
|
||||
)
|
||||
set "VERSION_ARG=!ARG!"
|
||||
if /I "!VERSION_ARG:~0,1!"=="v" set "VERSION_ARG=!VERSION_ARG:~1!"
|
||||
shift
|
||||
goto parse_args
|
||||
|
||||
:args_done
|
||||
if /I not "%OS%"=="Windows_NT" (
|
||||
call :fail "build.bat 必须在 Windows 环境运行"
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
for %%C in (node npm npx cargo rustc powershell) do (
|
||||
where %%C >nul 2>nul
|
||||
if errorlevel 1 (
|
||||
call :fail "缺少构建依赖:%%C"
|
||||
exit /b 1
|
||||
)
|
||||
)
|
||||
|
||||
for /f "usebackq delims=" %%V in (`node -p "require('./package.json').version"`) do set "CURRENT_VERSION=%%V"
|
||||
if not defined CURRENT_VERSION (
|
||||
call :fail "无法读取 package.json 版本"
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
if defined VERSION_ARG (
|
||||
set "VERSION=%VERSION_ARG%"
|
||||
) else (
|
||||
set "VERSION=%CURRENT_VERSION%"
|
||||
)
|
||||
|
||||
node -e "if (!/^2\.1\.\d+(?:[.-][0-9A-Za-z.-]+)?$/.test(process.argv[1])) process.exit(1)" "%VERSION%"
|
||||
if errorlevel 1 (
|
||||
call :fail "版本必须是 v2.1.x 语义版本,当前输入:%VERSION%"
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
where rustup >nul 2>nul
|
||||
if not errorlevel 1 (
|
||||
rustup target list --installed | findstr /X /C:"%RUST_TARGET%" >nul
|
||||
if errorlevel 1 (
|
||||
call :fail "Rust target 未安装:%RUST_TARGET%;请先运行 rustup target add %RUST_TARGET%"
|
||||
exit /b 1
|
||||
)
|
||||
)
|
||||
|
||||
if defined VERSION_ARG if /I not "%VERSION%"=="%CURRENT_VERSION%" (
|
||||
call :log "同步版本号到 v%VERSION%"
|
||||
node scripts\set-version.mjs "%VERSION%"
|
||||
if errorlevel 1 exit /b 1
|
||||
)
|
||||
|
||||
node scripts\set-version.mjs --check "%VERSION%"
|
||||
if errorlevel 1 exit /b 1
|
||||
|
||||
if not exist node_modules\ (
|
||||
call :log "安装锁定依赖"
|
||||
call npm ci
|
||||
if errorlevel 1 exit /b 1
|
||||
)
|
||||
|
||||
set "GIT_SHA=unknown"
|
||||
where git >nul 2>nul
|
||||
if not errorlevel 1 (
|
||||
for /f "delims=" %%G in ('git rev-parse --short HEAD 2^>nul') do set "GIT_SHA=%%G"
|
||||
)
|
||||
call :log "OpsXShell v%VERSION% · win-x64 · !GIT_SHA!"
|
||||
|
||||
if /I "%CHECK_MODE%"=="full" (
|
||||
call :log "运行完整发布门禁"
|
||||
call npm run acceptance:full
|
||||
if errorlevel 1 exit /b 1
|
||||
) else if /I "%CHECK_MODE%"=="quick" (
|
||||
call :log "运行快速发布门禁"
|
||||
call npm run acceptance:quick
|
||||
if errorlevel 1 exit /b 1
|
||||
) else (
|
||||
call :log "已按参数跳过构建前门禁"
|
||||
)
|
||||
|
||||
node scripts\release-preflight.mjs windows
|
||||
if errorlevel 1 exit /b 1
|
||||
|
||||
call :log "构建 Windows x86_64 原生安装包"
|
||||
call npx tauri build --ci --target "%RUST_TARGET%"
|
||||
if errorlevel 1 exit /b 1
|
||||
|
||||
set "BUNDLE_ROOT=src-tauri\target\%RUST_TARGET%\release\bundle"
|
||||
node scripts\verify-release.mjs windows "%BUNDLE_ROOT%"
|
||||
if errorlevel 1 exit /b 1
|
||||
|
||||
set "OUTPUT_DIR=%CD%\release\v%VERSION%\%OUTPUT_PLATFORM%"
|
||||
if exist "%OUTPUT_DIR%" rmdir /S /Q "%OUTPUT_DIR%"
|
||||
mkdir "%OUTPUT_DIR%"
|
||||
if errorlevel 1 (
|
||||
call :fail "无法创建输出目录:%OUTPUT_DIR%"
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
set /A ARTIFACT_COUNT=0
|
||||
for %%F in ("%BUNDLE_ROOT%\nsis\*.exe" "%BUNDLE_ROOT%\msi\*.msi" "%BUNDLE_ROOT%\wix\*.msi") do (
|
||||
if exist "%%~fF" (
|
||||
copy /Y "%%~fF" "%OUTPUT_DIR%\%%~nxF" >nul
|
||||
if errorlevel 1 (
|
||||
call :fail "复制安装包失败:%%~fF"
|
||||
exit /b 1
|
||||
)
|
||||
set /A ARTIFACT_COUNT+=1
|
||||
)
|
||||
)
|
||||
|
||||
if "%ARTIFACT_COUNT%"=="0" (
|
||||
call :fail "构建完成但未找到 NSIS/MSI 安装包:%BUNDLE_ROOT%"
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -Command ^
|
||||
"$files = Get-ChildItem -LiteralPath $env:OUTPUT_DIR -File ^| Where-Object { $_.Extension -in '.exe', '.msi' };" ^
|
||||
"$lines = @($files ^| ForEach-Object { (Get-FileHash -Algorithm SHA256 -LiteralPath $_.FullName).Hash.ToLowerInvariant() + ' ' + $_.Name });" ^
|
||||
"[System.IO.File]::WriteAllLines((Join-Path $env:OUTPUT_DIR 'SHA256SUMS.txt'), [string[]]$lines, (New-Object System.Text.UTF8Encoding($false)))"
|
||||
if errorlevel 1 (
|
||||
call :fail "生成 SHA256SUMS.txt 失败"
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
call :log "构建完成:%OUTPUT_DIR%"
|
||||
dir "%OUTPUT_DIR%"
|
||||
echo.
|
||||
echo [build] SHA-256
|
||||
type "%OUTPUT_DIR%\SHA256SUMS.txt"
|
||||
exit /b 0
|
||||
|
||||
:usage_ok
|
||||
call :usage
|
||||
exit /b 0
|
||||
|
||||
:usage
|
||||
echo OpsXShell Windows 原生安装包构建脚本
|
||||
echo.
|
||||
echo 用法:
|
||||
echo scripts\build.bat [v2.1.x] [win-x64] [选项]
|
||||
echo.
|
||||
echo 选项:
|
||||
echo --quick 构建前只运行快速门禁
|
||||
echo --skip-checks 跳过门禁,仅用于本地重复打包
|
||||
echo -h, --help 显示帮助
|
||||
echo.
|
||||
echo 示例:
|
||||
echo scripts\build.bat
|
||||
echo scripts\build.bat v2.1.1
|
||||
echo scripts\build.bat v2.1.2 win-x64 --quick
|
||||
echo.
|
||||
echo 环境要求:
|
||||
echo Windows 10/11 x64、Node.js 18+、Rust MSVC toolchain、
|
||||
echo Visual Studio 2022 Build Tools(Desktop development with C++)、
|
||||
echo Windows 10/11 SDK、WebView2 Runtime。
|
||||
echo.
|
||||
echo 说明:
|
||||
echo - 输出 NSIS/MSI 到 release\v版本\windows-x64 并生成 SHA256SUMS.txt。
|
||||
echo - 脚本不会创建或推送 Git tag,也不会上传 Release。
|
||||
echo - 正式签名需设置 REQUIRE_SIGNING=true 和 Windows 签名环境变量。
|
||||
exit /b 0
|
||||
|
||||
:log
|
||||
echo.
|
||||
echo [build] %~1
|
||||
exit /b 0
|
||||
|
||||
:fail
|
||||
echo.
|
||||
echo [build] ERROR: %~1 1>&2
|
||||
exit /b 1
|
||||
Executable
+220
@@ -0,0 +1,220 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# OpsXShell v2.1.x native release builder.
|
||||
# Builds only for the current host OS; macOS and Windows installers must be
|
||||
# produced and verified on their respective systems.
|
||||
|
||||
set -Eeuo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
VERSION_ARG=""
|
||||
TARGET="auto"
|
||||
CHECK_MODE="full"
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
OpsXShell 原生安装包构建脚本
|
||||
|
||||
用法:
|
||||
./scripts/build.sh [v2.1.x] [目标] [选项]
|
||||
|
||||
目标:
|
||||
auto 根据当前系统自动选择(默认)
|
||||
mac-arm64 macOS Apple Silicon,生成 .app 和 .dmg
|
||||
mac-x64 macOS Intel,生成 .app 和 .dmg
|
||||
win-x64 Windows x86_64,生成 NSIS/MSI 安装包
|
||||
|
||||
选项:
|
||||
--quick 构建前只运行快速门禁
|
||||
--skip-checks 跳过门禁,仅用于本地重复打包
|
||||
-h, --help 显示帮助
|
||||
|
||||
示例:
|
||||
./scripts/build.sh
|
||||
./scripts/build.sh v2.1.0 mac-arm64
|
||||
./scripts/build.sh v2.1.1 mac-arm64 --quick
|
||||
|
||||
说明:
|
||||
- 显式传入新版本时,会同步 package.json、package-lock.json、
|
||||
Cargo.toml、Cargo.lock 和 tauri.conf.json,但不会创建或推送 Git tag。
|
||||
- Windows 包必须在 Windows 上构建;macOS 包必须在 macOS 上构建。
|
||||
- 正式签名构建请先设置 REQUIRE_SIGNING=true 及对应签名环境变量。
|
||||
EOF
|
||||
}
|
||||
|
||||
fail() {
|
||||
printf '\n[build] ERROR: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
log() {
|
||||
printf '\n[build] %s\n' "$*"
|
||||
}
|
||||
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
--quick)
|
||||
CHECK_MODE="quick"
|
||||
;;
|
||||
--skip-checks)
|
||||
CHECK_MODE="none"
|
||||
;;
|
||||
auto|mac-arm64|mac-x64|win-x64)
|
||||
TARGET="$1"
|
||||
;;
|
||||
v[0-9]*|[0-9]*)
|
||||
[ -z "$VERSION_ARG" ] || fail "只能指定一个版本号"
|
||||
VERSION_ARG="${1#v}"
|
||||
;;
|
||||
*)
|
||||
fail "未知参数:$1(使用 ./scripts/build.sh --help 查看用法)"
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
for command in node npm npx cargo rustc; do
|
||||
command -v "$command" >/dev/null 2>&1 || fail "缺少构建依赖:$command"
|
||||
done
|
||||
|
||||
CURRENT_VERSION="$(node -p "require('./package.json').version")"
|
||||
VERSION="${VERSION_ARG:-$CURRENT_VERSION}"
|
||||
|
||||
if ! printf '%s' "$VERSION" | grep -Eq '^2\.1\.[0-9]+([.-][0-9A-Za-z.-]+)?$'; then
|
||||
fail "版本必须是 v2.1.x 语义版本,当前输入:$VERSION"
|
||||
fi
|
||||
|
||||
HOST_OS="$(uname -s)"
|
||||
HOST_ARCH="$(uname -m)"
|
||||
if [ "$TARGET" = "auto" ]; then
|
||||
case "$HOST_OS/$HOST_ARCH" in
|
||||
Darwin/arm64|Darwin/aarch64) TARGET="mac-arm64" ;;
|
||||
Darwin/x86_64) TARGET="mac-x64" ;;
|
||||
MINGW*/*|MSYS*/*|CYGWIN*/*) TARGET="win-x64" ;;
|
||||
*) fail "当前平台 $HOST_OS/$HOST_ARCH 不支持自动打包,请显式指定目标" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
case "$TARGET" in
|
||||
mac-arm64)
|
||||
[ "$HOST_OS" = "Darwin" ] || fail "mac-arm64 必须在 macOS 上构建"
|
||||
RUST_TARGET="aarch64-apple-darwin"
|
||||
RELEASE_PLATFORM="macos"
|
||||
OUTPUT_PLATFORM="macos-arm64"
|
||||
;;
|
||||
mac-x64)
|
||||
[ "$HOST_OS" = "Darwin" ] || fail "mac-x64 必须在 macOS 上构建"
|
||||
RUST_TARGET="x86_64-apple-darwin"
|
||||
RELEASE_PLATFORM="macos"
|
||||
OUTPUT_PLATFORM="macos-x64"
|
||||
;;
|
||||
win-x64)
|
||||
case "$HOST_OS" in
|
||||
MINGW*|MSYS*|CYGWIN*) ;;
|
||||
*) fail "win-x64 必须在 Windows 上构建,macOS 交叉编译不能替代 Windows 实机打包" ;;
|
||||
esac
|
||||
RUST_TARGET="x86_64-pc-windows-msvc"
|
||||
RELEASE_PLATFORM="windows"
|
||||
OUTPUT_PLATFORM="windows-x64"
|
||||
;;
|
||||
esac
|
||||
|
||||
if command -v rustup >/dev/null 2>&1 && ! rustup target list --installed | grep -Fxq "$RUST_TARGET"; then
|
||||
fail "Rust target 未安装:$RUST_TARGET;请先运行 rustup target add $RUST_TARGET"
|
||||
fi
|
||||
|
||||
if [ -n "$VERSION_ARG" ] && [ "$VERSION" != "$CURRENT_VERSION" ]; then
|
||||
log "同步版本号到 v$VERSION"
|
||||
node scripts/set-version.mjs "$VERSION"
|
||||
fi
|
||||
|
||||
node scripts/set-version.mjs --check "$VERSION"
|
||||
|
||||
if [ ! -d node_modules ]; then
|
||||
log "安装锁定依赖"
|
||||
npm ci
|
||||
fi
|
||||
|
||||
log "OpsXShell v$VERSION · $TARGET · $(git rev-parse --short HEAD 2>/dev/null || printf unknown)"
|
||||
|
||||
case "$CHECK_MODE" in
|
||||
full)
|
||||
log "运行完整发布门禁"
|
||||
npm run acceptance:full
|
||||
;;
|
||||
quick)
|
||||
log "运行快速发布门禁"
|
||||
npm run acceptance:quick
|
||||
;;
|
||||
none)
|
||||
log "已按参数跳过构建前门禁"
|
||||
;;
|
||||
esac
|
||||
|
||||
node scripts/release-preflight.mjs "$RELEASE_PLATFORM"
|
||||
|
||||
log "构建原生安装包"
|
||||
if [ "$RELEASE_PLATFORM" = "macos" ]; then
|
||||
npx tauri build --ci --target "$RUST_TARGET" --bundles app,dmg
|
||||
else
|
||||
npx tauri build --ci --target "$RUST_TARGET"
|
||||
fi
|
||||
|
||||
BUNDLE_ROOT="${CARGO_TARGET_DIR:-$ROOT_DIR/src-tauri/target}/$RUST_TARGET/release/bundle"
|
||||
node scripts/verify-release.mjs "$RELEASE_PLATFORM" "$BUNDLE_ROOT"
|
||||
|
||||
OUTPUT_DIR="$ROOT_DIR/release/v$VERSION/$OUTPUT_PLATFORM"
|
||||
rm -rf "$OUTPUT_DIR"
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
shopt -s nullglob
|
||||
|
||||
COPIED_ARTIFACTS=()
|
||||
if [ "$RELEASE_PLATFORM" = "macos" ]; then
|
||||
SOURCE_ARTIFACTS=("$BUNDLE_ROOT"/dmg/*.dmg)
|
||||
else
|
||||
SOURCE_ARTIFACTS=("$BUNDLE_ROOT"/nsis/*.exe "$BUNDLE_ROOT"/msi/*.msi "$BUNDLE_ROOT"/wix/*.msi)
|
||||
fi
|
||||
|
||||
[ "${#SOURCE_ARTIFACTS[@]}" -gt 0 ] || fail "构建完成但未找到安装包:$BUNDLE_ROOT"
|
||||
for artifact in "${SOURCE_ARTIFACTS[@]}"; do
|
||||
destination="$OUTPUT_DIR/$(basename "$artifact")"
|
||||
cp "$artifact" "$destination"
|
||||
COPIED_ARTIFACTS+=("$destination")
|
||||
done
|
||||
|
||||
CHECKSUM_FILE="$OUTPUT_DIR/SHA256SUMS.txt"
|
||||
if command -v shasum >/dev/null 2>&1; then
|
||||
(
|
||||
cd "$OUTPUT_DIR"
|
||||
names=()
|
||||
for artifact in "${COPIED_ARTIFACTS[@]}"; do names+=("$(basename "$artifact")"); done
|
||||
shasum -a 256 "${names[@]}" > SHA256SUMS.txt
|
||||
)
|
||||
elif command -v sha256sum >/dev/null 2>&1; then
|
||||
(
|
||||
cd "$OUTPUT_DIR"
|
||||
names=()
|
||||
for artifact in "${COPIED_ARTIFACTS[@]}"; do names+=("$(basename "$artifact")"); done
|
||||
sha256sum "${names[@]}" > SHA256SUMS.txt
|
||||
)
|
||||
else
|
||||
node --input-type=module -e '
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { basename } from "node:path";
|
||||
for (const path of process.argv.slice(1)) {
|
||||
console.log(`${createHash("sha256").update(readFileSync(path)).digest("hex")} ${basename(path)}`);
|
||||
}
|
||||
' "${COPIED_ARTIFACTS[@]}" > "$CHECKSUM_FILE"
|
||||
fi
|
||||
|
||||
log "构建完成:$OUTPUT_DIR"
|
||||
ls -lh "$OUTPUT_DIR"
|
||||
printf '\n[build] SHA-256\n'
|
||||
cat "$CHECKSUM_FILE"
|
||||
Executable
+281
@@ -0,0 +1,281 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Create an OpsXShell Gitee Release and upload native installers.
|
||||
|
||||
The script is intentionally non-destructive: release artifacts stay in the
|
||||
local ``release/`` directory after a successful upload.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import netrc
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
API_BASE = "https://gitee.com/api/v5"
|
||||
DEFAULT_REPO = "opsx-tools/opsxshell"
|
||||
ALLOWED_SUFFIXES = {".dmg", ".exe", ".msi"}
|
||||
CHECKSUM_NAMES = {"SHA256SUMS.txt"}
|
||||
MAX_ASSET_BYTES = 100 * 1024 * 1024
|
||||
|
||||
|
||||
def info(message: str) -> None:
|
||||
print(f"[release] {message}")
|
||||
|
||||
|
||||
def fail(message: str) -> None:
|
||||
raise RuntimeError(message)
|
||||
|
||||
|
||||
def normalize_version(value: str) -> str:
|
||||
version = value if value.startswith("v") else f"v{value}"
|
||||
parts = version.removeprefix("v").split(".")
|
||||
if len(parts) != 3 or not all(part.isdigit() for part in parts):
|
||||
fail(f"版本号必须是 vX.Y.Z,当前输入:{value}")
|
||||
return version
|
||||
|
||||
|
||||
def detect_repo() -> str:
|
||||
result = subprocess.run(
|
||||
["git", "remote", "get-url", "origin"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return DEFAULT_REPO
|
||||
|
||||
remote = result.stdout.strip().removesuffix(".git")
|
||||
if remote.startswith("git@gitee.com:"):
|
||||
return remote.split(":", 1)[1]
|
||||
parsed = urlparse(remote)
|
||||
if parsed.hostname == "gitee.com" and parsed.path.strip("/"):
|
||||
return parsed.path.strip("/")
|
||||
return DEFAULT_REPO
|
||||
|
||||
|
||||
def load_token() -> str:
|
||||
token = os.environ.get("GITEE_TOKEN", "").strip()
|
||||
if token:
|
||||
return token
|
||||
try:
|
||||
auth = netrc.netrc().authenticators("gitee.com")
|
||||
except (FileNotFoundError, netrc.NetrcParseError, OSError):
|
||||
auth = None
|
||||
if auth and auth[2]:
|
||||
return auth[2]
|
||||
fail("缺少 Gitee Token:请设置 GITEE_TOKEN 或 ~/.netrc 的 gitee.com password")
|
||||
|
||||
|
||||
def collect_assets(root: Path, version: str) -> list[Path]:
|
||||
version_dir = root / "release" / version
|
||||
if not version_dir.is_dir():
|
||||
fail(f"发布目录不存在:{version_dir}")
|
||||
|
||||
assets = sorted(
|
||||
path
|
||||
for path in version_dir.rglob("*")
|
||||
if path.is_file()
|
||||
and (path.suffix.lower() in ALLOWED_SUFFIXES or path.name in CHECKSUM_NAMES)
|
||||
)
|
||||
installers = [path for path in assets if path.suffix.lower() in ALLOWED_SUFFIXES]
|
||||
checksums = [path for path in assets if path.name in CHECKSUM_NAMES]
|
||||
if not installers:
|
||||
fail(f"{version_dir} 中没有安装包")
|
||||
if not checksums:
|
||||
fail(f"{version_dir} 中没有 SHA256SUMS.txt")
|
||||
|
||||
duplicate_names = {path.name for path in assets if sum(item.name == path.name for item in assets) > 1}
|
||||
if duplicate_names:
|
||||
fail(f"附件文件名重复:{', '.join(sorted(duplicate_names))}")
|
||||
for path in assets:
|
||||
if path.stat().st_size > MAX_ASSET_BYTES:
|
||||
fail(f"附件超过 Gitee 100 MB 限制:{path.name}")
|
||||
return assets
|
||||
|
||||
|
||||
def release_body(version: str, repo: str, assets: list[Path]) -> str:
|
||||
installers = [path for path in assets if path.suffix.lower() in ALLOWED_SUFFIXES]
|
||||
lines = [
|
||||
f"## OpsXShell {version}",
|
||||
"",
|
||||
"轻量原生的 SSH/SFTP 远程运维工作台,基于 Rust 与 Tauri v2。",
|
||||
"",
|
||||
"### 本版更新",
|
||||
"",
|
||||
"- SSH 终端内置单入口 AI Agent,可执行、观察并继续处理远程任务",
|
||||
"- 完善命令风险分级、危险命令拒绝、执行前确认与循环保护",
|
||||
"- 优化远程 Shell 超时退出后的单窗格恢复和错误提示",
|
||||
"- 完善 SSH/SFTP、终端交互与原生发布验收门禁",
|
||||
"",
|
||||
"### 下载",
|
||||
"",
|
||||
]
|
||||
for path in installers:
|
||||
lower = path.name.lower()
|
||||
if path.suffix.lower() == ".dmg" and ("aarch64" in lower or "arm64" in lower):
|
||||
platform = "macOS Apple Silicon"
|
||||
elif path.suffix.lower() == ".dmg":
|
||||
platform = "macOS Intel"
|
||||
elif path.suffix.lower() in {".exe", ".msi"}:
|
||||
platform = "Windows x86_64"
|
||||
else:
|
||||
platform = "安装包"
|
||||
url = f"https://gitee.com/{repo}/releases/download/{version}/{path.name}"
|
||||
lines.append(f"- **{platform}**:[{path.name}]({url})")
|
||||
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"### macOS 安装",
|
||||
"",
|
||||
"1. 打开 DMG,将 OpsXShell 拖入 Applications。",
|
||||
"2. 当前公开包为 ad-hoc 开发签名且尚未 Apple 公证;首次启动如被 Gatekeeper 拦截,请在 Finder 中右键应用并选择“打开”。",
|
||||
"",
|
||||
"### 完整性校验",
|
||||
"",
|
||||
"下载 `SHA256SUMS.txt` 后,在安装包所在目录执行:",
|
||||
"",
|
||||
"```bash",
|
||||
"shasum -a 256 -c SHA256SUMS.txt",
|
||||
"```",
|
||||
"",
|
||||
"> 本 Release 仅声明附件中实际提供的平台与架构。",
|
||||
]
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
class GiteeRelease:
|
||||
def __init__(self, token: str, repo: str) -> None:
|
||||
self.repo = repo
|
||||
self.headers = {
|
||||
"Authorization": f"token {token}",
|
||||
"User-Agent": "opsxshell-release-uploader",
|
||||
}
|
||||
|
||||
def request(self, method: str, path: str, **kwargs: Any) -> requests.Response:
|
||||
response = requests.request(
|
||||
method,
|
||||
f"{API_BASE}/repos/{self.repo}{path}",
|
||||
headers=self.headers,
|
||||
timeout=kwargs.pop("timeout", 30),
|
||||
**kwargs,
|
||||
)
|
||||
return response
|
||||
|
||||
def get_by_tag(self, version: str) -> dict[str, Any] | None:
|
||||
response = self.request("GET", f"/releases/tags/{version}")
|
||||
if response.status_code == 404:
|
||||
return None
|
||||
if response.status_code != 200:
|
||||
fail(f"读取 Gitee Release 失败:HTTP {response.status_code} {response.text[:300]}")
|
||||
return response.json()
|
||||
|
||||
def upsert(self, version: str, body: str) -> dict[str, Any]:
|
||||
release = self.get_by_tag(version)
|
||||
payload = {
|
||||
"tag_name": version,
|
||||
"name": f"OpsXShell {version}",
|
||||
"body": body,
|
||||
"prerelease": False,
|
||||
}
|
||||
if release:
|
||||
response = self.request("PATCH", f"/releases/{release['id']}", json=payload)
|
||||
action = "更新"
|
||||
else:
|
||||
payload["target_commitish"] = subprocess.check_output(
|
||||
["git", "branch", "--show-current"], text=True
|
||||
).strip()
|
||||
response = self.request("POST", "/releases", json=payload)
|
||||
action = "创建"
|
||||
if response.status_code not in {200, 201}:
|
||||
fail(f"{action} Gitee Release 失败:HTTP {response.status_code} {response.text[:300]}")
|
||||
info(f"{action} Release 成功:{version}")
|
||||
return response.json()
|
||||
|
||||
def details(self, release_id: int) -> dict[str, Any]:
|
||||
response = self.request("GET", f"/releases/{release_id}")
|
||||
if response.status_code != 200:
|
||||
fail(f"读取 Release 附件失败:HTTP {response.status_code} {response.text[:300]}")
|
||||
return response.json()
|
||||
|
||||
def upload(self, release_id: int, asset: Path, existing: dict[str, int]) -> None:
|
||||
expected_size = asset.stat().st_size
|
||||
if existing.get(asset.name) == expected_size:
|
||||
info(f"跳过同名同大小附件:{asset.name}")
|
||||
return
|
||||
if asset.name in existing:
|
||||
fail(f"Release 已存在同名但大小不同的附件,请先人工删除:{asset.name}")
|
||||
info(f"上传 {asset.name}({expected_size / 1024 / 1024:.1f} MB)")
|
||||
with asset.open("rb") as stream:
|
||||
response = self.request(
|
||||
"POST",
|
||||
f"/releases/{release_id}/attach_files",
|
||||
files={"file": (asset.name, stream, "application/octet-stream")},
|
||||
timeout=600,
|
||||
)
|
||||
if response.status_code != 201:
|
||||
fail(f"上传 {asset.name} 失败:HTTP {response.status_code} {response.text[:300]}")
|
||||
|
||||
def verify(self, release_id: int, assets: list[Path]) -> dict[str, Any]:
|
||||
release = self.details(release_id)
|
||||
remote = {item["name"]: int(item.get("size", -1)) for item in release.get("assets", [])}
|
||||
mismatches = [
|
||||
path.name
|
||||
for path in assets
|
||||
if path.name not in remote or remote[path.name] not in {-1, path.stat().st_size}
|
||||
]
|
||||
if mismatches:
|
||||
fail(f"Release 附件核验失败:{', '.join(mismatches)}")
|
||||
return release
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="发布 OpsXShell 原生安装包到 Gitee")
|
||||
parser.add_argument("version", help="版本号,例如 v2.1.1")
|
||||
parser.add_argument("--repo", default=detect_repo(), help="Gitee 仓库,默认从 origin 检测")
|
||||
parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parent.parent)
|
||||
parser.add_argument("--dry-run", action="store_true", help="只检查并列出待上传附件")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
version = normalize_version(args.version)
|
||||
assets = collect_assets(args.root, version)
|
||||
info(f"仓库:{args.repo}")
|
||||
info(f"版本:{version}")
|
||||
for asset in assets:
|
||||
info(f"附件:{asset.relative_to(args.root)}")
|
||||
if args.dry_run:
|
||||
info("dry-run 完成,未访问 Gitee API")
|
||||
return 0
|
||||
|
||||
uploader = GiteeRelease(load_token(), args.repo)
|
||||
release = uploader.upsert(version, release_body(version, args.repo, assets))
|
||||
details = uploader.details(int(release["id"]))
|
||||
existing = {item["name"]: int(item.get("size", -1)) for item in details.get("assets", [])}
|
||||
for asset in assets:
|
||||
uploader.upload(int(release["id"]), asset, existing)
|
||||
verified = uploader.verify(int(release["id"]), assets)
|
||||
info(f"发布完成:https://gitee.com/{args.repo}/releases/tag/{version}")
|
||||
info(f"附件核验通过:{len(assets)} 个")
|
||||
if verified.get("tag_name") != version:
|
||||
fail("Release tag 核验失败")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except (RuntimeError, requests.RequestException, subprocess.SubprocessError) as exc:
|
||||
print(f"[release] ERROR: {exc}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
@@ -0,0 +1,31 @@
|
||||
@echo off
|
||||
setlocal EnableExtensions
|
||||
chcp 65001 >nul
|
||||
|
||||
rem OpsXShell local GUI launcher (Windows).
|
||||
rem App log path matches src-tauri infrastructure/file_log.rs (dirs::data_local_dir).
|
||||
|
||||
cd /d "%~dp0.."
|
||||
|
||||
set "PID_FILE=%TEMP%\opsxshell.pid"
|
||||
set "APP_LOG=%LOCALAPPDATA%\opsxshell\logs\opsxshell.log"
|
||||
|
||||
echo.
|
||||
echo [opsxshell] starting...
|
||||
|
||||
if not exist "node_modules\" (
|
||||
echo [opsxshell] npm install...
|
||||
call npm install
|
||||
if errorlevel 1 exit /b 1
|
||||
)
|
||||
|
||||
echo [opsxshell] npm run dev
|
||||
echo [opsxshell] app log: %APP_LOG% ^(max 10MB x 3 files^)
|
||||
start /b "" cmd /c "npm run dev"
|
||||
|
||||
echo %DATE% %TIME% > "%PID_FILE%"
|
||||
|
||||
echo [opsxshell] started. Tail app log with:
|
||||
echo powershell -Command "Get-Content -Path '%APP_LOG%' -Wait -Tail 50"
|
||||
echo.
|
||||
exit /b 0
|
||||
Executable
+53
@@ -0,0 +1,53 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
# 颜色定义
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m'
|
||||
|
||||
PID_FILE="/tmp/opsxshell.pid"
|
||||
|
||||
# Same path as src-tauri infrastructure/file_log.rs (dirs::data_local_dir).
|
||||
if [ "$(uname -s)" = "Darwin" ]; then
|
||||
APP_LOG="$HOME/Library/Application Support/opsxshell/logs/opsxshell.log"
|
||||
else
|
||||
APP_LOG="${XDG_DATA_HOME:-$HOME/.local/share}/opsxshell/logs/opsxshell.log"
|
||||
fi
|
||||
|
||||
echo -e "${CYAN}🦐 启动 OpsXShell...${NC}"
|
||||
|
||||
# 1. 清理旧进程
|
||||
echo -e "${YELLOW}🧹 清理旧进程...${NC}"
|
||||
pkill -f "vite" 2>/dev/null || true
|
||||
pkill -f "opsxshell" 2>/dev/null || true
|
||||
sleep 1
|
||||
|
||||
# 2. 检查依赖
|
||||
if [ ! -d "node_modules" ]; then
|
||||
echo -e "${YELLOW}📦 安装依赖...${NC}"
|
||||
npm install
|
||||
fi
|
||||
|
||||
# 3. 启动(原生 tracing 写入应用日志目录;Vite 输出留在本终端)
|
||||
echo -e "${GREEN}⚡ 启动 OpsXShell...${NC}"
|
||||
npm run dev &
|
||||
VITE_PID=$!
|
||||
echo $VITE_PID >"$PID_FILE"
|
||||
|
||||
# 4. 等待启动
|
||||
sleep 3
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}🎉 OpsXShell 已启动!${NC}"
|
||||
echo -e "${CYAN}📄 应用日志: tail -f \"${APP_LOG}\"${NC}"
|
||||
echo -e "${CYAN}📦 轮转: 单文件 ≤10MB,最多 opsxshell.log + .1 + .2(正式版同路径)${NC}"
|
||||
echo -e "${CYAN}🔍 DevTools: 菜单「视图 → 开发者工具」,或 Cmd+Option+I / F12(前端 console)${NC}"
|
||||
|
||||
# 等待子进程
|
||||
wait $VITE_PID 2>/dev/null || true
|
||||
Reference in new issue
Block a user