DSH-Arch-Setup/install.sh
Шурупов Илья Викторович cf9443753e Add one-command Arch DSH setup
Package the portable DSH profiles, local plugins, desktop integration, Firefox wrapper, and ownership-aware installer lifecycle. Bundle checksum-pinned OpenVSCode, editor extensions, translation assets, and cloudflared so unreliable upstream artifact downloads cannot break a clean installation.
2026-09-12 19:13:36 +03:00

259 lines
12 KiB
Bash
Executable file

#!/usr/bin/env bash
set -euo pipefail
repository_directory=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
dsh_home="$HOME/.dsh"
profile_directory="$dsh_home/profiles/web"
state_directory=${XDG_STATE_HOME:-"$HOME/.local/state"}/dsh-arch-setup
managed_manifest="$state_directory/managed.tsv"
partial_manifest="$state_directory/partial.tsv"
backup_manifest="$state_directory/backups.tsv"
new_manifest=$(mktemp)
cleanup() {
local status=$?
trap - EXIT
if [[ "$status" != 0 && -s "$new_manifest" ]]; then
mkdir -p "$state_directory"
cat "$new_manifest" >> "$partial_manifest"
fi
rm -f "$new_manifest"
exit "$status"
}
trap cleanup EXIT
skip_system_packages=${DSH_SETUP_SKIP_SYSTEM_PACKAGES:-0}
skip_network=${DSH_SETUP_SKIP_NETWORK:-0}
start_service=${DSH_SETUP_START_SERVICE:-1}
skip_host_integration=${DSH_SETUP_SKIP_HOST_INTEGRATION:-0}
for argument in "$@"; do
case "$argument" in
--skip-system-packages) skip_system_packages=1 ;;
--skip-network) skip_network=1 ;;
--no-start) start_service=0 ;;
--test-mode) skip_system_packages=1; skip_network=1; start_service=0; skip_host_integration=1 ;;
*) printf 'Unknown option: %s\n' "$argument" >&2; exit 2 ;;
esac
done
[[ "$EUID" != 0 ]] || { printf 'Run this installer as a regular user, not root.\n' >&2; exit 1; }
[[ "$(uname -s)" == Linux && "$(uname -m)" == x86_64 ]] || { printf 'This release supports Linux x86-64 only.\n' >&2; exit 1; }
if [[ "$skip_system_packages" != 1 ]]; then
[[ -f /etc/arch-release ]] || { printf 'This installer supports Arch Linux only.\n' >&2; exit 1; }
fi
[[ -f "$repository_directory/profile/web/pnpm-lock.yaml" ]] || { printf 'Missing Web profile lockfile.\n' >&2; exit 1; }
if [[ -x "$HOME/.local/bin/dsh" ]]; then
installed_dsh_version=$("$HOME/.local/bin/dsh" --version)
[[ "$installed_dsh_version" == 0.1.1-rc.2 ]] || { printf 'Existing ~/.local DSH version %s conflicts with required 0.1.1-rc.2.\n' "$installed_dsh_version" >&2; exit 1; }
fi
while IFS= read -r plugin; do
[[ -n "$plugin" ]] || continue
[[ -f "$repository_directory/plugins/$plugin/package.json" ]] || { printf 'Missing local plugin: %s\n' "$plugin" >&2; exit 1; }
done < "$repository_directory/manifests/local-plugins.txt"
while IFS= read -r plugin; do
[[ -n "$plugin" ]] || continue
[[ -f "$repository_directory/vendor/$plugin/package.json" ]] || { printf 'Missing vendored plugin: %s\n' "$plugin" >&2; exit 1; }
done < "$repository_directory/manifests/vendored-plugins.txt"
while IFS=$'\t' read -r extension relative expected_hash; do
[[ -n "$extension" ]] || continue
archive="$repository_directory/$relative"
[[ -f "$archive" ]] || { printf 'Missing editor extension archive: %s\n' "$relative" >&2; exit 1; }
actual_hash=$(sha256sum "$archive" | cut -d ' ' -f 1)
[[ "$actual_hash" == "$expected_hash" ]] || { printf 'Editor extension checksum mismatch: %s\n' "$relative" >&2; exit 1; }
done < "$repository_directory/manifests/vscode-extension-artifacts.tsv"
IFS=$'\t' read -r editor_version editor_archive_relative editor_archive_hash < "$repository_directory/manifests/editor-runtime.tsv"
editor_archive="$repository_directory/$editor_archive_relative"
[[ -f "$editor_archive" ]] || { printf 'Missing OpenVSCode runtime archive: %s\n' "$editor_archive_relative" >&2; exit 1; }
actual_editor_hash=$(sha256sum "$editor_archive" | cut -d ' ' -f 1)
[[ "$actual_editor_hash" == "$editor_archive_hash" ]] || { printf 'OpenVSCode runtime checksum mismatch: %s\n' "$editor_archive_relative" >&2; exit 1; }
IFS=$'\t' read -r cloudflared_version cloudflared_relative cloudflared_hash < "$repository_directory/manifests/cloudflared.tsv"
cloudflared_source="$repository_directory/$cloudflared_relative"
[[ -x "$cloudflared_source" ]] || { printf 'Missing cloudflared binary: %s\n' "$cloudflared_relative" >&2; exit 1; }
actual_cloudflared_hash=$(sha256sum "$cloudflared_source" | cut -d ' ' -f 1)
[[ "$actual_cloudflared_hash" == "$cloudflared_hash" ]] || { printf 'cloudflared checksum mismatch: %s\n' "$cloudflared_relative" >&2; exit 1; }
actual_cloudflared_version=$("$cloudflared_source" --version | awk '{print $3}')
[[ "$actual_cloudflared_version" == "$cloudflared_version" ]] || { printf 'cloudflared version mismatch: %s\n' "$actual_cloudflared_version" >&2; exit 1; }
existing_editor="$repository_directory/plugins/dsh-touched-git/runtime/current/bin/openvscode-server"
if [[ -x "$existing_editor" ]]; then
existing_editor_version=$("$existing_editor" --version | head -n 1)
[[ "$existing_editor_version" == "$editor_version" ]] || { printf 'Existing OpenVSCode runtime version %s conflicts with required %s.\n' "$existing_editor_version" "$editor_version" >&2; exit 1; }
elif [[ -e "$repository_directory/plugins/dsh-touched-git/runtime/current" ]]; then
printf 'Existing OpenVSCode runtime is incomplete.\n' >&2
exit 1
fi
mkdir -p "$state_directory"
touch "$backup_manifest"
if [[ ! -f "$managed_manifest" && ! -f "$partial_manifest" ]]; then
[[ -x "$HOME/.local/bin/dsh" ]] && touch "$state_directory/dsh-preexisting"
[[ -d "$profile_directory" ]] && touch "$state_directory/profile-preexisting"
[[ -d "$dsh_home/touched-git/editor" ]] && touch "$state_directory/editor-preexisting"
[[ -d "$repository_directory/plugins/dsh-touched-git/runtime/current" ]] && touch "$state_directory/runtime-preexisting"
[[ -d "$repository_directory/plugins/dsh-touched-git/node_modules" ]] && touch "$state_directory/plugin-dependencies-preexisting"
fi
is_managed_unchanged() {
local destination=$1
local record
record=$(
for manifest in "$managed_manifest" "$partial_manifest"; do
[[ ! -f "$manifest" ]] || cat "$manifest"
done | awk -F '\t' -v candidate="$destination" '$2 == candidate { record = $0 } END { print record }'
)
[[ -n "$record" ]] || return 1
local kind ignored expected
IFS=$'\t' read -r kind ignored expected <<< "$record"
case "$kind" in
file) [[ -f "$destination" && "$(sha256sum "$destination" | cut -d ' ' -f 1)" == "$expected" ]] ;;
link) [[ -L "$destination" && "$(readlink -f "$destination")" == "$(readlink -f "$expected")" ]] ;;
*) return 1 ;;
esac
}
backup_unmanaged() {
local destination=$1
if [[ ! -e "$destination" && ! -L "$destination" ]]; then
return
fi
if is_managed_unchanged "$destination"; then
return
fi
local relative=${destination#/}
local backup="$state_directory/backups/$relative"
if [[ -e "$backup" || -L "$backup" ]]; then
backup="$backup.$(date +%s%N)"
fi
mkdir -p "$(dirname "$backup")"
mv "$destination" "$backup"
printf '%s\t%s\n' "$destination" "$backup" >> "$backup_manifest"
}
record_file() {
local destination=$1
printf 'file\t%s\t%s\n' "$destination" "$(sha256sum "$destination" | cut -d ' ' -f 1)" >> "$new_manifest"
}
install_managed_file() {
local source=$1
local destination=$2
local mode=$3
backup_unmanaged "$destination"
install -Dm"$mode" "$source" "$destination"
record_file "$destination"
}
install_managed_link() {
local source=$1
local destination=$2
if [[ -L "$destination" && "$(readlink -f "$destination")" == "$(readlink -f "$source")" ]]; then
printf 'link\t%s\t%s\n' "$destination" "$source" >> "$new_manifest"
return
fi
backup_unmanaged "$destination"
mkdir -p "$(dirname "$destination")"
rm -rf "$destination"
ln -s "$source" "$destination"
printf 'link\t%s\t%s\n' "$destination" "$source" >> "$new_manifest"
}
if [[ "$skip_system_packages" != 1 ]]; then
packages=(nodejs npm pnpm git curl tar firefox desktop-file-utils hicolor-icon-theme iproute2 python clang cmake ninja)
missing=()
for package in "${packages[@]}"; do
pacman -Q "$package" >/dev/null 2>&1 || missing+=("$package")
done
if ((${#missing[@]})); then
sudo pacman -S --needed --noconfirm "${missing[@]}"
fi
fi
mkdir -p "$dsh_home/plugins" "$profile_directory" "$dsh_home/profiles/headless"
while IFS= read -r plugin; do
[[ -n "$plugin" ]] || continue
install_managed_link "$repository_directory/plugins/$plugin" "$dsh_home/plugins/$plugin"
done < "$repository_directory/manifests/local-plugins.txt"
while IFS= read -r plugin; do
[[ -n "$plugin" ]] || continue
install_managed_link "$repository_directory/vendor/$plugin" "$dsh_home/vendor/$plugin"
done < "$repository_directory/manifests/vendored-plugins.txt"
profile_files=(package.json pnpm-workspace.yaml pnpm-lock.yaml cordis.yml cordis.patch.yml patches/dsh-codex-subscription@1.10.0.patch)
for relative in "${profile_files[@]}"; do
install_managed_file "$repository_directory/profile/web/$relative" "$profile_directory/$relative" 0644
done
headless_files=(package.json pnpm-workspace.yaml cordis.yml cordis.patch.yml)
for relative in "${headless_files[@]}"; do
install_managed_file "$repository_directory/profile/headless/$relative" "$dsh_home/profiles/headless/$relative" 0644
done
install_managed_file "$repository_directory/bin/dsh-app" "$HOME/.local/bin/dsh-app" 0755
install_managed_file "$repository_directory/bin/free-port-3080.sh" "$dsh_home/free-port-3080.sh" 0755
install_managed_file "$repository_directory/systemd/dsh-web.service" "$HOME/.config/systemd/user/dsh-web.service" 0644
install_managed_file "$repository_directory/firefox/user.js" "$HOME/.local/share/dsh-app-firefox/user.js" 0644
install_managed_file "$repository_directory/firefox/userChrome.css" "$HOME/.local/share/dsh-app-firefox/chrome/userChrome.css" 0644
desktop_temporary=$(mktemp)
sed "s|@HOME@|$HOME|g" "$repository_directory/desktop/dsh-app.desktop.in" > "$desktop_temporary"
install_managed_file "$desktop_temporary" "$HOME/.local/share/applications/dsh-app.desktop" 0644
rm -f "$desktop_temporary"
while IFS= read -r icon; do
relative=${icon#"$repository_directory/assets/icons/"}
install_managed_file "$icon" "$HOME/.local/share/icons/hicolor/$relative" 0644
done < <(find "$repository_directory/assets/icons" -type f -name '*.png' | sort)
if [[ "$skip_network" != 1 ]]; then
if [[ ! -x "$HOME/.local/bin/dsh" ]]; then
npm install --global --prefix "$HOME/.local" --allow-scripts='@deepseek-ai/dsh-subprocess-local,koffi,node-pty,@google/genai,protobufjs' '@deepseek-ai/dsh@0.1.1-rc.2'
fi
touched_git_directory="$repository_directory/plugins/dsh-touched-git"
pnpm --dir "$touched_git_directory" install --frozen-lockfile
pnpm --dir "$profile_directory" install --frozen-lockfile
[[ -f "$profile_directory/node_modules/cloudflared/package.json" ]] || { printf 'cloudflared npm wrapper is missing after profile installation.\n' >&2; exit 1; }
install_managed_file "$cloudflared_source" "$profile_directory/node_modules/cloudflared/bin/cloudflared" 0755
runtime_directory="$touched_git_directory/runtime"
editor_binary="$runtime_directory/current/bin/openvscode-server"
if [[ ! -x "$editor_binary" ]]; then
mkdir -p "$runtime_directory"
runtime_staging=$(mktemp -d "$runtime_directory/.install-XXXXXX")
if ! tar -xzf "$editor_archive" -C "$runtime_staging"; then
rm -rf "$runtime_staging"
printf 'Failed to extract bundled OpenVSCode runtime.\n' >&2
exit 1
fi
[[ -x "$runtime_staging/current/bin/openvscode-server" ]] || { rm -rf "$runtime_staging"; printf 'Bundled OpenVSCode runtime is incomplete.\n' >&2; exit 1; }
mv "$runtime_staging/current" "$runtime_directory/current"
rmdir "$runtime_staging"
fi
printf 'Installed bundled OpenVSCode Server %s. No server started.\n' "$editor_version"
while IFS=$'\t' read -r extension relative expected_hash; do
[[ -n "$extension" ]] || continue
archive="$repository_directory/$relative"
timeout --signal=TERM 180 "$editor_binary" --extensions-dir "$dsh_home/touched-git/editor/extensions" --install-extension "$archive" --force
done < "$repository_directory/manifests/vscode-extension-artifacts.tsv"
DSH_PROFILE_DIRECTORY="$profile_directory" \
DSH_EDITOR_EXTENSIONS_DIRECTORY="$dsh_home/touched-git/editor/extensions" \
node "$repository_directory/scripts/apply-compatibility-patches.mjs"
fi
mv "$new_manifest" "$managed_manifest"
rm -f "$partial_manifest"
printf '%s\n' "$repository_directory" > "$state_directory/repository"
if [[ "$skip_host_integration" != 1 ]]; then
command -v update-desktop-database >/dev/null 2>&1 && update-desktop-database "$HOME/.local/share/applications"
command -v gtk-update-icon-cache >/dev/null 2>&1 && gtk-update-icon-cache --force "$HOME/.local/share/icons/hicolor" >/dev/null 2>&1 || true
systemctl --user daemon-reload
if [[ "$start_service" == 1 ]]; then
systemctl --user enable dsh-web.service
systemctl --user restart dsh-web.service
fi
fi
printf 'DSH installation complete. Open DSH from the application menu or run dsh-app.\n'
printf 'Configure provider credentials on first launch; no credentials are included here.\n'