#!/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'