diff --git a/.chezmoiroot b/.chezmoiroot new file mode 100644 index 0000000..5e72f75 --- /dev/null +++ b/.chezmoiroot @@ -0,0 +1 @@ +home diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..4b887bf --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +* text=auto eol=lf +*.cmd text eol=crlf diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..8ae22ea --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,157 @@ +name: Validate dotfiles + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: validate-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + static: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Install validation tools + shell: bash + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install --yes --no-install-recommends shellcheck + mkdir -p "$RUNNER_TEMP/bin" + curl -fsLS https://get.chezmoi.io | sh -s -- -b "$RUNNER_TEMP/bin" + + actionlint_version=1.7.12 + actionlint_archive="$RUNNER_TEMP/actionlint.tar.gz" + curl -fsSL "https://github.com/rhysd/actionlint/releases/download/v${actionlint_version}/actionlint_${actionlint_version}_linux_amd64.tar.gz" -o "$actionlint_archive" + echo "8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8 $actionlint_archive" | sha256sum --check + tar -xzf "$actionlint_archive" -C "$RUNNER_TEMP/bin" actionlint + echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH" + + - name: Validate workflow + run: actionlint .github/workflows/validate.yml + + - name: Validate shell source + run: bash tests/static/validate-source.sh shell + + - name: Validate chezmoi source + run: bash tests/static/validate-source.sh chezmoi + + - name: Validate source architecture + run: python3 tests/static/validate-architecture.py + + - name: Validate manifests + run: python3 tests/static/validate-manifests.py + + - name: Validate encoded PowerShell bridge + run: python3 tests/static/check-powershell-bridge.py + + - name: Validate PowerShell syntax + shell: pwsh + run: ./tests/static/validate-powershell.ps1 + + linux: + name: linux-${{ matrix.distro }} + runs-on: ubuntu-24.04 + timeout-minutes: 35 + strategy: + fail-fast: false + matrix: + include: + - distro: debian + image: debian:bookworm-slim + - distro: arch + image: archlinux:base + container: + image: ${{ matrix.image }} + steps: + - name: Prepare container + shell: sh + run: | + set -eu + case "${{ matrix.distro }}" in + debian) apt-get update; apt-get install --yes --no-install-recommends ca-certificates curl git sudo bash ;; + arch) pacman -Sy --noconfirm ca-certificates curl git sudo bash ;; + esac + + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Exercise bootstrap and update + run: bash tests/linux/run-ci.sh "${{ matrix.distro }}" "$GITHUB_SHA" + + windows: + runs-on: windows-2025 + timeout-minutes: 45 + env: + DOTFILES_NONINTERACTIVE: "1" + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + persist-credentials: false + - shell: pwsh + run: ./tests/windows/prepare-ci.ps1 + - name: Run bootstrap + shell: cmd + run: | + mkdir "%RUNNER_TEMP%\dotfiles-run" 2>nul + pushd "%RUNNER_TEMP%\dotfiles-run" + call "%GITHUB_WORKSPACE%\bootstrap.cmd" + set "E=%ERRORLEVEL%" + popd + exit /b %E% + - shell: pwsh + run: ./tests/windows/assert-state.ps1 -RepoRoot $env:GITHUB_WORKSPACE -ExpectedCommit $env:GITHUB_SHA + - shell: cmd + run: call "%GITHUB_WORKSPACE%\tests\windows\create-update-fixture.cmd" + - shell: cmd + run: call "%GITHUB_WORKSPACE%\update.cmd" + - shell: pwsh + run: ./tests/windows/assert-state.ps1 -RepoRoot $env:GITHUB_WORKSPACE -UpdateMarker $env:UPDATE_MARKER + + windows-allsigned: + runs-on: windows-2025 + timeout-minutes: 50 + env: + DOTFILES_NONINTERACTIVE: "1" + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + persist-credentials: false + - shell: pwsh + run: ./tests/windows/prepare-ci.ps1 + - shell: pwsh + run: ./tests/windows/enable-allsigned.ps1 + - shell: cmd + run: pwsh.exe -NoProfile -File "%RUNNER_TEMP%\allsigned-bridge-smoke.ps1" -Value bridge-ok + - name: Run bootstrap under AllSigned + shell: cmd + run: | + mkdir "%RUNNER_TEMP%\dotfiles-run" 2>nul + pushd "%RUNNER_TEMP%\dotfiles-run" + call "%GITHUB_WORKSPACE%\bootstrap.cmd" + set "E=%ERRORLEVEL%" + popd + exit /b %E% + - shell: cmd + run: call "%GITHUB_WORKSPACE%\scripts\windows\invoke-ps-script.cmd" "%GITHUB_WORKSPACE%\tests\windows\assert-allsigned.ps1" -RepoRoot "%GITHUB_WORKSPACE%" -ThumbprintFile "%RUNNER_TEMP%\dotfiles-cert-thumbprint.txt" + - shell: cmd + run: call "%GITHUB_WORKSPACE%\tests\windows\create-update-fixture.cmd" + - shell: cmd + run: call "%GITHUB_WORKSPACE%\update.cmd" + - shell: cmd + run: call "%GITHUB_WORKSPACE%\scripts\windows\invoke-ps-script.cmd" "%GITHUB_WORKSPACE%\tests\windows\assert-allsigned.ps1" -RepoRoot "%GITHUB_WORKSPACE%" -ThumbprintFile "%RUNNER_TEMP%\dotfiles-cert-thumbprint.txt" -UpdateMarker "%UPDATE_MARKER%" diff --git a/.gitignore b/.gitignore index 0903fae..4c61613 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,10 @@ .vscode # Auto-generated files -config/zsh/.zcompcache/* -config/zsh/.zcompdump* -config/zsh/.zhistory -config/zsh/*/*.zwc -config/zsh/.zsh_sessions/* +home/dot_config/zsh/.zcompcache/* +home/dot_config/zsh/.zcompdump* +home/dot_config/zsh/.zhistory +home/dot_config/zsh/*/*.zwc +home/dot_config/zsh/.zsh_sessions/* -fonts/ \ No newline at end of file +fonts/ diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index 1e300ff..0000000 --- a/.gitmodules +++ /dev/null @@ -1,4 +0,0 @@ -[submodule "dotbot"] - path = lib/dotbot - url = https://github.com/anishathalye/dotbot - ignore = dirty diff --git a/README.md b/README.md index 3683581..3e1d646 100644 --- a/README.md +++ b/README.md @@ -1,49 +1,56 @@ # Dotfiles -Welcome to my dotfiles! This repository contains configurations and scripts to quickly set up a development environment on Windows and Linux. +This repository uses [chezmoi](https://www.chezmoi.io/) to deploy regular dotfiles on Windows and Linux. Repository support files stay outside the `home/` source state. -## Quick Installation +## Quick installation -### 🖥️ Windows +### Windows -To set up your environment on Windows, run the following command in PowerShell: +Run this from a normal `cmd.exe` prompt: -```powershell -Invoke-WebRequest -Uri "https://raw.githubusercontent.com/jsilverdev/dotfiles/main/lets-go.ps1" -OutFile ".\lets-go.ps1"; Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned; .\lets-go.ps1 +```cmd +curl.exe -fsSLo "%TEMP%\dotfiles-bootstrap.cmd" https://raw.githubusercontent.com/jsilverdev/dotfiles/main/bootstrap.cmd && call "%TEMP%\dotfiles-bootstrap.cmd" ``` -To update installed applications and PowerShell modules: +The Windows bootstrap requires WinGet. If App Installer exists but WinGet is not registered for the current user, it attempts current-user App Installer registration. If corporate policy disables WinGet, it stops with an error. Bootstrap installs only Git, PowerShell 7, and chezmoi when they are missing; the platform installer owns the remaining application catalog. -```powershell -.\install.ps1 -Update # or -u -``` +Baseline CLI packages are declared in `scripts/windows/managed-apps.csv` and are installed with WinGet in user scope. An interactive run also installs workstation applications, fonts, Windows Terminal customization, and offers optional applications. `DOTFILES_NONINTERACTIVE=1` installs the complete baseline but skips those interactive/workstation customizations. + +The bootstrap does not require administrator rights or Developer Mode. It invokes PowerShell 7 with `-NoProfile`; an effective `AllSigned` policy is supported automatically. Each machine reuses or creates its own current-user Code Signing certificate and trusts its public certificate locally. No private key, PFX, or KeePass dependency is used. -### 🐧 Linux +### Linux -To set up your environment on Linux, use this command: +On Debian or Arch Linux, run: ```bash -bash <(curl -s https://raw.githubusercontent.com/jsilverdev/dotfiles/main/lets-go.sh) +bash <(curl -fsSL https://raw.githubusercontent.com/jsilverdev/dotfiles/main/bootstrap.sh) ``` -To refresh existing packages, pass `--update` (or `-u`): +The bootstrap installs only the prerequisites required to obtain/apply the repository, installs chezmoi in `~/.local/bin` when needed, and then runs the package installer. Linux package catalogs are declared under `scripts/linux/`. Non-interactive mode still installs and validates the complete baseline; it only skips shell changes, WSL system configuration, and optional-package prompts. Arch continues to install and manage `yay`. -```bash -./install.sh --update +## Updates + +For dotfiles-only updates, use: + +```text +chezmoi update ``` -## Requirements +For dotfiles plus installer-managed package/application/module updates, use `update.cmd` on Windows or `./update.sh` on Linux. Those wrappers run `chezmoi update` first and then rerun the platform installer in update mode. Starship and managed PowerShell modules participate in update mode as well. + +## State details + +- `home/.chezmoiroot` is represented by the repository-level `.chezmoiroot`, pointing to `home`. +- Windows-only `.wslconfig` and Linux-only Zsh/Sheldon state are filtered by `home/.chezmoiignore`. +- `~/.gitconfig.local`, `~/.codex/config.toml`, and `~/.codex/rules/default.rules` use chezmoi create-only semantics and are not overwritten after creation. +- Codex guidance and skills are managed normally; repository documentation is kept outside `~/.codex`. +- PowerShell source files are unsigned templates. The post-apply hook deploys copies and signs only the runtime files when `AllSigned` is effective, so Authenticode signatures never dirty chezmoi source state. +- Managed PowerShell modules are declared in `scripts/windows/managed-modules.txt`. Under `AllSigned`, managed PowerShell files are re-signed with the locally trusted dotfiles certificate unless they already carry a valid signature from that same certificate. This deliberately normalizes the publisher used by the non-interactive AllSigned path. -### 🖥️ Windows -- PowerShell -- [Dev Mode](https://learn.microsoft.com/en-us/windows/apps/get-started/enable-your-device-for-development) enabled +## CI and testing -### 🐧 Linux -- Debian / Arch Linux -- curl +The `Validate dotfiles` workflow exercises Debian, Arch Linux, Windows with its normal execution policy, and Windows with a simulated current-user `AllSigned` policy. GitHub Actions only orchestrates the scenarios; reusable fixture and assertion logic lives under `tests/`. -## Acknowledgments +Integration jobs bootstrap from a temporary local bare Git remote containing the exact commit under test. They run the real non-interactive baseline, validate installed CLI tools, exercise create-only Codex files and update wrappers, check idempotent chezmoi apply, and verify clean source/checkout state. Static validation includes ShellCheck, PowerShell parsing, manifest checks, chezmoi template evaluation, bridge-payload synchronization, and pinned `actionlint` validation of the workflow. -This dotfiles repository is inspired by: -- [Lissy93's dotfiles](https://github.com/Lissy93/dotfiles) -- [KEVINNITRO DOTFILES](https://github.com/KevinNitroG/dotfiles). +The Windows AllSigned job validates current-user certificate creation and reuse, Authenticode signing, the CMD execution bridge, PowerShell profile startup, and managed module loading. When PowerShell 7 requires signed scripts, the bridge uses inbox Windows PowerShell only for Authenticode signing and executes the resulting signed script with PowerShell 7 under the effective policy. The hosted runner is an administrator with UAC disabled, so CI trusts the test certificate through `LocalMachine\Root` plus `CurrentUser\TrustedPublisher`; runtime helpers also accept `CurrentUser\Root` for the real non-admin path. Corporate GPO/MDM/AppLocker/WDAC policy, enterprise App Installer policy, and a true non-admin corporate Windows 11 token still require validation on a managed machine. diff --git a/config/win-terminal/config.json b/assets/win-terminal/config.json similarity index 100% rename from config/win-terminal/config.json rename to assets/win-terminal/config.json diff --git a/config/wsl/wsl.conf b/assets/wsl/wsl.conf similarity index 100% rename from config/wsl/wsl.conf rename to assets/wsl/wsl.conf diff --git a/bootstrap.cmd b/bootstrap.cmd new file mode 100644 index 0000000..88a2cda --- /dev/null +++ b/bootstrap.cmd @@ -0,0 +1,114 @@ +@echo off +setlocal EnableExtensions EnableDelayedExpansion + +if defined DOTFILES_REPO ( + set "REPO_URL=%DOTFILES_REPO%" +) else ( + set "REPO_URL=https://github.com/jsilverdev/dotfiles.git" +) +where winget.exe >nul 2>&1 +if errorlevel 1 ( + echo WinGet is not registered for this user. Attempting App Installer registration... + "%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -Command "Add-AppxPackage -RegisterByFamilyName -MainPackage Microsoft.DesktopAppInstaller_8wekyb3d8bbwe" + if errorlevel 1 ( + echo App Installer registration failed. WinGet may be disabled by corporate policy. 1>&2 + goto bootstrap_failed + ) +) +where winget.exe >nul 2>&1 +if errorlevel 1 ( + echo WinGet is unavailable after App Installer registration. Check corporate policy or App Installer registration. 1>&2 + goto bootstrap_failed +) +call :ensure_package Git.Git git +if errorlevel 1 goto bootstrap_failed +call :ensure_package Microsoft.PowerShell pwsh +if errorlevel 1 goto bootstrap_failed +call :ensure_package twpayne.chezmoi chezmoi +if errorlevel 1 goto bootstrap_failed +call :refresh_path +where git.exe >nul 2>&1 +if errorlevel 1 ( + echo Git is still unavailable after installation. 1>&2 + goto bootstrap_failed +) +where pwsh.exe >nul 2>&1 +if errorlevel 1 ( + echo PowerShell 7 is still unavailable after installation. 1>&2 + goto bootstrap_failed +) +where chezmoi.exe >nul 2>&1 +if errorlevel 1 ( + echo chezmoi is still unavailable after installation. 1>&2 + goto bootstrap_failed +) +call :initialize_chezmoi +if errorlevel 1 ( + echo chezmoi initialization/update failed. 1>&2 + goto bootstrap_failed +) +call :resolve_repo_root +if not defined REPO_ROOT ( + echo Unable to resolve the chezmoi working tree. 1>&2 + goto bootstrap_failed +) +if not exist "%REPO_ROOT%\scripts\windows\invoke-ps-script.cmd" ( + echo Resolved chezmoi working tree does not contain the dotfiles scripts: "%REPO_ROOT%" 1>&2 + goto bootstrap_failed +) +if /I "%DOTFILES_NONINTERACTIVE%"=="1" ( + call "%REPO_ROOT%\scripts\windows\invoke-ps-script.cmd" "%REPO_ROOT%\install.ps1" -NonInteractive -RepoRoot "%REPO_ROOT%" +) else ( + call "%REPO_ROOT%\scripts\windows\invoke-ps-script.cmd" "%REPO_ROOT%\install.ps1" -RepoRoot "%REPO_ROOT%" +) +if errorlevel 1 goto bootstrap_failed +exit /b 0 + +:bootstrap_failed +exit /b 1 + +:ensure_package +set "PACKAGE_ID=%~1" +set "PACKAGE_COMMAND=%~2" +where "%PACKAGE_COMMAND%.exe" >nul 2>&1 +if not errorlevel 1 exit /b 0 +echo Installing %PACKAGE_ID% for the current user... +winget.exe install --id "%PACKAGE_ID%" --exact --source winget --scope user --silent --disable-interactivity --accept-source-agreements --accept-package-agreements +if errorlevel 1 ( + echo Unable to install %PACKAGE_ID% without administrator rights. No machine-scope or portable fallback will be attempted. 1>&2 + exit /b 1 +) +exit /b 0 + +:refresh_path +set "PATH=%PATH%;%LOCALAPPDATA%\Microsoft\WinGet\Links;%LOCALAPPDATA%\Programs\Microsoft.PowerShell;%LOCALAPPDATA%\Programs\mise;%LOCALAPPDATA%\Programs\chezmoi" +for /f "tokens=2,*" %%A in ('reg query HKCU\Environment /v Path 2^>nul ^| findstr /i "Path"') do set "PATH=!PATH!;%%B" +exit /b 0 + +:resolve_repo_root +set "REPO_ROOT=" +if exist "%CD%\.chezmoiroot" if exist "%CD%\install.ps1" (set "REPO_ROOT=%CD%"& exit /b 0) +for /f "delims=" %%R in ('chezmoi.exe execute-template "{{ .chezmoi.workingTree }}" 2^>nul') do set "REPO_ROOT=%%R" +if defined REPO_ROOT if exist "%REPO_ROOT%\install.ps1" exit /b 0 +for /f "delims=" %%R in ('chezmoi.exe source-path 2^>nul') do set "SOURCE_ROOT=%%R" +if defined SOURCE_ROOT if exist "%SOURCE_ROOT%\install.ps1" (set "REPO_ROOT=%SOURCE_ROOT%"& exit /b 0) +if defined SOURCE_ROOT for %%P in ("%SOURCE_ROOT%\..") do if exist "%%~fP\install.ps1" (set "REPO_ROOT=%%~fP"& exit /b 0) +exit /b 1 + +:initialize_chezmoi +if exist "%CD%\.chezmoiroot" ( + chezmoi.exe --source "%CD%" apply + exit /b %ERRORLEVEL% +) +set "SOURCE_ROOT=" +for /f "delims=" %%R in ('chezmoi.exe source-path 2^>nul') do set "SOURCE_ROOT=%%R" +if defined SOURCE_ROOT if exist "%SOURCE_ROOT%\.chezmoiroot" goto existing_chezmoi +if defined SOURCE_ROOT for %%P in ("%SOURCE_ROOT%\..") do if exist "%%~fP\.chezmoiroot" goto existing_chezmoi +chezmoi.exe init "%REPO_URL%" +if errorlevel 1 exit /b %ERRORLEVEL% +chezmoi.exe apply +exit /b %ERRORLEVEL% + +:existing_chezmoi +chezmoi.exe update +exit /b %ERRORLEVEL% diff --git a/bootstrap.sh b/bootstrap.sh new file mode 100644 index 0000000..c7e5df1 --- /dev/null +++ b/bootstrap.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_URL="${DOTFILES_REPO:-https://github.com/jsilverdev/dotfiles.git}" +export PATH="$HOME/.local/bin:$PATH" +DISTRO="" + +configure_local_chezmoi_source() { + local config_dir config_path + config_dir="${XDG_CONFIG_HOME:-$HOME/.config}/chezmoi" + config_path="$config_dir/chezmoi.toml" + if [[ ! -e "$config_path" ]]; then + mkdir -p "$config_dir" + printf 'sourceDir = "%s"\n' "$PWD" > "$config_path" + fi +} + +if [[ -f /etc/debian_version ]] && command -v apt-get >/dev/null 2>&1; then + DISTRO="debian" + missing=() + command -v git >/dev/null 2>&1 || missing+=(git) + command -v curl >/dev/null 2>&1 || missing+=(curl) + if (( ${#missing[@]} > 0 )); then + sudo apt-get update + sudo apt-get install --yes "${missing[@]}" + export DOTFILES_PACKAGE_INDEX_READY=1 + fi +elif [[ -f /etc/arch-release ]] && command -v pacman >/dev/null 2>&1; then + DISTRO="arch" + missing=() + command -v git >/dev/null 2>&1 || missing+=(git) + command -v curl >/dev/null 2>&1 || missing+=(curl) + command -v chezmoi >/dev/null 2>&1 || missing+=(chezmoi) + if (( ${#missing[@]} > 0 )); then + sudo pacman -Syu --noconfirm --needed "${missing[@]}" + export DOTFILES_PACKAGE_INDEX_READY=1 + fi +else + printf 'Unsupported Linux distribution. Debian and Arch Linux are supported.\n' >&2 + exit 1 +fi + +command -v git >/dev/null 2>&1 || { printf 'Git is required but unavailable.\n' >&2; exit 1; } +command -v curl >/dev/null 2>&1 || { printf 'curl is required but unavailable.\n' >&2; exit 1; } + +if [[ "$DISTRO" == "debian" ]] && ! command -v chezmoi >/dev/null 2>&1; then + mkdir -p "$HOME/.local/bin" + sh -c "$(curl -fsLS get.chezmoi.io)" -- -b "$HOME/.local/bin" +fi + +export PATH="$HOME/.local/bin:$PATH" +command -v chezmoi >/dev/null 2>&1 || { printf 'chezmoi installation failed.\n' >&2; exit 1; } + +if [[ -f "$PWD/.chezmoiroot" ]]; then + configure_local_chezmoi_source + chezmoi --source "$PWD" apply +else + SOURCE_ROOT="$(chezmoi source-path 2>/dev/null || true)" + if [[ -f "$SOURCE_ROOT/.chezmoiroot" || -f "$(dirname "$SOURCE_ROOT")/.chezmoiroot" ]]; then + chezmoi update + else + chezmoi init --apply "$REPO_URL" + fi +fi + +resolve_repo_root() { + local candidate parent + if [[ -f "$PWD/.chezmoiroot" && -f "$PWD/install.sh" ]]; then + printf '%s\n' "$PWD" + return 0 + fi + for candidate in "$(chezmoi execute-template '{{ .chezmoi.workingTree }}' 2>/dev/null || true)" "$(chezmoi source-path)"; do + [[ -n "$candidate" ]] || continue + if [[ -f "$candidate/install.sh" ]]; then + printf '%s\n' "$candidate" + return 0 + fi + parent="$(dirname "$candidate")" + if [[ -f "$parent/install.sh" ]]; then + printf '%s\n' "$parent" + return 0 + fi + done + return 1 +} + +REPO_ROOT="$(resolve_repo_root)" || { printf 'Unable to resolve the chezmoi working tree.\n' >&2; exit 1; } +exec "$REPO_ROOT/install.sh" diff --git a/config/pwsh/env.ps1 b/config/pwsh/env.ps1 deleted file mode 100644 index 39c6805..0000000 --- a/config/pwsh/env.ps1 +++ /dev/null @@ -1,13 +0,0 @@ - -$env:VISUAL = "code" -$env:PAGER = "delta" -$env:PYTHONIOENCODING = "utf-8" - -$PS_USER_FOLDER = "D:\$ENV:USERNAME" -if (Test-Path -Path $PS_USER_FOLDER) { - $ENV:STARSHIP_CACHE = "$PS_USER_FOLDER\Temp\starship" -} -else { - $PS_USER_FOLDER = "$HOME" -} -$ENV:STARSHIP_CONFIG = "$HOME\.config\starship\config.toml" \ No newline at end of file diff --git a/config/codex/README.md b/docs/codex.md similarity index 68% rename from config/codex/README.md rename to docs/codex.md index b977305..391fab3 100644 --- a/config/codex/README.md +++ b/docs/codex.md @@ -1,16 +1,16 @@ # Codex dotfiles -This directory contains only portable Codex defaults. Dotbot links stable -guidance and skills to `~/.codex`; mutable files are copied only when missing. +This directory contains only portable Codex defaults. Chezmoi deploys stable +guidance and skills to `~/.codex`; mutable files are created only when missing. ## Included -- `config.toml.example`: initial UI, sandbox, features, and default reasoning - effort. The installer copies it to `~/.codex/config.toml` only when missing. +- `create_config.toml`: initial UI, sandbox, features, and default reasoning + effort. Chezmoi creates it as `~/.codex/config.toml` only when missing. - `AGENTS.md`: personal working conventions that apply to every repository. -- `rules/default.rules.example`: initial narrowly scoped command approvals. +- `rules/create_default.rules`: initial narrowly scoped command approvals. - `skills/*`: the individual files of every skill placed in this directory are - linked to `~/.codex/skills`, including the bundled `mule-munit` workflow. + deployed to `~/.codex/skills`, including the bundled `mule-munit` workflow. ## Deliberately excluded diff --git a/home/.chezmoiignore b/home/.chezmoiignore new file mode 100644 index 0000000..059cb1c --- /dev/null +++ b/home/.chezmoiignore @@ -0,0 +1,11 @@ +{{- if eq .chezmoi.os "windows" }} +.zshenv +.config/zsh +.config/sheldon +{{- else }} +.config/pwsh +.wslconfig +.chezmoiscripts +.chezmoiscripts/run_after_90-deploy-pwsh.cmd.tmpl +.chezmoiscripts/90-deploy-pwsh.cmd +{{- end }} diff --git a/home/.chezmoiscripts/run_after_90-deploy-pwsh.cmd.tmpl b/home/.chezmoiscripts/run_after_90-deploy-pwsh.cmd.tmpl new file mode 100644 index 0000000..7bc95e3 --- /dev/null +++ b/home/.chezmoiscripts/run_after_90-deploy-pwsh.cmd.tmpl @@ -0,0 +1,19 @@ +{{- if eq .chezmoi.os "windows" }} +@echo off +setlocal EnableExtensions + +set "REPO_ROOT={{ .chezmoi.workingTree }}" +if not defined REPO_ROOT for /f "delims=" %%R in ('chezmoi execute-template "{{ .chezmoi.workingTree }}" 2^>nul') do set "REPO_ROOT=%%R" +if not defined REPO_ROOT set "REPO_ROOT={{ .chezmoi.sourceDir }}" +if not exist "%REPO_ROOT%\scripts\windows\invoke-ps-script.cmd" for /f "delims=" %%S in ('chezmoi source-path 2^>nul') do set "REPO_ROOT=%%S" +if not exist "%REPO_ROOT%\scripts\windows\invoke-ps-script.cmd" for %%P in ("%REPO_ROOT%\..") do set "REPO_ROOT=%%~fP" +for %%P in ("%REPO_ROOT%") do set "REPO_ROOT=%%~fP" + +if not exist "%REPO_ROOT%\scripts\windows\invoke-ps-script.cmd" ( + echo Unable to locate the dotfiles working tree: "%REPO_ROOT%" 1>&2 + exit /b 1 +) + +call "%REPO_ROOT%\scripts\windows\invoke-ps-script.cmd" "%REPO_ROOT%\scripts\windows\deploy-pwsh.ps1" -RepoRoot "%REPO_ROOT%" +exit /b %ERRORLEVEL% +{{- end }} diff --git a/home/.chezmoitemplates/pwsh/env.ps1 b/home/.chezmoitemplates/pwsh/env.ps1 new file mode 100644 index 0000000..9ffe63e --- /dev/null +++ b/home/.chezmoitemplates/pwsh/env.ps1 @@ -0,0 +1,22 @@ +$preferredEditor = if (Get-Command code -ErrorAction SilentlyContinue) { + "code" +} +elseif (Get-Command micro -ErrorAction SilentlyContinue) { + "micro" +} +else { + "notepad" +} +$env:VISUAL = $preferredEditor +$env:EDITOR = $preferredEditor +$env:PAGER = "delta" +$env:PYTHONIOENCODING = "utf-8" + +$PS_USER_FOLDER = "D:\$ENV:USERNAME" +if (Test-Path -Path $PS_USER_FOLDER) { + $ENV:STARSHIP_CACHE = "$PS_USER_FOLDER\Temp\starship" +} +else { + $PS_USER_FOLDER = "$HOME" +} +$ENV:STARSHIP_CONFIG = "$HOME\.config\starship\config.toml" diff --git a/config/pwsh/lib/aliases.ps1 b/home/.chezmoitemplates/pwsh/lib/aliases.ps1 similarity index 100% rename from config/pwsh/lib/aliases.ps1 rename to home/.chezmoitemplates/pwsh/lib/aliases.ps1 diff --git a/config/pwsh/lib/helpers.ps1 b/home/.chezmoitemplates/pwsh/lib/helpers.ps1 similarity index 100% rename from config/pwsh/lib/helpers.ps1 rename to home/.chezmoitemplates/pwsh/lib/helpers.ps1 diff --git a/config/pwsh/profile.ps1 b/home/.chezmoitemplates/pwsh/profile.ps1 similarity index 100% rename from config/pwsh/profile.ps1 rename to home/.chezmoitemplates/pwsh/profile.ps1 diff --git a/home/create_empty_dot_gitconfig.local b/home/create_empty_dot_gitconfig.local new file mode 100644 index 0000000..e69de29 diff --git a/config/codex/AGENTS.md b/home/dot_codex/AGENTS.md similarity index 100% rename from config/codex/AGENTS.md rename to home/dot_codex/AGENTS.md diff --git a/config/codex/config.toml.example b/home/dot_codex/create_config.toml similarity index 100% rename from config/codex/config.toml.example rename to home/dot_codex/create_config.toml diff --git a/config/codex/rules/default.rules.example b/home/dot_codex/rules/create_default.rules similarity index 100% rename from config/codex/rules/default.rules.example rename to home/dot_codex/rules/create_default.rules diff --git a/config/codex/skills/mule-munit/SKILL.md b/home/dot_codex/skills/mule-munit/SKILL.md similarity index 100% rename from config/codex/skills/mule-munit/SKILL.md rename to home/dot_codex/skills/mule-munit/SKILL.md diff --git a/config/codex/skills/mule-munit/agents/openai.yaml b/home/dot_codex/skills/mule-munit/agents/openai.yaml similarity index 100% rename from config/codex/skills/mule-munit/agents/openai.yaml rename to home/dot_codex/skills/mule-munit/agents/openai.yaml diff --git a/config/sheldon/plugins.toml b/home/dot_config/sheldon/plugins.toml similarity index 100% rename from config/sheldon/plugins.toml rename to home/dot_config/sheldon/plugins.toml diff --git a/config/starship/config.toml b/home/dot_config/starship/config.toml similarity index 100% rename from config/starship/config.toml rename to home/dot_config/starship/config.toml diff --git a/config/starship/lean.config.toml b/home/dot_config/starship/lean.config.toml similarity index 100% rename from config/starship/lean.config.toml rename to home/dot_config/starship/lean.config.toml diff --git a/config/zsh/.zshrc b/home/dot_config/zsh/dot_zshrc old mode 100755 new mode 100644 similarity index 100% rename from config/zsh/.zshrc rename to home/dot_config/zsh/dot_zshrc diff --git a/config/zsh/lib/aliases.zsh b/home/dot_config/zsh/lib/aliases.zsh similarity index 100% rename from config/zsh/lib/aliases.zsh rename to home/dot_config/zsh/lib/aliases.zsh diff --git a/config/zsh/lib/completions.zsh b/home/dot_config/zsh/lib/completions.zsh similarity index 100% rename from config/zsh/lib/completions.zsh rename to home/dot_config/zsh/lib/completions.zsh diff --git a/config/zsh/lib/key-bindings.zsh b/home/dot_config/zsh/lib/key-bindings.zsh similarity index 100% rename from config/zsh/lib/key-bindings.zsh rename to home/dot_config/zsh/lib/key-bindings.zsh diff --git a/config/zsh/lib/sheldon.zsh b/home/dot_config/zsh/lib/sheldon.zsh similarity index 100% rename from config/zsh/lib/sheldon.zsh rename to home/dot_config/zsh/lib/sheldon.zsh diff --git a/config/zsh/lib/utilities.zsh b/home/dot_config/zsh/lib/utilities.zsh similarity index 100% rename from config/zsh/lib/utilities.zsh rename to home/dot_config/zsh/lib/utilities.zsh diff --git a/config/zsh/lib/wsl.zsh b/home/dot_config/zsh/lib/wsl.zsh similarity index 100% rename from config/zsh/lib/wsl.zsh rename to home/dot_config/zsh/lib/wsl.zsh diff --git a/config/general/.gitignore_global b/home/dot_fdignore old mode 100755 new mode 100644 similarity index 100% rename from config/general/.gitignore_global rename to home/dot_fdignore diff --git a/config/general/.gitconfig b/home/dot_gitconfig old mode 100755 new mode 100644 similarity index 100% rename from config/general/.gitconfig rename to home/dot_gitconfig diff --git a/config/general/.npmrc b/home/dot_npmrc similarity index 100% rename from config/general/.npmrc rename to home/dot_npmrc diff --git a/config/general/.vimrc b/home/dot_vimrc old mode 100755 new mode 100644 similarity index 100% rename from config/general/.vimrc rename to home/dot_vimrc diff --git a/config/wsl/.wslconfig b/home/dot_wslconfig similarity index 100% rename from config/wsl/.wslconfig rename to home/dot_wslconfig diff --git a/config/zsh/.zshenv b/home/dot_zshenv similarity index 53% rename from config/zsh/.zshenv rename to home/dot_zshenv index a87feef..ae5e33e 100644 --- a/config/zsh/.zshenv +++ b/home/dot_zshenv @@ -1,39 +1,32 @@ -# ~/.zshenv -# Core envionmental variables -# Locations configured here are requred for all other files to be correctly imported +# ~/.zshenv +# Core environmental variables required by all interactive Zsh sessions. -# Set XDG directories export XDG_CONFIG_HOME="${HOME}/.config" export XDG_DATA_HOME="${HOME}/.local/share" -# Set default applications -export EDITOR="vim" +if command -v micro >/dev/null 2>&1; then + export EDITOR="micro" +elif command -v vim >/dev/null 2>&1; then + export EDITOR="vim" +else + export EDITOR="vi" +fi +export VISUAL="$EDITOR" export PAGER="less" -## Respect XDG directories export CARGO_HOME="${XDG_DATA_HOME}/cargo" export DOCKER_CONFIG="${XDG_CONFIG_HOME}/docker" - -# export GIT_CONFIG="${XDG_CONFIG_HOME}/git/.gitconfig" - -export LESSHISTFILE="-" # Disable less history. - +export LESSHISTFILE="-" export PIP_CONFIG_FILE="${XDG_CONFIG_HOME}/pip/pip.conf" export PIP_LOG_FILE="${XDG_DATA_HOME}/pip/log" export PYENV_ROOT="$HOME/.pyenv" - export ZDOTDIR="${XDG_CONFIG_HOME}/zsh" -# local bin -case ":$PATH:" in - *":$HOME/.local/bin:"*) ;; - *) export PATH="$PATH:$HOME/.local/bin" ;; -esac +case ":$PATH:" in *":$HOME/.local/bin:"*) ;; *) export PATH="$PATH:$HOME/.local/bin" ;; esac -# Define Chrome executable -if command -v "google-chrome" > /dev/null 2>&1; then +if command -v google-chrome >/dev/null 2>&1; then export CHROME_EXECUTABLE="google-chrome" -elif command -v "google-chrome-stable" > /dev/null 2>&1; then +elif command -v google-chrome-stable >/dev/null 2>&1; then export CHROME_EXECUTABLE="google-chrome-stable" fi @@ -43,5 +36,4 @@ else export STARSHIP_CONFIG="${XDG_CONFIG_HOME}/starship/lean.config.toml" fi -# Encodings, languges and misc settings -export PYTHONIOENCODING='UTF-8'; +export PYTHONIOENCODING="UTF-8" diff --git a/config/ssh/jsilverdev.pub b/home/private_dot_ssh/jsilverdev.pub similarity index 100% rename from config/ssh/jsilverdev.pub rename to home/private_dot_ssh/jsilverdev.pub diff --git a/install.ps1 b/install.ps1 index 21ef117..50555f7 100644 --- a/install.ps1 +++ b/install.ps1 @@ -1,421 +1,376 @@ [CmdletBinding()] param( [Alias("u")] - [switch]$Update + [switch]$Update, + + [switch]$NonInteractive, + + + [string]$RepoRoot ) -### Start Utils -function RefreshPath() { - $env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path", "User") +$ErrorActionPreference = "Stop" +$NonInteractive = $NonInteractive -or $env:DOTFILES_NONINTERACTIVE -eq "1" + +if ([string]::IsNullOrWhiteSpace($RepoRoot)) { + $RepoRoot = $PSScriptRoot } +$RepoRoot = (Resolve-Path -LiteralPath $RepoRoot).Path +$managedModulesPath = Join-Path $RepoRoot "scripts\windows\managed-modules.txt" +if (-not (Test-Path -LiteralPath $managedModulesPath -PathType Leaf)) { + throw "The managed PowerShell module list is missing from $RepoRoot." +} +$ManagedModules = @(Get-Content -LiteralPath $managedModulesPath | Where-Object { $_.Trim() -and -not $_.Trim().StartsWith('#') }) -function CheckRequiredApps() { - if ($PSVersionTable.PSVersion.Major -lt 7) { - Write-Host "This script requires PowerShell 7 or newer. Exiting..." -ForegroundColor Red - exit - } - if (-not (Get-Command -Name git -ErrorAction SilentlyContinue)) { - Write-Host "Git is not installed. Please install Git before running this script." -ForegroundColor Red - exit - } +$managedAppsPath = Join-Path $RepoRoot "scripts\windows\managed-apps.csv" +if (-not (Test-Path -LiteralPath $managedAppsPath -PathType Leaf)) { + throw "The managed WinGet application catalog is missing from $RepoRoot." +} +$ManagedApps = @(Import-Csv -LiteralPath $managedAppsPath) +if ($ManagedApps.Count -eq 0) { + throw "The managed WinGet application catalog is empty." } -function EnsureDevModeIsEnabled() { - try { - if ((Get-WindowsDeveloperLicense).IsValid) { - Write-Host "Developer Mode is Enabled" -ForegroundColor Green - } - else { - Write-Host "Please enable the Developer Mode and RESTART!!! before continue" -ForegroundColor Red - exit +function Refresh-Path { + $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + $pathEntries = foreach ($pathValue in @( + $PSHOME + $env:Path + [Environment]::GetEnvironmentVariable("Path", "Machine") + [Environment]::GetEnvironmentVariable("Path", "User") + )) { + if ([string]::IsNullOrWhiteSpace($pathValue)) { continue } + foreach ($entry in $pathValue -split [IO.Path]::PathSeparator) { + $entry = $entry.Trim() + if ($entry -and $seen.Add($entry)) { $entry } } } - catch { - Write-Host "An error occurred while checking the developer license: $_" -ForegroundColor Red - exit - } + + $env:Path = $pathEntries -join [IO.Path]::PathSeparator } -function CheckWinget() { - if ($null -eq (Get-Command -Name winget -ErrorAction SilentlyContinue)) { - Write-Output "Enable winget..." - Add-AppxPackage -RegisterByFamilyName -MainPackage Microsoft.DesktopAppInstaller_8wekyb3d8bbwe - RefreshPath +function Check-RequiredApps { + if ($PSVersionTable.PSVersion.Major -lt 7) { + throw "This installer requires PowerShell 7 or newer." + } + foreach ($command in @("git", "winget")) { + if (-not (Get-Command -Name $command -ErrorAction SilentlyContinue)) { + throw "$command is not available. Run bootstrap.cmd first." + } } } -function InstallWithWinget() { +function Invoke-SigningHelper { param( - [string]$appId, - [string]$alias, - [switch]$Update + [Parameter(Mandatory)][ValidateSet("ProtectFiles", "ProtectModule")][string]$Action, + [string[]]$Path, + [string]$ModuleName ) - if (-not ([string]::IsNullOrEmpty($alias))) { - Get-Command -Name $alias -ErrorAction SilentlyContinue | Out-Null - } - else { - winget list --accept-source-agreements -e --id $appId -n 1 | Out-Null + if ($env:DOTFILES_SIGNING_REQUIRED -ne "1" -and (Get-ExecutionPolicy) -ne "AllSigned") { + return } - if (-not $?) { - Write-Host "$appId is not installed. Installing..." -ForegroundColor Yellow - winget install -e --accept-source-agreements --accept-package-agreements --id $appId - } - elseif ($Update) { - Write-Host "Updating $appId..." -ForegroundColor Yellow - winget upgrade --accept-source-agreements --id $appId + $helper = Join-Path $RepoRoot "scripts\windows\signing.ps1" + $bridge = Join-Path $RepoRoot "scripts\windows\invoke-ps-script.cmd" + if (-not (Test-Path -LiteralPath $helper) -or -not (Test-Path -LiteralPath $bridge)) { + throw "The centralized PowerShell signing helper is missing from $RepoRoot." } - else { - Write-Host "$appId is already installed" -ForegroundColor Green + + $arguments = @($helper, "-Action", $Action) + if ($Path) { $arguments += @("-Path") + $Path } + if ($ModuleName) { $arguments += @("-ModuleName", $ModuleName) } + & $bridge @arguments + if ($LASTEXITCODE -ne 0) { + throw "The centralized PowerShell signing helper failed for $Action." } } +function Save-ManagedModuleForAllSigned { + param([Parameter(Mandatory)][string]$Name) -function GetPythonFromMise { + $windowsPowerShell = Join-Path $env:SystemRoot "System32\WindowsPowerShell\v1.0\powershell.exe" + if (-not (Test-Path -LiteralPath $windowsPowerShell -PathType Leaf)) { + throw "Windows PowerShell is required to provision modules under AllSigned." + } + + # Download directly into the PowerShell 7 current-user module root without + # loading PowerShellGet or PackageManagement inside pwsh under AllSigned. + $moduleRoot = Join-Path $HOME "Documents\PowerShell\Modules" + New-Item -ItemType Directory -Path $moduleRoot -Force | Out-Null + + $escapedName = $Name.Replace("'", "''") + $escapedRoot = $moduleRoot.Replace("'", "''") + $command = @( + "`$ErrorActionPreference = 'Stop'" + "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12" + "Save-Module -Name '$escapedName' -Path '$escapedRoot' -Repository PSGallery -Force -AcceptLicense" + ) -join [Environment]::NewLine + $encodedCommand = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($command)) + + $originalPSModulePath = $env:PSModulePath try { - $python = mise which python 2>$null + $env:PSModulePath = @( + (Join-Path $HOME "Documents\WindowsPowerShell\Modules") + (Join-Path $env:ProgramFiles "WindowsPowerShell\Modules") + (Join-Path $env:SystemRoot "System32\WindowsPowerShell\v1.0\Modules") + ) -join [IO.Path]::PathSeparator + + & $windowsPowerShell -NoProfile -NonInteractive -EncodedCommand $encodedCommand if ($LASTEXITCODE -ne 0) { - return $null + throw "Windows PowerShell could not save module '$Name' for PowerShell 7 (exit code $LASTEXITCODE)." } - - return $python.Trim() } - catch { - return $null + finally { + $env:PSModulePath = $originalPSModulePath } } -function InitializeCodexDefaults { - param([string]$BaseDir) - - $codexDir = Join-Path $env:USERPROFILE ".codex" - $defaults = @( - @{ Source = Join-Path $BaseDir "config\codex\config.toml.example"; Destination = Join-Path $codexDir "config.toml" }, - @{ Source = Join-Path $BaseDir "config\codex\rules\default.rules.example"; Destination = Join-Path $codexDir "rules\default.rules" } +function Install-WithWinget { + param( + [Parameter(Mandatory)][string]$AppId, + [string]$Alias, + [string]$Scope, + [switch]$Update ) - foreach ($default in $defaults) { - if (Test-Path -LiteralPath $default.Destination) { - Write-Host "Codex local configuration already exists: $($default.Destination)" -ForegroundColor Yellow - continue - } - - New-Item -ItemType Directory -Force -Path (Split-Path -Parent $default.Destination) | Out-Null - Copy-Item -LiteralPath $default.Source -Destination $default.Destination - Write-Host "Created Codex local configuration: $($default.Destination)" -ForegroundColor Green + $installed = if ($Alias) { + $null -ne (Get-Command -Name $Alias -ErrorAction SilentlyContinue) + } + else { + & winget list --id $AppId --exact --source winget --accept-source-agreements *> $null + $LASTEXITCODE -eq 0 } -} -function InstallMustHaveApps { - ### Start Installing must-have apps - Write-Host "Installing must-have apps..." -ForegroundColor Cyan - - $installs = @( - { InstallWithWinget -appId "7zip.7zip" -Update:$Update }, - { InstallWithWinget -appId "Microsoft.PowerToys" -Update:$Update }, - { InstallWithWinget -appId "zyedidia.micro" -alias "micro" -Update:$Update }, - { InstallWithWinget -appId "lsd-rs.lsd" -alias "lsd" -Update:$Update }, - { InstallWithWinget -appId "sharkdp.bat" -alias "bat" -Update:$Update }, - { InstallWithWinget -appId "Fastfetch-cli.Fastfetch" -alias "fastfetch" -Update:$Update }, - { InstallWithWinget -appId "junegunn.fzf" -alias "fzf" -Update:$Update }, - { InstallWithWinget -appId "sharkdp.fd" -alias "fd" -Update:$Update }, - { InstallWithWinget -appId "dandavison.delta" -alias "delta" -Update:$Update }, - { InstallWithWinget -appId "jqlang.jq" -alias "jq" -Update:$Update }, - { InstallWithWinget -appId "Microsoft.VisualStudioCode" -alias "code" -Update:$Update }, - { InstallWithWinget -appId "BurntSushi.ripgrep.MSVC" -alias "rg" -Update:$Update }, - { InstallWithWinget -appId "jdx.mise" -alias "mise" -Update:$Update } - ) + $scopeArgs = @() + if (-not [string]::IsNullOrWhiteSpace($Scope)) { + $scopeArgs = @("--scope", $Scope) + } - foreach ($install in $installs) { - & $install + if (-not $installed) { + Write-Host "Installing $AppId..." -ForegroundColor Cyan + & winget install --id $AppId --exact --source winget @scopeArgs --silent --disable-interactivity --accept-source-agreements --accept-package-agreements + if ($LASTEXITCODE -ne 0) { throw "WinGet could not install $AppId in scope '$Scope' (exit code $LASTEXITCODE)." } + return } - RefreshPath - ## Install mise cli tools - mise use -g starship@latest + if (-not $Update) { + Write-Host "$AppId is already installed" -ForegroundColor Green + return + } - # Install must-have modules - $modules = @( - "PSFzf", - "git-aliases" + $upgradeCandidates = @( + & winget list --upgrade-available --id $AppId --exact --source winget --accept-source-agreements 2>&1 | + ForEach-Object { [string]$_ } ) - foreach ($module in $modules) { - if (Get-InstalledPSResource -Name $module -ErrorAction SilentlyContinue) { - Write-Host "$module module is already installed" -ForegroundColor Green - - if ($Update) { - Write-Host "Updating $module module..." -ForegroundColor Yellow - Update-PSResource -Name $module -Scope CurrentUser -Force - } - continue - } - - Write-Host "Installing $module module..." -ForegroundColor Cyan - Install-Module -Name $module -Scope CurrentUser -Force -AllowClobber + $upgradeAvailable = @($upgradeCandidates | Where-Object { $_ -match [regex]::Escape($AppId) }).Count -gt 0 + if (-not $upgradeAvailable) { + Write-Host "$AppId is already up to date" -ForegroundColor Green + return } - ### End Installing must-have apps -} - -function SetupDotFiles { - $BASEDIR = $PSScriptRoot - - ### Start DotBot - $DOTBOT_BIN = "bin/dotbot" - $DOTBOT_DIR = "lib/dotbot" + Write-Host "Updating $AppId..." -ForegroundColor Yellow + & winget upgrade --id $AppId --exact --source winget @scopeArgs --silent --disable-interactivity --accept-source-agreements --accept-package-agreements + if ($LASTEXITCODE -ne 0) { throw "WinGet could not update $AppId (exit code $LASTEXITCODE)." } +} - Set-Location $BASEDIR +function Install-MustHaveApps { + Write-Host "Installing baseline apps..." -ForegroundColor Cyan - $PYTHON = GetPythonFromMise - if ([string]::IsNullOrEmpty($PYTHON)) { - Write-Host "Cannot find Python 3 from mise. Installing..." -ForegroundColor Yellow - mise use --global python@latest - $PYTHON = GetPythonFromMise + $coreApps = @($ManagedApps | Where-Object Category -eq "core") + foreach ($app in $coreApps) { + Install-WithWinget -AppId $app.AppId -Alias $app.Alias -Scope $app.Scope -Update:$Update + } - if ([string]::IsNullOrEmpty($PYTHON)) { - Write-Host "Error: Python can't not be found through mise. Aborting..." -ForegroundColor Red - exit + if (-not $NonInteractive) { + foreach ($app in @($ManagedApps | Where-Object Category -eq "workstation")) { + Install-WithWinget -AppId $app.AppId -Alias $app.Alias -Scope $app.Scope -Update:$Update } } - Write-Host "Running Dotbot..." -ForegroundColor Cyan - $env:PROFILE_LOCATION = $profile.CurrentUserAllHosts ## PROFILE_LOCATION - - $DOTBOT_FULL_PATH_BIN = Join-Path $BASEDIR -ChildPath $DOTBOT_DIR | Join-Path -ChildPath $DOTBOT_BIN - - $BASE_CONFIG = "base" - $CONFIG_SUFFIX = ".yaml" - $META_DIR = "meta" - $CONFIG_DIR = "configs" + else { + Write-Host "Skipping workstation applications in non-interactive mode." -ForegroundColor Yellow + } - InitializeCodexDefaults -BaseDir $BASEDIR + Refresh-Path + foreach ($app in $coreApps) { + if ([string]::IsNullOrWhiteSpace($app.Alias)) { continue } + if (-not (Get-Command -Name $app.Alias -ErrorAction SilentlyContinue)) { + throw "Baseline CLI tool '$($app.Alias)' is unavailable after WinGet provisioning." + } + } - &$PYTHON $DOTBOT_FULL_PATH_BIN -d $BASEDIR -c "${META_DIR}/${BASE_CONFIG}${CONFIG_SUFFIX}" + if ($Update) { + & mise use -g starship@latest + if ($LASTEXITCODE -ne 0) { throw "mise could not update starship." } + } + else { + & mise which starship *> $null + if ($LASTEXITCODE -ne 0) { + & mise use -g starship@latest + if ($LASTEXITCODE -ne 0) { throw "mise could not install starship." } + } + } - $CONFIGS = @( - "codex", - "pwsh", - "windows" + $allSigned = $env:DOTFILES_SIGNING_REQUIRED -eq "1" + $userModuleRoots = @( + (Join-Path $HOME "Documents\PowerShell\Modules"), + (Join-Path $HOME ".local\share\powershell\Modules") ) - foreach ($CONFIG in $CONFIGS) { - &$PYTHON $DOTBOT_FULL_PATH_BIN -d $BASEDIR -c "${META_DIR}/${CONFIG_DIR}/${CONFIG}${CONFIG_SUFFIX}" + foreach ($module in $ManagedModules) { + $installedModule = @(Get-Module -ListAvailable -Name $module | Where-Object { + $moduleBase = [IO.Path]::GetFullPath($_.ModuleBase).TrimEnd([IO.Path]::DirectorySeparatorChar) + @($userModuleRoots | Where-Object { + $root = [IO.Path]::GetFullPath($_).TrimEnd([IO.Path]::DirectorySeparatorChar) + $moduleBase.Equals($root, [StringComparison]::OrdinalIgnoreCase) -or + $moduleBase.StartsWith($root + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase) + }).Count -gt 0 + } | Select-Object -First 1) + + if ($installedModule.Count -eq 0 -or $Update) { + $verb = if ($installedModule.Count -eq 0) { "Installing" } else { "Updating" } + Write-Host "$verb $module module..." -ForegroundColor Cyan + + if ($allSigned) { + Save-ManagedModuleForAllSigned -Name $module + } + else { + Install-Module -Name $module -Repository PSGallery -Scope CurrentUser -Force -AllowClobber -AcceptLicense -Confirm:$false + } + } + else { + Write-Host "$module module is already installed" -ForegroundColor Green + } + + Invoke-SigningHelper -Action ProtectModule -ModuleName $module } - ### End DotBot } -function InstallOptionalApps { - ### Start Installing optional apps - - $optionalApps = @( - @{ name = "Google Chrome" ; install = { InstallWithWinget -appId "Google.Chrome" -Update:$Update } }, - @{ name = "KeepassXC" ; install = { InstallWithWinget -appId "KeePassXCTeam.KeePassXC" -Update:$Update } }, - @{ name = "DBeaver"; install = { InstallWithWinget -appId "dbeaver.dbeaver" -Update:$Update } }, - @{ name = "Postman"; install = { InstallWithWinget -appId "Postman.Postman" -Update:$Update } }, - @{ name = "Bruno"; install = { InstallWithWinget -appId "Bruno.Bruno" -Update:$Update } }, - @{ name = "kubectl"; install = { InstallWithWinget -appId "Kubernetes.kubectl" -alias "kubectl" -Update:$Update } }, - @{ name = "GIMP"; install = { InstallWithWinget -appId "GIMP.GIMP" -Update:$Update } }, - @{ name = "Android Studio"; install = { InstallWithWinget -appId "Google.AndroidStudio" -Update:$Update } }, - @{ name = "Steam" ; install = { InstallWithWinget -appId "Valve.Steam" -Update:$Update } }, - @{ name = "Discord" ; install = { InstallWithWinget -appId "Discord.Discord" -Update:$Update } }, - @{ name = "npiperelay" ; install = { InstallWithWinget -appId "albertony.npiperelay" -alias "npiperelay" -Update:$Update } } - ) +function Install-OptionalApps { + if ($NonInteractive) { + Write-Host "Skipping optional applications in non-interactive mode." -ForegroundColor Yellow + return + } + $optionalApps = @($ManagedApps | Where-Object Category -eq "optional") Write-Host " Optionals" Write-Host "-----------------------------------" -ForegroundColor Cyan - Write-Host " Choose to Install" - Write-Host "-----------------------------------" -ForegroundColor Cyan for ($i = 0; $i -lt $optionalApps.Count; $i++) { - Write-Host ("{0}. Install {1}" -f ($i + 1), $optionalApps[$i].name) + Write-Host ("{0}. Install {1}" -f ($i + 1), $optionalApps[$i].Name) } - Write-Host "-----------------------------------" -ForegroundColor Cyan Write-Host "You can use ranges like 1-4 or individual numbers separated by commas" -ForegroundColor Yellow - $rawOptions = Read-Host ("Select options [e.g. 1-4,8,10]" ) + $rawOptions = Read-Host "Select options [e.g. 1-4,8,10]" $options = @() - foreach ($option in $rawOptions -split ',') { $option = $option.Trim() - if ($option -match '^(\d+)-(\d+)$') { - $start = [int]$Matches[1] - $end = [int]$Matches[2] - if ($start -le $end) { - $options += $start..$end - } + if ($option -match '^(\d+)-(\d+)$' -and [int]$Matches[1] -le [int]$Matches[2]) { + $options += [int]$Matches[1]..[int]$Matches[2] } elseif ($option -match '^\d+$') { $options += [int]$option } } - $options = $options | Where-Object { $_ -gt 0 -and $_ -le $optionalApps.Count } | Select-Object -Unique | Sort-Object - - if ($options.Count -eq 0) { - Write-Host "Skipping optional installs..." -ForegroundColor Yellow - } - else { - foreach ($index in $options) { - $app = $optionalApps[$index - 1] - Write-Host "Installing $($app.name)..." -ForegroundColor Cyan - & $app.install - } - ## Refresh Path - RefreshPath + $options = @($options | Where-Object { $_ -gt 0 -and $_ -le $optionalApps.Count } | Select-Object -Unique | Sort-Object) + foreach ($index in $options) { + $app = $optionalApps[$index - 1] + Write-Host "Installing $($app.Name)..." -ForegroundColor Cyan + Install-WithWinget -AppId $app.AppId -Alias $app.Alias -Scope $app.Scope -Update:$Update } - ### End Installing optional apps + if ($options.Count -eq 0) { Write-Host "Skipping optional installs..." -ForegroundColor Yellow } + Refresh-Path } -function DownloadFonts { - $BASEDIR = $PSScriptRoot - $FONTS = "$BASEDIR\fonts" - - if (!(Test-Path -Path "$FONTS")) { - New-Item -ItemType Directory -Force -Path "$FONTS" - } - - $CASCADIA_CODE = "$FONTS\CascadiaCode" - - if (!(Test-Path -Path "${CASCADIA_CODE}.ttf")) { - $apiUrl = "https://api.github.com/repos/microsoft/cascadia-code/releases/latest" - $latestGitInfo = Invoke-RestMethod -Uri $apiUrl -Headers @{ "User-Agent" = "PowerShell" } - $browser_download_url = $latestGitInfo.assets[0].browser_download_url - Invoke-WebRequest -Uri $browser_download_url -OutFile "${CASCADIA_CODE}.zip" - Expand-Archive "${CASCADIA_CODE}.zip" -DestinationPath $CASCADIA_CODE - Remove-Item -r -force "${CASCADIA_CODE}\ttf\static" - Get-ChildItem -Path "${CASCADIA_CODE}\*.ttf" -Recurse | Move-Item -Destination $FONTS - Remove-Item -r -force "${CASCADIA_CODE}.zip" - Remove-Item -r -force "${CASCADIA_CODE}" +function Download-Fonts { + $fonts = Join-Path $RepoRoot "fonts" + New-Item -ItemType Directory -Force -Path $fonts | Out-Null + + if (-not (Test-Path (Join-Path $fonts "CascadiaCode.ttf"))) { + $release = Invoke-RestMethod -Uri "https://api.github.com/repos/microsoft/cascadia-code/releases/latest" -Headers @{ "User-Agent" = "PowerShell" } + $asset = @($release.assets | Where-Object name -Match '^CascadiaCode-.*\.zip$' | Select-Object -First 1) + if ($asset.Count -ne 1) { throw "Unable to locate the Cascadia Code ZIP asset in the latest GitHub release." } + + $zip = Join-Path $fonts "CascadiaCode.zip" + $extract = Join-Path $fonts "CascadiaCode" + Invoke-WebRequest -Uri $asset[0].browser_download_url -OutFile $zip + Expand-Archive $zip -DestinationPath $extract -Force + Remove-Item -Recurse -Force (Join-Path $extract "ttf\static") -ErrorAction SilentlyContinue + Get-ChildItem -Path $extract -Filter *.ttf -Recurse -File | Move-Item -Destination $fonts -Force + Remove-Item -Recurse -Force $zip, $extract + } + + $nerdRelease = Invoke-RestMethod -Uri "https://api.github.com/repos/ryanoasis/nerd-fonts/releases/latest" -Headers @{ "User-Agent" = "PowerShell" } + foreach ($font in @( + @{ folder = (Join-Path $fonts "CaskaydiaCoveNerdFont"); filename = "CascadiaCode" }, + @{ folder = (Join-Path $fonts "CaskaydiaMonoNerdFont"); filename = "CascadiaMono" } + )) { + if (Test-Path "$($font.folder)-Regular.ttf") { continue } + $zip = "$($font.folder).zip" + Invoke-WebRequest -Uri "https://github.com/ryanoasis/nerd-fonts/releases/download/$($nerdRelease.tag_name)/$($font.filename).zip" -OutFile $zip + Expand-Archive $zip -DestinationPath $font.folder -Force + Get-ChildItem -Path $font.folder -Filter *.ttf -Recurse -File | Move-Item -Destination $fonts -Force + Remove-Item -Recurse -Force $zip, $font.folder } - - $apiUrl = "https://api.github.com/repos/ryanoasis/nerd-fonts/releases/latest" - - $nFonts = @( - @{ folder = "$FONTS\CaskaydiaCoveNerdFont"; filename = "CascadiaCode" }, - @{ folder = "$FONTS\CaskaydiaMonoNerdFont"; filename = "CascadiaMono" } - ) - - foreach ($nf in $nFonts) { - $NF_FONT = $nf.folder - $NF_FILENAME = $nf.filename - - if (!(Test-Path -Path "${NF_FONT}-Regular.ttf")) { - $latestGitInfo = Invoke-RestMethod -Uri $apiUrl -Headers @{ "User-Agent" = "PowerShell" } - $NF_VERSION = $latestGitInfo.tag_name - $browser_download_url = "https://github.com/ryanoasis/nerd-fonts/releases/download/${NF_VERSION}/${NF_FILENAME}.zip" - Invoke-WebRequest -Uri $browser_download_url -OutFile "${NF_FONT}.zip" - Expand-Archive "${NF_FONT}.zip" -DestinationPath $NF_FONT - Get-ChildItem -Path "${NF_FONT}\*.ttf" -Recurse | Move-Item -Destination $FONTS - Remove-Item -r -force "${NF_FONT}.zip" - Remove-Item -r -force "${NF_FONT}" - } - } - - } -function InstallUserFonts { - $sourceDir = Join-Path $PSScriptRoot "fonts" +function Install-UserFonts { + $sourceDir = Join-Path $RepoRoot "fonts" $userFontsDir = Join-Path $env:LOCALAPPDATA "Microsoft\Windows\Fonts" $fontRegistryKey = "HKCU:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts" - $fontExtensions = @("*.otc", "*.otf", "*.ttc", "*.ttf") - $windowsVersion = [System.Environment]::OSVersion.Version - - if ($windowsVersion.Major -lt 10 -or ($windowsVersion.Major -eq 10 -and $windowsVersion.Build -lt 17704)) { - Write-Host "User font installation requires Windows 10 build 17704 or newer." -ForegroundColor Red - return - } - - if (-not (Test-Path -Path $sourceDir -PathType Container)) { - Write-Host "Font source directory not found: $sourceDir" -ForegroundColor Red - return - } - - if ((Get-Item -LiteralPath $userFontsDir -ErrorAction SilentlyContinue) -is [System.IO.FileInfo]) { - Write-Host "User fonts path is a file: $userFontsDir" -ForegroundColor Red - return - } - - if (-not (Test-Path -Path $userFontsDir -PathType Container)) { - New-Item -ItemType Directory -Path $userFontsDir -Force | Out-Null - } - - $sourceFonts = @(foreach ($pattern in $fontExtensions) { - Get-ChildItem -Path $sourceDir -Filter $pattern -Recurse -File - }) - - if ($null -eq $sourceFonts -or $sourceFonts.Count -eq 0) { - Write-Host "No fonts found in $sourceDir" -ForegroundColor Yellow - return - } - - $installedFonts = @{} - foreach ($font in Get-ChildItem -Path $userFontsDir -Recurse -File -ErrorAction SilentlyContinue) { - $installedFonts[$font.Name] = $font.FullName - } - + if (-not (Test-Path -LiteralPath $sourceDir -PathType Container)) { throw "Font source directory not found: $sourceDir" } + New-Item -ItemType Directory -Path $userFontsDir -Force | Out-Null + $sourceFonts = @(Get-ChildItem -Path $sourceDir -Include *.otc,*.otf,*.ttc,*.ttf -Recurse -File) foreach ($font in $sourceFonts | Sort-Object Name -Unique) { - if ($installedFonts.ContainsKey($font.Name)) { - Write-Host "Font '$($font.Name)' is already installed" -ForegroundColor Green - continue - } - $destination = Join-Path $userFontsDir $font.Name - - try { - Copy-Item -LiteralPath $font.FullName -Destination $destination -Force - New-ItemProperty -Path $fontRegistryKey -Name "$($font.Name) (dotfiles)" -Value $destination -PropertyType String -Force | Out-Null - Write-Host "Installed font '$($font.Name)'" -ForegroundColor Cyan - } - catch { - Write-Host "Unable to install font '$($font.Name)': $_" -ForegroundColor Red - } + if (Test-Path -LiteralPath $destination) { continue } + Copy-Item -LiteralPath $font.FullName -Destination $destination + New-ItemProperty -Path $fontRegistryKey -Name "$($font.Name) (dotfiles)" -Value $destination -PropertyType String -Force | Out-Null } } -function ConfigureGit { - # Create .gitconfig.local if not exists - if (-not (Test-Path "$HOME\.gitconfig.local")) { New-Item -Path "$HOME\.gitconfig.local" -ItemType File } - git submodule sync --quiet --recursive - git submodule update --init --recursive +function Configure-Git { + $localConfig = Join-Path $HOME ".gitconfig.local" + if (-not (Test-Path -LiteralPath $localConfig)) { New-Item -ItemType File -Path $localConfig | Out-Null } Write-Host "Git successfully configured!" -ForegroundColor Green } -function SettingsForWindowsTerminal { - $source = "$PSScriptRoot\config\win-terminal\config.json" - $destination = "$($env:LOCALAPPDATA)\Packages\Microsoft.WindowsTerminal_8wekyb3d8bbwe\LocalState\settings.json" +function Configure-WindowsTerminal { + $source = Join-Path $RepoRoot "assets\win-terminal\config.json" + $destination = Join-Path $env:LOCALAPPDATA "Packages\Microsoft.WindowsTerminal_8wekyb3d8bbwe\LocalState\settings.json" + if (-not (Test-Path -LiteralPath $destination)) { Write-Host "Windows Terminal settings file not found. Skipping configuration." -ForegroundColor Yellow; return } + if (-not (Get-Command jq -ErrorAction SilentlyContinue)) { Write-Host "jq is unavailable. Skipping Windows Terminal configuration." -ForegroundColor Yellow; return } + & jq --indent 4 --slurpfile src $source '. as $original | $src[0] | to_entries | map(select(.key != "profiles")) | reduce .[] as $item ($original; . * {($item.key): $item.value}) | . * {"profiles": {"defaults": $src[0].profiles.defaults}}' $destination | Set-Content -Path $destination + if ($LASTEXITCODE -ne 0) { throw "Windows Terminal configuration merge failed." } +} - if (-not (Test-Path $destination)) { - Write-Host "Windows Terminal settings file not found. Skipping configuration." -ForegroundColor Yellow +function Configure-Wsl { + if ($NonInteractive) { + Write-Host "Skipping WSL installation in non-interactive mode." -ForegroundColor Yellow return } + if (Get-Command wsl -ErrorAction SilentlyContinue) { + Write-Host "Installing WSL..." -ForegroundColor Cyan + & wsl --install --no-distribution + } +} - jq --indent 4 --slurpfile src "$source" ' - . as $original | - $src[0] | - to_entries | - map(select(.key != "profiles")) | - reduce .[] as $item ($original; - . * {($item.key): $item.value} - ) | - . * {"profiles": {"defaults": $src[0].profiles.defaults}} - ' "$destination" | Set-Content -Path $destination +Refresh-Path +Check-RequiredApps + +if (-not $NonInteractive) { + Download-Fonts + Install-UserFonts } -function ConfigureWsl { - Write-Host "Installing WSL..." -ForegroundColor Cyan - wsl --install --no-distribution +Configure-Git +Install-MustHaveApps + +if (-not $NonInteractive) { + Configure-WindowsTerminal + Install-OptionalApps +} +else { + Write-Host "Skipping fonts, terminal configuration, and optional applications in non-interactive mode." -ForegroundColor Yellow } -### End Utils - -RefreshPath -CheckRequiredApps -EnsureDevModeIsEnabled -CheckWinget -DownloadFonts -InstallUserFonts -ConfigureGit -InstallMustHaveApps -SetupDotFiles -SettingsForWindowsTerminal -InstallOptionalApps -ConfigureWsl +Configure-Wsl diff --git a/install.sh b/install.sh index 31c18e6..2d752f6 100755 --- a/install.sh +++ b/install.sh @@ -1,528 +1,359 @@ -#!/usr/bin/bash +#!/usr/bin/env bash +set -euo pipefail -RED='\033[0;31m' # Red -GREEN='\033[0;32m' # Green -YELLOW='\033[0;33m' # Yellow -BLUE='\033[0;34m' # Blue -CYAN='\033[0;36m' # Cyan +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +CYAN='\033[0;36m' LIGHT='\x1b[2m' RESET='\033[0m' - -SRC_DIR=$(dirname "${0}") -DOTFILES_DIR="${DOTFILES_DIR:-${SRC_DIR:-$HOME/.dotfiles}}" +REPO_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" UPDATE=false - -function usage () { - echo "Usage: $0 [--update|-u]" - echo - echo "Options:" - echo " -u, --update Re-run package installers even when commands already exist" - echo " -h, --help Show this help message" +NON_INTERACTIVE=false +DISTRO="" +arch="" + +usage() { + cat <<'EOF' +Usage: install.sh [--update|-u] [--non-interactive] + +Options: + -u, --update Refresh packages and tools managed by this installer + --non-interactive Install the baseline without prompts or workstation customization + -h, --help Show this help message +EOF } -function parse_args () { - while [ "$#" -gt 0 ]; do +parse_args() { + while (( $# > 0 )); do case "$1" in - -u|--update) - UPDATE=true - ;; - -h|--help) - usage - exit 0 - ;; - *) - echo -e "${RED}Unknown option: $1${RESET}" - usage - exit 1 - ;; + -u|--update) UPDATE=true ;; + --non-interactive) NON_INTERACTIVE=true ;; + -h|--help) usage; exit 0 ;; + *) printf '%bUnknown option: %s%b\n' "$RED" "$1" "$RESET" >&2; usage >&2; exit 1 ;; esac shift done + if [[ "${DOTFILES_NONINTERACTIVE:-0}" == "1" ]]; then + NON_INTERACTIVE=true + fi } -function updates_enabled () { - case "${UPDATE}" in - 1|true|TRUE|yes|YES|y|Y) return 0 ;; - *) return 1 ;; - esac +updates_enabled() { [[ "$UPDATE" == true ]]; } +noninteractive_enabled() { [[ "$NON_INTERACTIVE" == true ]]; } + +read_manifest() { + local path=$1 + [[ -f "$path" ]] || { printf 'Manifest not found: %s\n' "$path" >&2; return 1; } + grep -Ev '^[[:space:]]*(#|$)' "$path" } -function pre_setup_tasks() { - if [ ! -d "$DOTFILES_DIR" ]; then - echo -e "${RED}The folder '$DOTFILES_DIR' not exists exiting..."; - exit 1; +detect_distro() { + if [[ -f /etc/debian_version ]] && command -v apt-get >/dev/null 2>&1; then + DISTRO="debian" + elif [[ -f /etc/arch-release ]] && command -v pacman >/dev/null 2>&1; then + DISTRO="arch" + else + printf '%bUnsupported Linux distribution. Debian and Arch Linux are supported.%b\n' "$RED" "$RESET" >&2 + exit 1 fi +} - source "${DOTFILES_DIR}/config/zsh/.zshenv" +detect_arch() { + arch="$(uname -m | tr '[:upper:]' '[:lower:]')" + case "$arch" in x86_64) arch="amd64" ;; arm64) arch="aarch64" ;; esac + if [[ "$arch" == "amd64" && "$(getconf LONG_BIT)" -eq 32 ]]; then + arch="i686" + elif [[ "$arch" == "aarch64" && "$(getconf LONG_BIT)" -eq 32 ]]; then + arch="arm" + fi + if [[ "$arch" != "amd64" && "$arch" != "aarch64" ]]; then + printf '%bOnly amd64 and aarch64 are supported.%b\n' "$RED" "$RESET" >&2 + exit 1 + fi + printf '%bCurrent arch is %s%b\n' "$GREEN" "$arch" "$RESET" +} +pre_setup_tasks() { + [[ -d "$REPO_ROOT" ]] || { printf '%bRepository folder not found: %s%b\n' "$RED" "$REPO_ROOT" "$RESET" >&2; exit 1; } + mkdir -p "$HOME/.local/bin" + export PATH="$HOME/.local/bin:$PATH" + detect_distro detect_arch - if updates_enabled; then - echo -e "${CYAN}Update mode enabled. Existing packages will be refreshed when possible.${RESET}" + printf '%bUpdate mode enabled.%b\n' "$CYAN" "$RESET" fi } -detect_arch() { - - arch="$(uname -m | tr '[:upper:]' '[:lower:]')" - - case "${arch}" in - x86_64) arch="amd64" ;; - arm64) arch="aarch64" ;; - esac - - # `uname -m` in some cases mis-reports 32-bit OS as 64-bit, so double check - if [ "${arch}" = "amd64" ] && [ "$(getconf LONG_BIT)" -eq 32 ]; then - arch=i686 - elif [ "${arch}" = "aarch64" ] && [ "$(getconf LONG_BIT)" -eq 32 ]; then - arch=arm - fi - - if [ "${arch}" != "amd64" ] && [ "${arch}" != "aarch64" ]; then - echo -e "${RED}Only amd64 and aarch64 supported"; - exit 1 - fi - - echo -e "${GREEN}Current arch is '${arch}'${RESET}" +refresh_package_index() { + [[ "${DOTFILES_PACKAGE_INDEX_READY:-0}" == "1" ]] && return + case "$DISTRO" in + debian) sudo apt-get update ;; + arch) sudo pacman -Syu --noconfirm ;; + esac + export DOTFILES_PACKAGE_INDEX_READY=1 } -function install_with_apt () { - local app=$1 - - if hash "${app}" 2> /dev/null && ! updates_enabled; then - echo -e "${YELLOW}[Skipping]${LIGHT} ${app} is already installed${RESET}" - elif dpkg -s "${app}" &> /dev/null && ! updates_enabled; then - echo -e "${YELLOW}[Skipping]${LIGHT} ${app} is already installed via APT${RESET}" - elif hash flatpak 2> /dev/null && [[ ! -z $(echo $(flatpak list --columns=ref | grep $app)) ]]; then - echo -e "${YELLOW}[Skipping]${LIGHT} ${app} is already installed via Flatpak${RESET}" +install_debian_manifest() { + local manifest="$REPO_ROOT/scripts/linux/packages-debian.txt" + local -a packages selected=() + mapfile -t packages < <(read_manifest "$manifest") + if updates_enabled; then + selected=("${packages[@]}") else - if updates_enabled && { hash "${app}" 2> /dev/null || dpkg -s "${app}" &> /dev/null; }; then - echo -e "${CYAN}[Updating]${LIGHT} ${app}...${RESET}" - else - echo -e "${CYAN}[Installing]${LIGHT} Downloading ${app}...${RESET}" - fi - sudo apt install "${app}" --assume-yes + local package + for package in "${packages[@]}"; do + dpkg -s "$package" >/dev/null 2>&1 || selected+=("$package") + done + fi + if (( ${#selected[@]} > 0 )); then + sudo apt-get install --yes "${selected[@]}" + else + printf '%bAPT baseline already installed.%b\n' "$YELLOW" "$RESET" fi } -function check_package_or_run () { - local app=$1 - local installer=$2 - - if hash "$app" 2> /dev/null && ! updates_enabled; then - echo -e "${YELLOW}[Skipping]${LIGHT} $app is already installed${RESET}" +install_arch_manifest() { + local manifest="$REPO_ROOT/scripts/linux/packages-arch.txt" + local -a packages selected=() + mapfile -t packages < <(read_manifest "$manifest") + if updates_enabled; then + selected=("${packages[@]}") else - if hash "$app" 2> /dev/null; then - echo -e "${CYAN}[Updating]${LIGHT} $app...${RESET}" - else - echo -e "${CYAN}[Installing]${LIGHT} Downloading $app...${RESET}" - fi - "$installer" + local package + for package in "${packages[@]}"; do + pacman -Q "$package" >/dev/null 2>&1 || selected+=("$package") + done + fi + if (( ${#selected[@]} > 0 )); then + sudo pacman -S --needed --noconfirm "${selected[@]}" + else + printf '%bPacman baseline already installed.%b\n' "$YELLOW" "$RESET" fi } -function github_release_json () { - local repo=$1 - local release - local releases +check_package_or_run() { + local command_name=$1 installer=$2 + if command -v "$command_name" >/dev/null 2>&1 && ! updates_enabled; then + printf '%b[Skipping]%b %s is already installed%b\n' "$YELLOW" "$LIGHT" "$command_name" "$RESET" + return + fi + "$installer" +} - if release=$(curl -fsSL "https://api.github.com/repos/${repo}/releases/latest" 2>/dev/null); then +github_release_json() { + local repo=$1 release releases + if release="$(curl -fsSL "https://api.github.com/repos/${repo}/releases/latest" 2>/dev/null)"; then printf '%s\n' "$release" - return 0 + return fi - - releases=$(curl -fsSL "https://api.github.com/repos/${repo}/releases?per_page=10" 2>/dev/null) || return 1 + releases="$(curl -fsSL "https://api.github.com/repos/${repo}/releases?per_page=10")" jq -e 'map(select((.draft | not) and (.prerelease | not))) | .[0]' <<< "$releases" } -function github_release_asset_url () { - local repo=$1 - local asset_regex=$2 - local release - - release=$(github_release_json "$repo") || return 1 - - jq -er --arg asset_regex "$asset_regex" ' - first(.assets[]?.browser_download_url | select(test($asset_regex))) - ' <<< "$release" +github_release_asset_url() { + local repo=$1 asset_regex=$2 release + release="$(github_release_json "$repo")" + jq -er --arg asset_regex "$asset_regex" 'first(.assets[]?.browser_download_url | select(test($asset_regex)))' <<< "$release" } -function install_github_deb_asset () { - local repo=$1 - local asset_regex=$2 - local asset_url - local deb_file - local install_status - - asset_url=$(github_release_asset_url "$repo" "$asset_regex") || { - echo -e "${RED}Could not find a matching release asset for ${repo}.${RESET}" +install_github_deb_asset() { + local repo=$1 asset_regex=$2 asset_url tmp_dir deb_file + asset_url="$(github_release_asset_url "$repo" "$asset_regex")" || { + printf '%bCould not find a matching release asset for %s.%b\n' "$RED" "$repo" "$RESET" >&2 return 1 } - - deb_file="${asset_url##*/}" - - wget -O "$deb_file" "$asset_url" || return 1 - sudo dpkg -i "$deb_file" - install_status=$? - rm -f "$deb_file" - - return "$install_status" -} - -function debian_release_arch () { - case "$arch" in - aarch64) printf 'arm64\n' ;; - *) printf '%s\n' "$arch" ;; - esac + tmp_dir="$(mktemp -d)" + deb_file="$tmp_dir/${asset_url##*/}" + if ! curl -fL "$asset_url" -o "$deb_file"; then rm -rf "$tmp_dir"; return 1; fi + if ! sudo dpkg -i "$deb_file"; then rm -rf "$tmp_dir"; return 1; fi + rm -rf "$tmp_dir" } -function install_fastfetch () { - if apt-cache show fastfetch &>/dev/null; then - sudo apt install -y fastfetch - else - install_github_deb_asset "fastfetch-cli/fastfetch" "fastfetch-linux-${arch}\\.deb$" - fi -} +debian_release_arch() { [[ "$arch" == "aarch64" ]] && printf 'arm64\n' || printf '%s\n' "$arch"; } -function install_lsd () { - if apt-cache show lsd &>/dev/null; then - sudo apt install -y lsd - else - local lsd_arch - lsd_arch=$(debian_release_arch) - install_github_deb_asset "lsd-rs/lsd" "lsd_.*_${lsd_arch}_xz\\.deb$" - fi +install_fastfetch() { + if apt-cache show fastfetch >/dev/null 2>&1; then sudo apt-get install --yes fastfetch + else install_github_deb_asset "fastfetch-cli/fastfetch" "fastfetch-linux-${arch}-polyfilled\\.deb$"; fi } - -function install_fzf () { - local fzf_dir="$HOME/.config/fzf" - - if [ -d "$fzf_dir/.git" ]; then - git -C "$fzf_dir" pull --ff-only - else - git clone https://github.com/junegunn/fzf.git "$fzf_dir" - fi - - "$fzf_dir/install" --bin +install_lsd() { + if apt-cache show lsd >/dev/null 2>&1; then sudo apt-get install --yes lsd + else local a; a="$(debian_release_arch)"; install_github_deb_asset "lsd-rs/lsd" "lsd_.*_${a}_xz\\.deb$"; fi } - -function install_vivid () { - local vivid_arch - vivid_arch=$(debian_release_arch) - install_github_deb_asset "sharkdp/vivid" "vivid_.*_${vivid_arch}\\.deb$" +install_fzf() { + local dir="$HOME/.config/fzf" + if [[ -d "$dir/.git" ]]; then git -c safe.directory="$dir" -C "$dir" pull --ff-only + else git clone https://github.com/junegunn/fzf.git "$dir"; fi + "$dir/install" --bin + ln -sfn "$dir/bin/fzf" "$HOME/.local/bin/fzf" } - -function install_delta () { - local delta_arch - delta_arch=$(debian_release_arch) - install_github_deb_asset "dandavison/delta" "git-delta_.*_${delta_arch}\\.deb$" +install_vivid() { local a; a="$(debian_release_arch)"; install_github_deb_asset "sharkdp/vivid" "vivid_.*_${a}\\.deb$"; } +install_delta() { local a; a="$(debian_release_arch)"; install_github_deb_asset "dandavison/delta" "git-delta_.*_${a}\\.deb$"; } +install_starship() { curl -fsSL https://starship.rs/install.sh | sh -s -- -y -b "$HOME/.local/bin"; } +install_sheldon() { + local -a args=(--repo rossmacarthur/sheldon --to "$HOME/.local/bin") + updates_enabled && args+=(--force) + curl --proto '=https' -fLsS https://rossmacarthur.github.io/install/crate.sh | bash -s -- "${args[@]}" } -function install_starship () { - curl -sS https://starship.rs/install.sh | sh +install_debian_packages() { + install_debian_manifest + check_package_or_run fastfetch install_fastfetch + check_package_or_run lsd install_lsd + check_package_or_run fzf install_fzf + check_package_or_run vivid install_vivid + check_package_or_run delta install_delta + check_package_or_run starship install_starship + check_package_or_run sheldon install_sheldon } -function install_sheldon () { - local install_args=( - --repo rossmacarthur/sheldon - --to "$HOME/.local/bin" - ) - - if updates_enabled; then - install_args+=(--force) +install_yay() { + if command -v yay >/dev/null 2>&1 && ! updates_enabled; then + printf '%b[Skipping]%b yay is already installed%b\n' "$YELLOW" "$LIGHT" "$RESET" + return fi - - curl --proto '=https' -fLsS https://rossmacarthur.github.io/install/crate.sh \ - | bash -s -- "${install_args[@]}" + sudo pacman -S --needed --noconfirm git base-devel + local tmp_dir + tmp_dir="$(mktemp -d)" + git clone https://aur.archlinux.org/yay.git "$tmp_dir/yay" + (cd "$tmp_dir/yay" && makepkg -si --noconfirm --needed) + rm -rf "$tmp_dir" } -function install_debian_packages () { - - debian_apps=( - "git" - "curl" - "wget" - "zsh" - "micro" - "jq" - "tree" - "python3" - "ufw" - "rsync" - "zip" - "unzip" - "less" - "socat" - "bat" - "fd-find" # fd - "binutils" # strings - "ripgrep" - ) - - for app in ${debian_apps[@]}; do - install_with_apt $app - done - - check_package_or_run "fastfetch" "install_fastfetch" - check_package_or_run "lsd" "install_lsd" - check_package_or_run "fzf" "install_fzf" - check_package_or_run "vivid" "install_vivid" - check_package_or_run "delta" "install_delta" - check_package_or_run "starship" "install_starship" - check_package_or_run "sheldon" "install_sheldon" -} +install_arch_packages() { install_arch_manifest; install_yay; } -function install_with_pacman () { - local app=$1 - local pacman_app - local pacman_status - - pacman_app=$(printf '%s' "$app" | tr 'A-Z' 'a-z') - pacman_status=$(pacman -Qk "$pacman_app" 2> /dev/null) - - if hash "${app}" 2> /dev/null && ! updates_enabled; then - echo -e "${YELLOW}[Skipping]${LIGHT} ${app} is already installed${RESET}" - elif [[ "$pacman_status" == *"total files"* ]] && ! updates_enabled; then - echo -e "${YELLOW}[Skipping]${LIGHT} ${app} is already installed via Pacman${RESET}" - elif hash flatpak 2> /dev/null && [[ ! -z $(echo $(flatpak list --columns=ref | grep $app)) ]]; then - echo -e "${YELLOW}[Skipping]${LIGHT} ${app} is already installed via Flatpak${RESET}" - else - if updates_enabled && { hash "${app}" 2> /dev/null || [[ "$pacman_status" == *"total files"* ]]; }; then - echo -e "${CYAN}[Updating]${LIGHT} ${app}...${RESET}" - else - echo -e "${CYAN}[Installing]${LIGHT} Downloading ${app}...${RESET}" - fi - sudo pacman -S "${app}" --needed --noconfirm - fi +install_must_have_packages() { + printf '%bInstalling baseline packages...%b\n' "$CYAN" "$RESET" + refresh_package_index + case "$DISTRO" in debian) install_debian_packages ;; arch) install_arch_packages ;; esac } -function install_arch_packages () { - pacman_apps=( - "git" - "curl" - "wget" - "zsh" - "micro" - "fastfetch" - "tree" - "jq" - "lsd" - "fd" - "fzf" - "vivid" - "git-delta" - "starship" - "sheldon" - "bat" - "python" - "ufw" - "rsync" - "zip" - "unzip" - "less" - "socat" - "binutils" - "ripgrep" - ) +setup_sheldon_plugins() { command -v sheldon >/dev/null 2>&1 && sheldon lock; } - for app in ${pacman_apps[@]}; do - install_with_pacman $app - done - - # Install yay - if hash "yay" 2> /dev/null; then - echo -e "${YELLOW}[Skipping]${LIGHT} yay is already installed${RESET}" +setup_default_shell() { + local target_user="${USER:-$(id -un)}" current_shell target_shell + current_shell="$(getent passwd "$target_user" | cut -d: -f7)" + target_shell="$(command -v zsh)" + if [[ "$current_shell" != "$target_shell" ]]; then + chsh -s "$target_shell" "$target_user" + printf '%bDefault shell changed to zsh for %s.%b\n' "$GREEN" "$target_user" "$RESET" else - sudo pacman -S --needed git base-devel && git clone https://aur.archlinux.org/yay.git ~/.yay && (cd ~/.yay && makepkg -si) && rm -rf ~/.yay - fi -} - -function install_must_have_packages() { - echo -e "${CYAN}Installing must have packages...${RESET}" - - if [ -f "/etc/debian_version" ]; then - sudo apt update && \ - install_debian_packages - elif [ -f "/etc/arch-release" ]; then - sudo pacman -Syy --noconfirm && \ - install_arch_packages + printf '%bzsh is already the default shell for %s.%b\n' "$YELLOW" "$target_user" "$RESET" fi - } -function setup_dot_files () { - - DOTBOT_BIN="bin/dotbot" - DOTBOT_DIR="lib/dotbot" - DOTBOT_CONF_FILE="install.conf.yaml" - DOTBOT_FULL_PATH_BIN="${DOTFILES_DIR}/${DOTBOT_DIR}/bin/dotbot" - - BASE_CONFIG="base" - CONFIG_SUFFIX=".yaml" - META_DIR="meta" - CONFIG_DIR="configs" - - CODEX_DIR="$HOME/.codex" - CODEX_CONFIG="$CODEX_DIR/config.toml" - CODEX_RULES="$CODEX_DIR/rules/default.rules" +configure_git() { [[ -e "$HOME/.gitconfig.local" ]] || touch "$HOME/.gitconfig.local"; } - if [ ! -e "$CODEX_CONFIG" ]; then - mkdir -p "$CODEX_DIR" - cp "$DOTFILES_DIR/config/codex/config.toml.example" "$CODEX_CONFIG" - echo -e "${GREEN}Created Codex local configuration: $CODEX_CONFIG${RESET}" - else - echo -e "${YELLOW}Codex local configuration already exists: $CODEX_CONFIG${RESET}" - fi - - if [ ! -e "$CODEX_RULES" ]; then - mkdir -p "$CODEX_DIR/rules" - cp "$DOTFILES_DIR/config/codex/rules/default.rules.example" "$CODEX_RULES" - echo -e "${GREEN}Created Codex local configuration: $CODEX_RULES${RESET}" - else - echo -e "${YELLOW}Codex local configuration already exists: $CODEX_RULES${RESET}" +configure_wsl() { + if noninteractive_enabled; then + printf '%bSkipping WSL system configuration in non-interactive mode.%b\n' "$YELLOW" "$RESET" + return fi - - $DOTBOT_FULL_PATH_BIN -d "$DOTFILES_DIR" -c "${META_DIR}/${BASE_CONFIG}${CONFIG_SUFFIX}" - - CONFIGS="codex zsh" - - for config in $CONFIGS; do - $DOTBOT_FULL_PATH_BIN -d "$DOTFILES_DIR" -c "${META_DIR}/${CONFIG_DIR}/${config}${CONFIG_SUFFIX}" - done -} - -function setup_sheldon_plugins () { - if hash "sheldon" 2> /dev/null; then - sheldon lock + local desired="$REPO_ROOT/assets/wsl/wsl.conf" + if grep -qi microsoft /proc/version 2>/dev/null && [[ -f "$desired" ]]; then + if [[ ! -f /etc/wsl.conf ]] || ! cmp -s "$desired" /etc/wsl.conf; then + sudo install -m 0644 "$desired" /etc/wsl.conf + fi fi } -function setup_default_shell() { - - current_shell=$(getent passwd "$USER" | cut -d: -f7) - - if [ "$current_shell" != "$(which zsh)" ]; then - chsh -s "$(which zsh)" - echo -e "${GREEN}Default shell changed to zsh.${RESET}" +install_with_apt() { + local package=$1 + refresh_package_index + if dpkg -s "$package" >/dev/null 2>&1 && ! updates_enabled; then + printf '%b[Skipping]%b %s is already installed%b\n' "$YELLOW" "$LIGHT" "$package" "$RESET" else - echo -e "${YELLOW}zsh is already the default shell for $USER. No changes made.${RESET}" + sudo apt-get install --yes "$package" fi } -function configure_git () { - [ ! -e ~/.gitconfig.local ] && touch ~/.gitconfig.local - git submodule sync --quiet --recursive - git submodule update --init --recursive - echo -e "${GREEN}Git successfully configured!${RESET}" -} - -function configure_wsl() { - if grep -qi microsoft /proc/version && [[ ! -e /etc/wsl.conf ]]; then - sudo cp "${DOTFILES_DIR}/config/wsl/wsl.conf" /etc/wsl.conf - echo -e "${GREEN}wsl.conf configured successfully!${RESET}" - fi -} - -function install_mise_en_place () { - local mise_arch=$arch - case "${mise_arch}" in - aarch64) mise_arch="arm64" ;; - esac - - if apt-cache show mise &>/dev/null; then - sudo apt install -y mise +install_with_pacman() { + local package=$1 + refresh_package_index + if pacman -Q "$package" >/dev/null 2>&1 && ! updates_enabled; then + printf '%b[Skipping]%b %s is already installed%b\n' "$YELLOW" "$LIGHT" "$package" "$RESET" else - sudo apt install -y curl - sudo install -dm 755 /etc/apt/keyrings - curl -fSs https://mise.jdx.dev/gpg-key.pub | sudo tee /etc/apt/keyrings/mise-archive-keyring.pub 1> /dev/null - echo "deb [signed-by=/etc/apt/keyrings/mise-archive-keyring.pub arch=$mise_arch] https://mise.jdx.dev/deb stable main" | sudo tee /etc/apt/sources.list.d/mise.list - sudo apt update -y - sudo apt install -y mise + sudo pacman -S --needed --noconfirm "$package" fi } -function install_docker () { - curl -fsSL https://get.docker.com -o get-docker.sh - sudo sh ./get-docker.sh - rm get-docker.sh - sudo usermod -aG docker $USER +install_mise_en_place() { + local mise_arch="$arch" + [[ "$mise_arch" == "aarch64" ]] && mise_arch="arm64" + if apt-cache show mise >/dev/null 2>&1; then + sudo apt-get install --yes mise + return + fi + sudo install -dm 755 /etc/apt/keyrings + curl -fSs https://mise.jdx.dev/gpg-key.pub | sudo tee /etc/apt/keyrings/mise-archive-keyring.pub >/dev/null + echo "deb [signed-by=/etc/apt/keyrings/mise-archive-keyring.pub arch=$mise_arch] https://mise.jdx.dev/deb stable main" | sudo tee /etc/apt/sources.list.d/mise.list >/dev/null + sudo apt-get update + sudo apt-get install --yes mise } -function install_dagger () { - curl -fsSL https://dl.dagger.io/dagger/install.sh | BIN_DIR=$HOME/.local/bin sh +install_docker() { + local script + script="$(mktemp)" + curl -fsSL https://get.docker.com -o "$script" + sudo sh "$script" + rm -f "$script" + sudo usermod -aG docker "${USER:-$(id -un)}" } +install_dagger() { curl -fsSL https://dl.dagger.io/dagger/install.sh | BIN_DIR="$HOME/.local/bin" sh; } -function install_optional_packages () { +install_optional_packages() { + if noninteractive_enabled; then + printf '%bSkipping optional package selection in non-interactive mode.%b\n' "$YELLOW" "$RESET" + return + fi local packages=( "mise-en-place|deb:check_package_or_run mise install_mise_en_place|arch:install_with_pacman mise" "docker|deb:check_package_or_run docker install_docker|arch:install_with_pacman docker" "dagger|deb:check_package_or_run dagger install_dagger|arch:install_with_pacman dagger" ) - - echo -e "\n${CYAN}Choose optional packages to install:${RESET}" + printf '\n%bChoose optional packages to install:%b\n' "$CYAN" "$RESET" + local i for i in "${!packages[@]}"; do - IFS='|' read -ra pkg_info <<< "${packages[i]}" - pkg_name="${pkg_info[0]}" - echo -e "${CYAN}$((i+1)). ${pkg_name}${RESET}" + IFS='|' read -r -a pkg_info <<< "${packages[i]}" + printf '%b%d. %s%b\n' "$CYAN" "$((i + 1))" "${pkg_info[0]}" "$RESET" done - echo -e "${CYAN}You can use ranges like 1-4 or individual numbers separated by commas:${RESET}" - read -p "Enter your choices (or press Enter to skip): " user_input - if [ -z "$user_input" ]; then - echo -e "${YELLOW}No optional packages selected for installation.${RESET}" - return - fi - # Parse user input to get selected indices and trim spaces - IFS=',' read -ra selections <<< "$(echo $user_input | tr -d ' ')" - selected_indices=() + read -r -p "Enter choices (e.g. 1-3,5) or press Enter to skip: " user_input + [[ -n "$user_input" ]] || return + local -a selected_indices=() + local sel start end + IFS=',' read -r -a selections <<< "${user_input// /}" for sel in "${selections[@]}"; do - if [[ $sel =~ ^[0-9]+-[0-9]+$ ]]; then - IFS='-' read -ra range <<< "$sel" - start=${range[0]} - end=${range[1]} - for ((i=start; i<=end; i++)); do - if (( i >= 1 && i <= ${#packages[@]} )); then - selected_indices+=("$i") - fi - done - - elif [[ $sel =~ ^[0-9]+$ ]]; then - if (( sel >= 1 && sel <= ${#packages[@]} )); then - selected_indices+=("$sel") - fi + if [[ "$sel" =~ ^[0-9]+-[0-9]+$ ]]; then + IFS='-' read -r start end <<< "$sel" + for ((i=start; i<=end; i++)); do (( i >= 1 && i <= ${#packages[@]} )) && selected_indices+=("$i"); done + elif [[ "$sel" =~ ^[0-9]+$ ]] && (( sel >= 1 && sel <= ${#packages[@]} )); then + selected_indices+=("$sel") fi done - # Remove duplicates - IFS=$'\n' selected_indices=($(sort -u <<<"${selected_indices[*]}")) - unset IFS - # If no valid selections, exit - if [ ${#selected_indices[@]} -eq 0 ]; then - echo -e "${YELLOW}No valid optional packages selected for installation.${RESET}" - return - fi - # Install selected packages + (( ${#selected_indices[@]} > 0 )) || { printf '%bNo valid selection.%b\n' "$YELLOW" "$RESET"; return; } + mapfile -t selected_indices < <(printf '%s\n' "${selected_indices[@]}" | sort -nu) + local index idx deb_func arch_func for index in "${selected_indices[@]}"; do - idx=$((index-1)) - IFS='|' read -ra pkg_info <<< "${packages[idx]}" - pkg_name="${pkg_info[0]}" + idx=$((index - 1)) + IFS='|' read -r -a pkg_info <<< "${packages[idx]}" deb_func="${pkg_info[1]#deb:}" arch_func="${pkg_info[2]#arch:}" - echo -e "${CYAN}[Installing]${LIGHT} ${pkg_name}...${RESET}" - if [ -f "/etc/debian_version" ]; then - $deb_func - elif [ -f "/etc/arch-release" ]; then - $arch_func - fi + case "$DISTRO" in debian) eval "$deb_func" ;; arch) eval "$arch_func" ;; esac done } - parse_args "$@" pre_setup_tasks configure_git -configure_wsl install_must_have_packages -setup_dot_files setup_sheldon_plugins -setup_default_shell + +if noninteractive_enabled; then + printf '%bSkipping shell and WSL customization in non-interactive mode.%b\n' "$YELLOW" "$RESET" +else + setup_default_shell + configure_wsl +fi + install_optional_packages diff --git a/lets-go.ps1 b/lets-go.ps1 deleted file mode 100644 index 8ddc189..0000000 --- a/lets-go.ps1 +++ /dev/null @@ -1,80 +0,0 @@ -function RefreshPath() { - $env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path", "User") -} - -function EnsureDevModeIsEnabled() { - try { - if ((Get-WindowsDeveloperLicense).IsValid) { - Write-Host "Developer Mode is Enabled" -ForegroundColor Green - } - else { - Write-Host "Please enable the Developer Mode and RESTART!!! before continue" -ForegroundColor Red - exit - } - } - catch { - Write-Host "An error occurred while checking the developer license: $_" -ForegroundColor Red - exit - } -} - -function InstallWithWinget() { - param( - [string]$appId, - [string]$alias, - [string]$customArgs = "" - ) - - if (-not ([string]::IsNullOrEmpty($alias))) { - Get-Command -Name $alias -ErrorAction SilentlyContinue | Out-Null - } - else { - winget list --accept-source-agreements --id $appId -n 1 | Out-Null - } - - if (-not $?) { - Write-Host "$appId is not installed. Installing..." -ForegroundColor Yellow - $wingetArgs = @('-e', '--accept-source-agreements', '--accept-package-agreements', '--id', $appId) - if (-not [string]::IsNullOrWhiteSpace($customArgs)) { - $wingetArgs += @('--custom', $customArgs) - } - winget install @wingetArgs - } -} -# Ensure Dev Mode is Enabled -EnsureDevModeIsEnabled - -# Check winget and activate -if ($null -eq (Get-Command -Name winget -ErrorAction SilentlyContinue)) { - Write-Output "Enable winget..." - Add-AppxPackage -RegisterByFamilyName -MainPackage Microsoft.DesktopAppInstaller_8wekyb3d8bbwe - RefreshPath -} - -# List apps to install -Write-Host "Installing must-have apps..." -ForegroundColor Cyan -$installs = @( - $(InstallWithWinget -appId "Git.Git" -alias "git" -customArgs '/Components="gitlfs,assoc,windowsterminal" /o:SSHOption=ExternalOpenSSH /o:CurlOption=WinSSL /o:CRLFOption=CRLFCommitAsIs'), - $(InstallWithWinget -appId "Microsoft.PowerShell") -) - -# For each app, check if not present and install -foreach ($install in $installs) { - $install -} - -# If not already set, specify dotfiles destination directory and source repo -if (!$DOTFILES_DIR) { $DOTFILES_DIR = "$HOME\.dotfiles" } -if (!$DOTFILES_REPO) { $DOTFILES_REPO = "https://github.com/jsilverdev/dotfiles.git" } - -# Reload PATH -RefreshPath - -if (-not (Test-Path -Path $DOTFILES_DIR -PathType Container)) { - New-Item -ItemType Directory -Path "$DOTFILES_DIR" -Force - git clone --recursive "$DOTFILES_REPO" "$DOTFILES_DIR" -} - -Set-Location -Path $DOTFILES_DIR -$installScript = Join-Path $DOTFILES_DIR "install.ps1" -& pwsh.exe -F $installScript diff --git a/lets-go.sh b/lets-go.sh deleted file mode 100644 index dd7a960..0000000 --- a/lets-go.sh +++ /dev/null @@ -1,51 +0,0 @@ -#!/usr/bin/bash - -core_packages=( - 'git' - 'zsh' - 'wget' -) - -function install_debian () { - echo -e "${PURPLE}Installing ${1} via apt-get${RESET}" - sudo apt install $1 --assume-yes -} -function install_arch () { - echo -e "${PURPLE}Installing ${1} via Pacman${RESET}" - sudo pacman -S $1 --needed --noconfirm -} - -function multi_system_install () { - app=$1 - if [ -f "/etc/arch-release" ] && hash pacman 2> /dev/null; then - install_arch $app # Arch Linux via Pacman - elif [ -f "/etc/debian_version" ] && hash apt 2> /dev/null; then - install_debian $app # Debian via apt-get - else - echo -e "${YELLOW}Skipping ${app}, as couldn't detect system type ${RESET}" - fi -} - -# If not already set, specify dotfiles destination directory and source repo -DOTFILES_DIR="${DOTFILES_DIR:-$HOME/.dotfiles}" -DOTFILES_REPO="${DOTFILES_REPO:-https://github.com/jsilverdev/dotfiles.git}" - -# For each app, check if not present and install -for app in ${core_packages[@]}; do - if ! hash "${app}" 2> /dev/null; then - multi_system_install $app - else - echo -e "${YELLOW}${app} is already installed, skipping${RESET}" - fi -done - -# If dotfiles not yet present then clone -if [[ ! -d "$DOTFILES_DIR" ]]; then - mkdir -p "${DOTFILES_DIR}" && \ - git clone --recursive ${DOTFILES_REPO} ${DOTFILES_DIR} -fi - -# Execute setup or update script -cd "${DOTFILES_DIR}" && \ -chmod +x ./install.sh && \ -./install.sh diff --git a/lib/dotbot b/lib/dotbot deleted file mode 160000 index 67aeaf7..0000000 --- a/lib/dotbot +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 67aeaf75401e21f8b6085c1f2cecd472861d081d diff --git a/meta/base.yaml b/meta/base.yaml deleted file mode 100644 index 55ce67a..0000000 --- a/meta/base.yaml +++ /dev/null @@ -1,41 +0,0 @@ -- defaults: - link: - create: true - relink: true - -- create: - ~/.ssh: - mode: 0700 - -- clean: ["~"] - -- clean: - ~/.config: - recursive: true - -- link: - # starship - ~/.config/starship/: - glob: true - path: config/starship/* - force: true - create: true - # General config - ~/: - glob: true - path: config/general/.* - relink: true - exclude: ["config/general/.gitignore*"] - ~/.gitconfig: - path: config/general/.gitconfig - force: true - create: true - ~/.fdignore: - path: config/general/.gitignore_global - force: true - create: true - ~/.ssh/: - glob: true - path: config/ssh/* - force: true - create: true diff --git a/meta/configs/codex.yaml b/meta/configs/codex.yaml deleted file mode 100644 index 1d1c7b9..0000000 --- a/meta/configs/codex.yaml +++ /dev/null @@ -1,10 +0,0 @@ -- link: - ~/.codex/AGENTS.md: - path: config/codex/AGENTS.md - force: true - create: true - ~/.codex/skills/: - glob: true - path: config/codex/skills/** - force: true - create: true diff --git a/meta/configs/pwsh.yaml b/meta/configs/pwsh.yaml deleted file mode 100644 index 7eaeeea..0000000 --- a/meta/configs/pwsh.yaml +++ /dev/null @@ -1,12 +0,0 @@ -- link: - # pwsh - ~/.config/pwsh/: - glob: true - path: config/pwsh/** - force: true - create: true - exclude: ["config/pwsh/profile.ps1"] - ${PROFILE_LOCATION}: - path: config/pwsh/profile.ps1 - force: true - create: true diff --git a/meta/configs/windows.yaml b/meta/configs/windows.yaml deleted file mode 100644 index a70fef5..0000000 --- a/meta/configs/windows.yaml +++ /dev/null @@ -1,5 +0,0 @@ -- link: - ~/.wslconfig: - path: config/wsl/.wslconfig - force: true - create: true diff --git a/meta/configs/zsh.yaml b/meta/configs/zsh.yaml deleted file mode 100644 index a9e0216..0000000 --- a/meta/configs/zsh.yaml +++ /dev/null @@ -1,20 +0,0 @@ -- link: - # zsh - ~/.config/zsh/: - path: config/zsh/** - glob: true - force: true - create: true - exclude: ["config/general/.zshrc", "config/general/.zshenv"] - ~/.config/zsh/.zshrc: - path: config/zsh/.zshrc - force: true - create: true - ~/.config/sheldon/plugins.toml: - path: config/sheldon/plugins.toml - force: true - create: true - ~/.zshenv: - path: config/zsh/.zshenv - force: true - create: true diff --git a/scripts/linux/packages-arch.txt b/scripts/linux/packages-arch.txt new file mode 100644 index 0000000..6c8511c --- /dev/null +++ b/scripts/linux/packages-arch.txt @@ -0,0 +1,25 @@ +# Packages installed through pacman. yay remains managed separately from AUR. +git +curl +zsh +micro +fastfetch +tree +jq +lsd +fd +fzf +vivid +git-delta +starship +sheldon +bat +python +ufw +rsync +zip +unzip +less +socat +binutils +ripgrep diff --git a/scripts/linux/packages-debian.txt b/scripts/linux/packages-debian.txt new file mode 100644 index 0000000..b94e988 --- /dev/null +++ b/scripts/linux/packages-debian.txt @@ -0,0 +1,18 @@ +# Packages installed through APT. Release-fallback tools are handled by install.sh. +git +curl +zsh +micro +jq +tree +python3 +ufw +rsync +zip +unzip +less +socat +bat +fd-find +binutils +ripgrep diff --git a/scripts/linux/required-commands.txt b/scripts/linux/required-commands.txt new file mode 100644 index 0000000..8b36c35 --- /dev/null +++ b/scripts/linux/required-commands.txt @@ -0,0 +1,19 @@ +# Each line is a logical requirement. Alternatives are separated by |. +git +curl +zsh +micro +jq +tree +python3|python +bat|batcat +fd|fdfind +strings +rg +fastfetch +lsd +fzf +vivid +delta +starship +sheldon diff --git a/scripts/windows/deploy-pwsh.ps1 b/scripts/windows/deploy-pwsh.ps1 new file mode 100644 index 0000000..8e0a406 --- /dev/null +++ b/scripts/windows/deploy-pwsh.ps1 @@ -0,0 +1,39 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string]$RepoRoot +) + +$ErrorActionPreference = "Stop" + +$repo = (Resolve-Path -LiteralPath $RepoRoot).Path +$sourceRoot = Join-Path $repo "home\.chezmoitemplates\pwsh" +$destinationRoot = Join-Path $HOME ".config\pwsh" +$files = @( + @{ Source = "env.ps1"; Destination = (Join-Path $destinationRoot "env.ps1") }, + @{ Source = "lib\helpers.ps1"; Destination = (Join-Path $destinationRoot "lib\helpers.ps1") }, + @{ Source = "lib\aliases.ps1"; Destination = (Join-Path $destinationRoot "lib\aliases.ps1") }, + @{ Source = "profile.ps1"; Destination = $PROFILE.CurrentUserAllHosts } +) + +foreach ($file in $files) { + $source = Join-Path $sourceRoot $file.Source + if (-not (Test-Path -LiteralPath $source -PathType Leaf)) { + throw "PowerShell source file not found: $source" + } + + New-Item -ItemType Directory -Path (Split-Path -Parent $file.Destination) -Force | Out-Null + + Copy-Item -LiteralPath $source -Destination $file.Destination -Force +} + +if ($env:DOTFILES_SIGNING_REQUIRED -eq "1" -or (Get-ExecutionPolicy) -eq "AllSigned") { + $helper = Join-Path $repo "scripts\windows\signing.ps1" + $bridge = Join-Path $repo "scripts\windows\invoke-ps-script.cmd" + foreach ($destination in @($files | ForEach-Object Destination)) { + & $bridge $helper -Action ProtectFiles -Path $destination + if ($LASTEXITCODE -ne 0) { + throw "PowerShell runtime signing failed for $destination with exit code $LASTEXITCODE." + } + } +} diff --git a/scripts/windows/invoke-ps-script-bridge.ps1 b/scripts/windows/invoke-ps-script-bridge.ps1 new file mode 100644 index 0000000..1ed1654 --- /dev/null +++ b/scripts/windows/invoke-ps-script-bridge.ps1 @@ -0,0 +1,31 @@ +$ErrorActionPreference='Stop' +$s=$env:DOTFILES_PS_SCRIPT +$a=@() +for($i=1;$i-le[int]$env:DOTFILES_PS_ARGC;$i++){$a+=[Environment]::GetEnvironmentVariable("DOTFILES_PS_ARG$i")} +$t=$null +$f=$false +try{ + if(!(Test-Path -LiteralPath $s -PathType Leaf)){throw "PowerShell script not found: $s"} + $pw=(Get-Command pwsh.exe -ErrorAction Stop).Source + $ps=Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' + if(!(Test-Path -LiteralPath $ps -PathType Leaf)){throw "Windows PowerShell not found: $ps"} + $h=Join-Path $env:DOTFILES_PS_BRIDGE_DIR 'signing.ps1' + if(!(Test-Path -LiteralPath $h -PathType Leaf)){throw "Signing helper not found: $h"} + $env:DOTFILES_SIGNING_REQUIRED='1' + if([IO.Path]::GetFileName($s)-ieq 'signing.ps1'){ + & $ps -NoProfile -File $h @a + if($LASTEXITCODE-ne0){$f=$true} + }else{ + $t=[IO.Path]::ChangeExtension([IO.Path]::GetTempFileName(),'.ps1') + Copy-Item -LiteralPath $s -Destination $t -Force + & $ps -NoProfile -File $h -Action ProtectFiles -Path $t + if($LASTEXITCODE-ne0){throw "Signing helper failed with exit code $LASTEXITCODE"} + & $pw -NoProfile -File $t @a + if($LASTEXITCODE-ne0){$f=$true} + } +}catch{Write-Error $_;$f=$true} +finally{ + if($t){Remove-Item -LiteralPath $t -Force -ErrorAction SilentlyContinue} + Remove-Item Env:DOTFILES_SIGNING_REQUIRED -ErrorAction SilentlyContinue +} +if($f){exit 1} diff --git a/scripts/windows/invoke-ps-script.cmd b/scripts/windows/invoke-ps-script.cmd new file mode 100644 index 0000000..60e1923 --- /dev/null +++ b/scripts/windows/invoke-ps-script.cmd @@ -0,0 +1,104 @@ +@echo off +setlocal EnableExtensions DisableDelayedExpansion + +if "%~1"=="" goto usage + +rem Marshal arguments for the AllSigned signer bridge. +set "DOTFILES_PS_SCRIPT=%~1" +set "DOTFILES_PS_BRIDGE_DIR=%~dp0" +set "DOTFILES_PS_ARG1=%~2" +set "DOTFILES_PS_ARG2=%~3" +set "DOTFILES_PS_ARG3=%~4" +set "DOTFILES_PS_ARG4=%~5" +set "DOTFILES_PS_ARG5=%~6" +set "DOTFILES_PS_ARG6=%~7" +set "DOTFILES_PS_ARG7=%~8" +set "DOTFILES_PS_ARG8=%~9" +set "DOTFILES_PS_ARGC=0" +if defined DOTFILES_PS_ARG1 set "DOTFILES_PS_ARGC=1" +if defined DOTFILES_PS_ARG2 set "DOTFILES_PS_ARGC=2" +if defined DOTFILES_PS_ARG3 set "DOTFILES_PS_ARGC=3" +if defined DOTFILES_PS_ARG4 set "DOTFILES_PS_ARGC=4" +if defined DOTFILES_PS_ARG5 set "DOTFILES_PS_ARGC=5" +if defined DOTFILES_PS_ARG6 set "DOTFILES_PS_ARGC=6" +if defined DOTFILES_PS_ARG7 set "DOTFILES_PS_ARGC=7" +if defined DOTFILES_PS_ARG8 set "DOTFILES_PS_ARGC=8" + +where pwsh.exe >nul 2>&1 +if errorlevel 1 goto missing_pwsh + +rem Probe PowerShell 7 with a harmless unsigned script. If it runs, execute the +rem target directly and avoid certificate/signing work entirely. +set "DOTFILES_PS_PROBE=%TEMP%\dotfiles-pwsh-probe-%RANDOM%-%RANDOM%.ps1" +> "%DOTFILES_PS_PROBE%" echo exit 0 +pwsh.exe -NoProfile -File "%DOTFILES_PS_PROBE%" >nul 2>&1 +set "DOTFILES_PS_PROBE_EXIT=%ERRORLEVEL%" +del /q "%DOTFILES_PS_PROBE%" >nul 2>&1 +if "%DOTFILES_PS_PROBE_EXIT%"=="0" goto run_direct + +if not exist "%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe" goto missing_windows_powershell +rem A nested bridge can inherit PowerShell 7's PSModulePath. Restore the inbox +rem Windows PowerShell module roots before starting the signing host. +set "PSModulePath=%USERPROFILE%\Documents\WindowsPowerShell\Modules;%ProgramFiles%\WindowsPowerShell\Modules;%SystemRoot%\System32\WindowsPowerShell\v1.0\Modules" +"%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -EncodedCommand JABFAHIAcgBvAHIAQQBjAHQAaQBvAG4AUAByAGUAZgBlAHIAZQBuAGMAZQA9ACcAUwB0AG8AcAAnAAoAJABzAD0AJABlAG4AdgA6AEQATwBUAEYASQBMAEUAUwBfAFAAUwBfAFMAQwBSAEkAUABUAAoAJABhAD0AQAAoACkACgBmAG8AcgAoACQAaQA9ADEAOwAkAGkALQBsAGUAWwBpAG4AdABdACQAZQBuAHYAOgBEAE8AVABGAEkATABFAFMAXwBQAFMAXwBBAFIARwBDADsAJABpACsAKwApAHsAJABhACsAPQBbAEUAbgB2AGkAcgBvAG4AbQBlAG4AdABdADoAOgBHAGUAdABFAG4AdgBpAHIAbwBuAG0AZQBuAHQAVgBhAHIAaQBhAGIAbABlACgAIgBEAE8AVABGAEkATABFAFMAXwBQAFMAXwBBAFIARwAkAGkAIgApAH0ACgAkAHQAPQAkAG4AdQBsAGwACgAkAGYAPQAkAGYAYQBsAHMAZQAKAHQAcgB5AHsACgAgAGkAZgAoACEAKABUAGUAcwB0AC0AUABhAHQAaAAgAC0ATABpAHQAZQByAGEAbABQAGEAdABoACAAJABzACAALQBQAGEAdABoAFQAeQBwAGUAIABMAGUAYQBmACkAKQB7AHQAaAByAG8AdwAgACIAUABvAHcAZQByAFMAaABlAGwAbAAgAHMAYwByAGkAcAB0ACAAbgBvAHQAIABmAG8AdQBuAGQAOgAgACQAcwAiAH0ACgAgACQAcAB3AD0AKABHAGUAdAAtAEMAbwBtAG0AYQBuAGQAIABwAHcAcwBoAC4AZQB4AGUAIAAtAEUAcgByAG8AcgBBAGMAdABpAG8AbgAgAFMAdABvAHAAKQAuAFMAbwB1AHIAYwBlAAoAIAAkAHAAcwA9AEoAbwBpAG4ALQBQAGEAdABoACAAJABlAG4AdgA6AFMAeQBzAHQAZQBtAFIAbwBvAHQAIAAnAFMAeQBzAHQAZQBtADMAMgBcAFcAaQBuAGQAbwB3AHMAUABvAHcAZQByAFMAaABlAGwAbABcAHYAMQAuADAAXABwAG8AdwBlAHIAcwBoAGUAbABsAC4AZQB4AGUAJwAKACAAaQBmACgAIQAoAFQAZQBzAHQALQBQAGEAdABoACAALQBMAGkAdABlAHIAYQBsAFAAYQB0AGgAIAAkAHAAcwAgAC0AUABhAHQAaABUAHkAcABlACAATABlAGEAZgApACkAewB0AGgAcgBvAHcAIAAiAFcAaQBuAGQAbwB3AHMAIABQAG8AdwBlAHIAUwBoAGUAbABsACAAbgBvAHQAIABmAG8AdQBuAGQAOgAgACQAcABzACIAfQAKACAAJABoAD0ASgBvAGkAbgAtAFAAYQB0AGgAIAAkAGUAbgB2ADoARABPAFQARgBJAEwARQBTAF8AUABTAF8AQgBSAEkARABHAEUAXwBEAEkAUgAgACcAcwBpAGcAbgBpAG4AZwAuAHAAcwAxACcACgAgAGkAZgAoACEAKABUAGUAcwB0AC0AUABhAHQAaAAgAC0ATABpAHQAZQByAGEAbABQAGEAdABoACAAJABoACAALQBQAGEAdABoAFQAeQBwAGUAIABMAGUAYQBmACkAKQB7AHQAaAByAG8AdwAgACIAUwBpAGcAbgBpAG4AZwAgAGgAZQBsAHAAZQByACAAbgBvAHQAIABmAG8AdQBuAGQAOgAgACQAaAAiAH0ACgAgACQAZQBuAHYAOgBEAE8AVABGAEkATABFAFMAXwBTAEkARwBOAEkATgBHAF8AUgBFAFEAVQBJAFIARQBEAD0AJwAxACcACgAgAGkAZgAoAFsASQBPAC4AUABhAHQAaABdADoAOgBHAGUAdABGAGkAbABlAE4AYQBtAGUAKAAkAHMAKQAtAGkAZQBxACAAJwBzAGkAZwBuAGkAbgBnAC4AcABzADEAJwApAHsACgAgACAAJgAgACQAcABzACAALQBOAG8AUAByAG8AZgBpAGwAZQAgAC0ARgBpAGwAZQAgACQAaAAgAEAAYQAKACAAIABpAGYAKAAkAEwAQQBTAFQARQBYAEkAVABDAE8ARABFAC0AbgBlADAAKQB7ACQAZgA9ACQAdAByAHUAZQB9AAoAIAB9AGUAbABzAGUAewAKACAAIAAkAHQAPQBbAEkATwAuAFAAYQB0AGgAXQA6ADoAQwBoAGEAbgBnAGUARQB4AHQAZQBuAHMAaQBvAG4AKABbAEkATwAuAFAAYQB0AGgAXQA6ADoARwBlAHQAVABlAG0AcABGAGkAbABlAE4AYQBtAGUAKAApACwAJwAuAHAAcwAxACcAKQAKACAAIABDAG8AcAB5AC0ASQB0AGUAbQAgAC0ATABpAHQAZQByAGEAbABQAGEAdABoACAAJABzACAALQBEAGUAcwB0AGkAbgBhAHQAaQBvAG4AIAAkAHQAIAAtAEYAbwByAGMAZQAKACAAIAAmACAAJABwAHMAIAAtAE4AbwBQAHIAbwBmAGkAbABlACAALQBGAGkAbABlACAAJABoACAALQBBAGMAdABpAG8AbgAgAFAAcgBvAHQAZQBjAHQARgBpAGwAZQBzACAALQBQAGEAdABoACAAJAB0AAoAIAAgAGkAZgAoACQATABBAFMAVABFAFgASQBUAEMATwBEAEUALQBuAGUAMAApAHsAdABoAHIAbwB3ACAAIgBTAGkAZwBuAGkAbgBnACAAaABlAGwAcABlAHIAIABmAGEAaQBsAGUAZAAgAHcAaQB0AGgAIABlAHgAaQB0ACAAYwBvAGQAZQAgACQATABBAFMAVABFAFgASQBUAEMATwBEAEUAIgB9AAoAIAAgACYAIAAkAHAAdwAgAC0ATgBvAFAAcgBvAGYAaQBsAGUAIAAtAEYAaQBsAGUAIAAkAHQAIABAAGEACgAgACAAaQBmACgAJABMAEEAUwBUAEUAWABJAFQAQwBPAEQARQAtAG4AZQAwACkAewAkAGYAPQAkAHQAcgB1AGUAfQAKACAAfQAKAH0AYwBhAHQAYwBoAHsAVwByAGkAdABlAC0ARQByAHIAbwByACAAJABfADsAJABmAD0AJAB0AHIAdQBlAH0ACgBmAGkAbgBhAGwAbAB5AHsACgAgAGkAZgAoACQAdAApAHsAUgBlAG0AbwB2AGUALQBJAHQAZQBtACAALQBMAGkAdABlAHIAYQBsAFAAYQB0AGgAIAAkAHQAIAAtAEYAbwByAGMAZQAgAC0ARQByAHIAbwByAEEAYwB0AGkAbwBuACAAUwBpAGwAZQBuAHQAbAB5AEMAbwBuAHQAaQBuAHUAZQB9AAoAIABSAGUAbQBvAHYAZQAtAEkAdABlAG0AIABFAG4AdgA6AEQATwBUAEYASQBMAEUAUwBfAFMASQBHAE4ASQBOAEcAXwBSAEUAUQBVAEkAUgBFAEQAIAAtAEUAcgByAG8AcgBBAGMAdABpAG8AbgAgAFMAaQBsAGUAbgB0AGwAeQBDAG8AbgB0AGkAbgB1AGUACgB9AAoAaQBmACgAJABmACkAewBlAHgAaQB0ACAAMQB9AAoA +exit /b %ERRORLEVEL% + +:run_direct +if "%DOTFILES_PS_ARGC%"=="0" goto run_0 +if "%DOTFILES_PS_ARGC%"=="1" goto run_1 +if "%DOTFILES_PS_ARGC%"=="2" goto run_2 +if "%DOTFILES_PS_ARGC%"=="3" goto run_3 +if "%DOTFILES_PS_ARGC%"=="4" goto run_4 +if "%DOTFILES_PS_ARGC%"=="5" goto run_5 +if "%DOTFILES_PS_ARGC%"=="6" goto run_6 +if "%DOTFILES_PS_ARGC%"=="7" goto run_7 +if "%DOTFILES_PS_ARGC%"=="8" goto run_8 +exit /b 2 + +:run_0 +pwsh.exe -NoProfile -File "%DOTFILES_PS_SCRIPT%" +exit /b %ERRORLEVEL% + +:run_1 +pwsh.exe -NoProfile -File "%DOTFILES_PS_SCRIPT%" "%DOTFILES_PS_ARG1%" +exit /b %ERRORLEVEL% + +:run_2 +pwsh.exe -NoProfile -File "%DOTFILES_PS_SCRIPT%" "%DOTFILES_PS_ARG1%" "%DOTFILES_PS_ARG2%" +exit /b %ERRORLEVEL% + +:run_3 +pwsh.exe -NoProfile -File "%DOTFILES_PS_SCRIPT%" "%DOTFILES_PS_ARG1%" "%DOTFILES_PS_ARG2%" "%DOTFILES_PS_ARG3%" +exit /b %ERRORLEVEL% + +:run_4 +pwsh.exe -NoProfile -File "%DOTFILES_PS_SCRIPT%" "%DOTFILES_PS_ARG1%" "%DOTFILES_PS_ARG2%" "%DOTFILES_PS_ARG3%" "%DOTFILES_PS_ARG4%" +exit /b %ERRORLEVEL% + +:run_5 +pwsh.exe -NoProfile -File "%DOTFILES_PS_SCRIPT%" "%DOTFILES_PS_ARG1%" "%DOTFILES_PS_ARG2%" "%DOTFILES_PS_ARG3%" "%DOTFILES_PS_ARG4%" "%DOTFILES_PS_ARG5%" +exit /b %ERRORLEVEL% + +:run_6 +pwsh.exe -NoProfile -File "%DOTFILES_PS_SCRIPT%" "%DOTFILES_PS_ARG1%" "%DOTFILES_PS_ARG2%" "%DOTFILES_PS_ARG3%" "%DOTFILES_PS_ARG4%" "%DOTFILES_PS_ARG5%" "%DOTFILES_PS_ARG6%" +exit /b %ERRORLEVEL% + +:run_7 +pwsh.exe -NoProfile -File "%DOTFILES_PS_SCRIPT%" "%DOTFILES_PS_ARG1%" "%DOTFILES_PS_ARG2%" "%DOTFILES_PS_ARG3%" "%DOTFILES_PS_ARG4%" "%DOTFILES_PS_ARG5%" "%DOTFILES_PS_ARG6%" "%DOTFILES_PS_ARG7%" +exit /b %ERRORLEVEL% + +:run_8 +pwsh.exe -NoProfile -File "%DOTFILES_PS_SCRIPT%" "%DOTFILES_PS_ARG1%" "%DOTFILES_PS_ARG2%" "%DOTFILES_PS_ARG3%" "%DOTFILES_PS_ARG4%" "%DOTFILES_PS_ARG5%" "%DOTFILES_PS_ARG6%" "%DOTFILES_PS_ARG7%" "%DOTFILES_PS_ARG8%" +exit /b %ERRORLEVEL% + +:usage +echo Usage: %~nx0 script.ps1 [arguments...] 1>&2 +exit /b 2 + +:missing_pwsh +echo PowerShell 7 (pwsh.exe) is required. 1>&2 +exit /b 1 + +:missing_windows_powershell +echo Windows PowerShell is required to bootstrap Authenticode signing under AllSigned. 1>&2 +exit /b 1 diff --git a/scripts/windows/managed-apps.csv b/scripts/windows/managed-apps.csv new file mode 100644 index 0000000..ab65f7b --- /dev/null +++ b/scripts/windows/managed-apps.csv @@ -0,0 +1,25 @@ +Category,Name,AppId,Alias,Scope +core,micro,zyedidia.micro,micro,user +core,lsd,lsd-rs.lsd,lsd,user +core,bat,sharkdp.bat,bat,user +core,fastfetch,Fastfetch-cli.Fastfetch,fastfetch,user +core,fzf,junegunn.fzf,fzf,user +core,fd,sharkdp.fd,fd,user +core,delta,dandavison.delta,delta,user +core,jq,jqlang.jq,jq,user +core,ripgrep,BurntSushi.ripgrep.MSVC,rg,user +core,mise,jdx.mise,mise,user +workstation,7-Zip,7zip.7zip,, +workstation,PowerToys,Microsoft.PowerToys,, +workstation,Visual Studio Code,Microsoft.VisualStudioCode,code,user +optional,Google Chrome,Google.Chrome,, +optional,KeePassXC,KeePassXCTeam.KeePassXC,, +optional,DBeaver,dbeaver.dbeaver,, +optional,Postman,Postman.Postman,, +optional,Bruno,Bruno.Bruno,, +optional,kubectl,Kubernetes.kubectl,kubectl, +optional,GIMP,GIMP.GIMP,, +optional,Android Studio,Google.AndroidStudio,, +optional,Steam,Valve.Steam,, +optional,Discord,Discord.Discord,, +optional,npiperelay,albertony.npiperelay,npiperelay, diff --git a/scripts/windows/managed-modules.txt b/scripts/windows/managed-modules.txt new file mode 100644 index 0000000..70496f9 --- /dev/null +++ b/scripts/windows/managed-modules.txt @@ -0,0 +1,3 @@ +PSFzf +git-aliases +PSReadLine diff --git a/scripts/windows/signing.ps1 b/scripts/windows/signing.ps1 new file mode 100644 index 0000000..04fcd35 --- /dev/null +++ b/scripts/windows/signing.ps1 @@ -0,0 +1,210 @@ +[CmdletBinding()] +param( + [ValidateSet("ProtectFiles", "ProtectModule")] + [string]$Action = "ProtectFiles", + + [string[]]$Path, + + [string]$ModuleName +) + +$ErrorActionPreference = "Stop" +$certificateSubject = "CN=jsilverdev Dotfiles Code Signing" +$codeSigningOid = "1.3.6.1.5.5.7.3.3" +$enhancedKeyUsageOid = "2.5.29.37" + +function Initialize-CertificateProvider { + if ($null -ne (Get-PSDrive -Name Cert -ErrorAction SilentlyContinue)) { + return + } + + # Windows PowerShell can inherit PowerShell 7's PSModulePath when the bridge + # is invoked recursively from a pwsh process. Load the inbox security module + # by absolute path so the Cert: provider and Authenticode cmdlets are present. + $securityModule = Join-Path $PSHOME "Modules\Microsoft.PowerShell.Security\Microsoft.PowerShell.Security.psd1" + if (-not (Test-Path -LiteralPath $securityModule -PathType Leaf)) { + throw "Microsoft.PowerShell.Security was not found under PSHOME: $securityModule" + } + + Import-Module -Name $securityModule -Force -ErrorAction Stop + + if ($null -eq (Get-PSDrive -Name Cert -ErrorAction SilentlyContinue)) { + throw "The Cert: provider is unavailable after loading Microsoft.PowerShell.Security." + } +} + +function Test-CodeSigningCertificate { + param([System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate) + + if ($null -eq $Certificate -or -not $Certificate.HasPrivateKey -or $Certificate.NotAfter -le (Get-Date)) { + return $false + } + + # Read the X.509 EKU extension directly instead of relying on the + # PowerShell certificate provider's EnhancedKeyUsageList projection. + # This behaves consistently in PowerShell 7 and Windows PowerShell 5.1. + $ekuExtension = $Certificate.Extensions | + Where-Object { $_.Oid.Value -eq $enhancedKeyUsageOid } | + Select-Object -First 1 + if ($null -eq $ekuExtension) { + return $false + } + + try { + $eku = New-Object System.Security.Cryptography.X509Certificates.X509EnhancedKeyUsageExtension + $eku.CopyFrom($ekuExtension) + } + catch { + return $false + } + + return @($eku.EnhancedKeyUsages | Where-Object { $_.Value -eq $codeSigningOid }).Count -gt 0 +} + +function Get-DotfilesSigningCertificate { + $certificate = Get-ChildItem -Path Cert:\CurrentUser\My | + Where-Object { $_.Subject -eq $certificateSubject -and (Test-CodeSigningCertificate $_) } | + Sort-Object NotAfter -Descending | + Select-Object -First 1 + + if ($null -eq $certificate) { + try { + $certificate = New-SelfSignedCertificate ` + -Type CodeSigningCert ` + -Subject $certificateSubject ` + -CertStoreLocation Cert:\CurrentUser\My ` + -NotAfter (Get-Date).AddYears(10) ` + -HashAlgorithm SHA256 + } + catch { + throw "Unable to create the current-user code-signing certificate. Corporate certificate-store policy may prevent this operation. $($_.Exception.Message)" + } + } + + if (-not (Test-CodeSigningCertificate $certificate)) { + throw "The managed dotfiles certificate is missing a private key, is expired, or lacks the Code Signing EKU." + } + + $publicCertificatePath = $null + try { + $publicCertificatePath = [IO.Path]::ChangeExtension([IO.Path]::GetTempFileName(), ".cer") + Export-Certificate -Cert $certificate -FilePath $publicCertificatePath -Type CERT -Force | Out-Null + + $trustedRoot = @( + Get-ChildItem -Path Cert:\CurrentUser\Root | Where-Object Thumbprint -eq $certificate.Thumbprint + Get-ChildItem -Path Cert:\LocalMachine\Root | Where-Object Thumbprint -eq $certificate.Thumbprint + ) + if ($trustedRoot.Count -eq 0) { + & certutil.exe -user -f -addstore Root $publicCertificatePath | Out-Null + if ($LASTEXITCODE -ne 0) { throw "certutil could not trust CurrentUser\\Root." } + } + + $trustedPublisher = Get-ChildItem -Path Cert:\CurrentUser\TrustedPublisher | + Where-Object Thumbprint -eq $certificate.Thumbprint + if ($null -eq $trustedPublisher) { + & certutil.exe -user -f -addstore TrustedPublisher $publicCertificatePath | Out-Null + if ($LASTEXITCODE -ne 0) { throw "certutil could not trust CurrentUser\\TrustedPublisher." } + } + } + catch { + throw "Unable to trust the current-user dotfiles certificate in Root and TrustedPublisher. Corporate certificate-store policy may prevent this operation. $($_.Exception.Message)" + } + finally { + if ($publicCertificatePath) { + Remove-Item -LiteralPath $publicCertificatePath -Force -ErrorAction SilentlyContinue + } + } + + return $certificate +} + +function Protect-PowerShellFile { + param( + [Parameter(Mandatory)] + [string]$FilePath, + + [Parameter(Mandatory)] + [System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate + ) + + if (-not (Test-Path -LiteralPath $FilePath -PathType Leaf)) { + throw "PowerShell file not found: $FilePath" + } + + $signature = Get-AuthenticodeSignature -FilePath $FilePath + if ( + $signature.Status -eq "Valid" -and + $null -ne $signature.SignerCertificate -and + $signature.SignerCertificate.Thumbprint -eq $Certificate.Thumbprint + ) { + return + } + + # A module can already carry a cryptographically valid vendor signature + # whose publisher is not trusted by a non-interactive AllSigned session. + # Re-sign managed files with the dotfiles certificate so every executable + # PowerShell asset has the same explicitly trusted publisher. + Set-AuthenticodeSignature -FilePath $FilePath -Certificate $Certificate -HashAlgorithm SHA256 | Out-Null + $signature = Get-AuthenticodeSignature -FilePath $FilePath + if ($signature.Status -ne "Valid") { + throw "Authenticode signature verification failed for ${FilePath}: $($signature.Status) $($signature.StatusMessage)" + } + if ( + $null -eq $signature.SignerCertificate -or + $signature.SignerCertificate.Thumbprint -ne $Certificate.Thumbprint + ) { + throw "Authenticode signer verification failed for ${FilePath}: expected $($Certificate.Thumbprint)." + } +} + +function Get-ManagedModuleFiles { + param([Parameter(Mandatory)][string]$Name) + + $userRoots = @( + (Join-Path $HOME "Documents\PowerShell\Modules"), + (Join-Path $HOME ".local\share\powershell\Modules") + ) + $moduleDirectories = @($userRoots | ForEach-Object { + $candidate = Join-Path $_ $Name + if (Test-Path -LiteralPath $candidate -PathType Container) { $candidate } + } | Select-Object -Unique) + + if ($moduleDirectories.Count -eq 0) { + throw "Managed PowerShell module '$Name' was not found in a current-user PowerShell module root." + } + + $extensions = @("*.ps1", "*.psm1", "*.psd1", "*.ps1xml", "*.cdxml", "*.xaml") + return @($moduleDirectories | ForEach-Object { + foreach ($extension in $extensions) { + Get-ChildItem -LiteralPath $_ -Filter $extension -File -Recurse -ErrorAction Stop + } + } | Select-Object -ExpandProperty FullName -Unique) +} + +# The bridge sets DOTFILES_SIGNING_REQUIRED after PowerShell 7 proves that +# unsigned scripts are blocked. Direct/manual calls still use the effective policy. +if ($env:DOTFILES_SIGNING_REQUIRED -ne "1" -and (Get-ExecutionPolicy) -ne "AllSigned") { + return +} + +Initialize-CertificateProvider +$certificate = Get-DotfilesSigningCertificate + +switch ($Action) { + "ProtectFiles" { + if ($null -eq $Path -or $Path.Count -eq 0) { + throw "ProtectFiles requires at least one -Path." + } + foreach ($file in $Path) { + Protect-PowerShellFile -FilePath $file -Certificate $certificate + } + } + "ProtectModule" { + if ([string]::IsNullOrWhiteSpace($ModuleName)) { + throw "ProtectModule requires -ModuleName." + } + foreach ($file in (Get-ManagedModuleFiles -Name $ModuleName)) { + Protect-PowerShellFile -FilePath $file -Certificate $certificate + } + } +} diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..977cd6c --- /dev/null +++ b/tests/README.md @@ -0,0 +1,9 @@ +# Test suite + +GitHub Actions intentionally contains orchestration only. Reusable fixture and assertion logic lives under this directory. + +- `static/` validates source structure, manifests, shell code, PowerShell syntax, chezmoi templates, the encoded AllSigned bridge, and the workflow with actionlint. +- `linux/` runs the real non-interactive Debian and Arch baseline, including package installation, `yay` on Arch, update behavior, and post-bootstrap assertions. +- `windows/` owns WinGet fixture setup, normal-policy assertions, AllSigned assertions, and update fixtures. + +`DOTFILES_NONINTERACTIVE=1` is the only CI execution mode. It is also a supported real-world mode: baseline dependencies are installed normally while prompts, GUI/workstation customization, shell changes, and WSL setup are skipped. diff --git a/tests/linux/assert-state.sh b/tests/linux/assert-state.sh new file mode 100644 index 0000000..c4e0c67 --- /dev/null +++ b/tests/linux/assert-state.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +set -euo pipefail + +expected_distro="${1:?expected distribution (debian or arch) is required}" +expected_commit="${2:-}" +repo_root="${GITHUB_WORKSPACE:-$(pwd)}" + +fail() { printf 'ASSERTION FAILED: %s\n' "$1" >&2; exit 1; } + +printf 'distribution: %s\n' "$(tr '\n' ' ' < /etc/os-release)" +printf 'uname: %s\n' "$(uname -a)" +printf 'user: %s (%s)\n' "$(id -un)" "$(id -u)" +printf 'HOME: %s\n' "$HOME" +printf 'chezmoi: %s\n' "$(chezmoi --version)" +printf 'chezmoi source-path: %s\n' "$(chezmoi source-path)" + +case "$expected_distro" in + debian) [[ -f /etc/debian_version ]] || fail "Debian detection marker is missing" ;; + arch) [[ -f /etc/arch-release ]] || fail "Arch detection marker is missing" ;; + *) fail "unknown expected distribution: $expected_distro" ;; +esac + +sudo -n true || fail "the CI user does not have passwordless sudo" + +required_files=( + "$HOME/.gitconfig" "$HOME/.gitconfig.local" "$HOME/.fdignore" "$HOME/.zshenv" + "$HOME/.config/zsh/.zshrc" "$HOME/.config/zsh/lib/aliases.zsh" + "$HOME/.config/zsh/lib/completions.zsh" "$HOME/.config/zsh/lib/key-bindings.zsh" + "$HOME/.config/zsh/lib/sheldon.zsh" "$HOME/.config/starship/config.toml" + "$HOME/.config/starship/lean.config.toml" "$HOME/.config/sheldon/plugins.toml" + "$HOME/.codex/AGENTS.md" "$HOME/.codex/skills/mule-munit/SKILL.md" + "$HOME/.codex/skills/mule-munit/agents/openai.yaml" +) +for path in "${required_files[@]}"; do [[ -f "$path" ]] || fail "expected deployed file is missing: $path"; done + +while IFS= read -r requirement; do + [[ -n "$requirement" && "$requirement" != \#* ]] || continue + found=false + IFS='|' read -r -a candidates <<< "$requirement" + for command_name in "${candidates[@]}"; do + if command -v "$command_name" >/dev/null 2>&1; then found=true; break; fi + done + [[ "$found" == true ]] || fail "required baseline command is unavailable: $requirement" +done < "$repo_root/scripts/linux/required-commands.txt" + +if [[ "$expected_distro" == "arch" ]]; then command -v yay >/dev/null 2>&1 || fail "yay is unavailable on Arch"; fi + +assert_clean() { + local status + status="$(chezmoi status)" + [[ -z "$status" ]] || fail "chezmoi status is not clean: $status" +} + +chezmoi apply +chezmoi apply +assert_clean + +if [[ -n "$expected_commit" ]]; then + source_path="$(chezmoi source-path)" + source_commit="$(git -C "$source_path" rev-parse HEAD)" + [[ "$source_commit" == "$expected_commit" ]] || fail "source commit $source_commit is not expected commit $expected_commit" +fi + +config_marker="ci-create-only-$(date +%s)" +rules_marker="ci-create-only-rule-$(date +%s)" +printf '\n%s\n' "$config_marker" >> "$HOME/.codex/config.toml" +printf '\n%s\n' "$rules_marker" >> "$HOME/.codex/rules/default.rules" +chezmoi apply +grep -Fqx "$config_marker" "$HOME/.codex/config.toml" || fail "chezmoi overwrote create-only config.toml" +grep -Fqx "$rules_marker" "$HOME/.codex/rules/default.rules" || fail "chezmoi overwrote create-only default.rules" +assert_clean + +source_path="$(chezmoi source-path)" +[[ -z "$(git -C "$source_path" status --porcelain)" ]] || fail "chezmoi source Git tree is dirty" +[[ -z "$(git -C "$repo_root" status --porcelain)" ]] || fail "Actions checkout Git tree is dirty" + +printf 'Linux state assertions passed for %s.\n' "$expected_distro" diff --git a/tests/linux/run-ci.sh b/tests/linux/run-ci.sh new file mode 100644 index 0000000..29e7fc3 --- /dev/null +++ b/tests/linux/run-ci.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +set -euo pipefail + +distro="${1:?distro required}" +commit="${2:?commit required}" +repo="${GITHUB_WORKSPACE:?GITHUB_WORKSPACE is required}" + +git config --global --add safe.directory "$repo" +[[ "$(git -C "$repo" rev-parse HEAD)" == "$commit" ]] + +remote_parent="$(mktemp -d)" +remote="$remote_parent/dotfiles.git" +git init --bare "$remote" +git -C "$repo" push "$remote" "$commit:refs/heads/main" +git --git-dir="$remote" symbolic-ref HEAD refs/heads/main +chmod -R a+rX "$repo" + +useradd --create-home --shell /bin/bash dotfilesci +printf 'dotfilesci ALL=(ALL) NOPASSWD:ALL\n' > /etc/sudoers.d/dotfilesci +chmod 0440 /etc/sudoers.d/dotfilesci +chown -R dotfilesci:dotfilesci "$remote_parent" + +as_ci() { + sudo -u dotfilesci -H env HOME=/home/dotfilesci USER=dotfilesci "$@" +} + +as_ci GITHUB_WORKSPACE="$repo" DOTFILES_REPO="file://$remote" DOTFILES_NONINTERACTIVE=1 bash -lc 'cd /tmp; bash "$GITHUB_WORKSPACE/bootstrap.sh"' + +as_ci PATH="/home/dotfilesci/.local/bin:$PATH" GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=safe.directory GIT_CONFIG_VALUE_0="$repo" GITHUB_WORKSPACE="$repo" D="$distro" C="$commit" bash -lc 'bash "$GITHUB_WORKSPACE/tests/linux/assert-state.sh" "$D" "$C"' + +marker="ci-update-marker-${GITHUB_RUN_ID:-local}" +as_ci DOTFILES_REPO="file://$remote" M="$marker" bash -lc ' + set -euo pipefail + work="$(mktemp -d)" + git clone "$DOTFILES_REPO" "$work/repo" + git -C "$work/repo" config user.name ci + git -C "$work/repo" config user.email ci@example.invalid + printf "\n%s\n" "$M" >> "$work/repo/home/dot_fdignore" + git -C "$work/repo" add home/dot_fdignore + git -c commit.gpgsign=false -C "$work/repo" commit -m "CI update fixture" + git -C "$work/repo" push origin HEAD:refs/heads/main +' + +as_ci PATH="/home/dotfilesci/.local/bin:$PATH" GITHUB_WORKSPACE="$repo" DOTFILES_NONINTERACTIVE=1 bash -lc 'bash "$GITHUB_WORKSPACE/update.sh" --non-interactive' + +as_ci PATH="/home/dotfilesci/.local/bin:$PATH" GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=safe.directory GIT_CONFIG_VALUE_0="$repo" GITHUB_WORKSPACE="$repo" D="$distro" M="$marker" bash -lc 'grep -Fqx "$M" "$HOME/.fdignore"; bash "$GITHUB_WORKSPACE/tests/linux/assert-state.sh" "$D"' diff --git a/tests/static/check-powershell-bridge.py b/tests/static/check-powershell-bridge.py new file mode 100644 index 0000000..612b0bd --- /dev/null +++ b/tests/static/check-powershell-bridge.py @@ -0,0 +1,10 @@ +#!/usr/bin/env python3 +import base64,pathlib,re +root=pathlib.Path(__file__).resolve().parents[2] +cmd=(root/"scripts/windows/invoke-ps-script.cmd").read_text() +src=(root/"scripts/windows/invoke-ps-script-bridge.ps1").read_text() +m=re.search(r'(?m)^"%SystemRoot%\\System32\\WindowsPowerShell\\v1\.0\\powershell\.exe" -NoProfile -EncodedCommand ([A-Za-z0-9+/=]+)\s*$',cmd) +if not m: raise SystemExit("encoded bridge payload is missing") +if base64.b64decode(m.group(1)).decode("utf-16le")!=src: raise SystemExit("encoded bridge payload does not match source") +if len(m.group(1))>=8000: raise SystemExit("encoded bridge payload is too close to cmd.exe limits") +print(f"bridge-source-ok: encoded length={len(m.group(1))}") diff --git a/tests/static/validate-architecture.py b/tests/static/validate-architecture.py new file mode 100644 index 0000000..82d9269 --- /dev/null +++ b/tests/static/validate-architecture.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import pathlib +import re +import subprocess + +ROOT = pathlib.Path(__file__).resolve().parents[2] +SELF = pathlib.Path(__file__).resolve().relative_to(ROOT).as_posix() + + +def fail(message: str) -> None: + raise SystemExit(message) + + +def tracked_files() -> list[str]: + result = subprocess.run( + ["git", "-C", str(ROOT), "ls-files", "-z"], + check=True, + capture_output=True, + ) + return [item.decode() for item in result.stdout.split(b"\0") if item] + + +def read_text(path: str) -> str: + try: + return (ROOT / path).read_text(encoding="utf-8") + except UnicodeDecodeError: + return "" + + +if (ROOT / ".chezmoiroot").read_text(encoding="utf-8").strip() != "home": + fail(".chezmoiroot must contain 'home'") + +required_paths = ( + "home", + "scripts/linux/packages-debian.txt", + "scripts/linux/packages-arch.txt", + "scripts/linux/required-commands.txt", + "scripts/windows/managed-apps.csv", + "scripts/windows/managed-modules.txt", + "scripts/windows/invoke-ps-script.cmd", + "scripts/windows/invoke-ps-script-bridge.ps1", + "scripts/windows/signing.ps1", + "scripts/windows/deploy-pwsh.ps1", + "home/.chezmoiscripts/run_after_90-deploy-pwsh.cmd.tmpl", +) +for relative in required_paths: + if not (ROOT / relative).exists(): + fail(f"required path is missing: {relative}") + +paths = tracked_files() +for path in paths: + if pathlib.PurePosixPath(path).name.startswith("symlink_"): + fail(f"chezmoi symlink source state is not allowed: {path}") + +checks = ( + ( + re.compile(r"DOTFILES_CORE_ONLY|CoreOnly|--core-only"), + {SELF}, + "obsolete core-only mode is still referenced", + ), + ( + re.compile(r"dotbot", re.IGNORECASE), + {".github/workflows/validate.yml", SELF}, + "obsolete Dotbot dependency/reference found", + ), + ( + re.compile( + r"https://github\.com/jsilverdev/dotfiles\.git.*master|" + r"raw\.githubusercontent\.com/jsilverdev/dotfiles/master" + ), + {SELF}, + "obsolete master bootstrap URL found", + ), + ( + re.compile( + r"Set-ExecutionPolicy.*(?:Bypass|Unrestricted)|" + r"-ExecutionPolicy\s+(?:Bypass|Unrestricted)", + re.IGNORECASE, + ), + {".github/workflows/validate.yml", SELF}, + "production execution-policy weakening found", + ), +) + +for pattern, exclusions, message in checks: + for path in paths: + if path in exclusions: + continue + text = read_text(path) + match = pattern.search(text) + if match: + line = text.count("\n", 0, match.start()) + 1 + fail(f"{message}: {path}:{line}") + +registry_pattern = re.compile( + r"PowerShellCore\\ShellIds|Software\\Microsoft\\PowerShell\\1\\ShellIds", + re.IGNORECASE, +) +for path in paths: + if pathlib.PurePosixPath(path).suffix.lower() not in {".ps1", ".psm1", ".cmd"}: + continue + text = read_text(path) + match = registry_pattern.search(text) + if match: + line = text.count("\n", 0, match.start()) + 1 + fail(f"implementation-specific execution-policy registry probing found: {path}:{line}") + +signature_marker = "# SIG # Begin signature block" +for path in paths: + if pathlib.PurePosixPath(path).suffix.lower() not in {".ps1", ".psm1"}: + continue + text = read_text(path) + if any(line.startswith(signature_marker) for line in text.splitlines()): + fail(f"signed PowerShell source was committed: {path}") + +print("architecture-validation-ok") diff --git a/tests/static/validate-manifests.py b/tests/static/validate-manifests.py new file mode 100644 index 0000000..58cffec --- /dev/null +++ b/tests/static/validate-manifests.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +import csv +import pathlib + +root = pathlib.Path(__file__).resolve().parents[2] +with (root / "scripts/windows/managed-apps.csv").open(newline="", encoding="utf-8") as handle: + apps = list(csv.DictReader(handle)) + +required_columns = {"Category", "Name", "AppId", "Alias", "Scope"} +if not apps or set(apps[0]) != required_columns: + raise SystemExit("managed-apps.csv has an invalid schema") + +ids = [row["AppId"] for row in apps] +if len(ids) != len(set(ids)): + raise SystemExit("managed-apps.csv contains duplicate AppId values") + +core = [row for row in apps if row["Category"] == "core"] +if not core or any(not row["Alias"] for row in core): + raise SystemExit("every core WinGet app must define an Alias") +if any(row["Scope"] != "user" for row in core): + raise SystemExit("every core WinGet app must use user scope") +if any(row["Category"] not in {"core", "workstation", "optional"} for row in apps): + raise SystemExit("managed-apps.csv contains an unknown category") + +for name in ("packages-debian.txt", "packages-arch.txt", "required-commands.txt"): + path = root / "scripts/linux" / name + lines = [line.strip() for line in path.read_text(encoding="utf-8").splitlines() if line.strip() and not line.lstrip().startswith("#")] + if not lines: + raise SystemExit(f"{name} is empty") + if len(lines) != len(set(lines)): + raise SystemExit(f"{name} contains duplicates") + +print("manifest-validation-ok") diff --git a/tests/static/validate-powershell.ps1 b/tests/static/validate-powershell.ps1 new file mode 100644 index 0000000..553efc3 --- /dev/null +++ b/tests/static/validate-powershell.ps1 @@ -0,0 +1,9 @@ +$ErrorActionPreference="Stop" +$errors=@() +Get-ChildItem (Join-Path $PSScriptRoot "..\..") -Recurse -File -Include *.ps1,*.psm1 | ForEach-Object { + $tokens=$null; $parseErrors=$null + [System.Management.Automation.Language.Parser]::ParseFile($_.FullName,[ref]$tokens,[ref]$parseErrors)|Out-Null + if($parseErrors.Count){$errors += "$($_.FullName): $($parseErrors -join '; ')"} +} +if($errors.Count){$errors|ForEach-Object{Write-Error $_};exit 1} +Write-Host "powershell-syntax-ok" diff --git a/tests/static/validate-source.sh b/tests/static/validate-source.sh new file mode 100644 index 0000000..5426ef6 --- /dev/null +++ b/tests/static/validate-source.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="${GITHUB_WORKSPACE:-$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)}" +cd "$repo_root" + +validate_shell() { + local shell_files=( + bootstrap.sh + install.sh + update.sh + tests/linux/assert-state.sh + tests/linux/run-ci.sh + tests/static/validate-source.sh + ) + bash -n "${shell_files[@]}" + shellcheck -x -S error "${shell_files[@]}" +} + +validate_chezmoi() { + chezmoi --source "$repo_root" managed >/dev/null + while IFS= read -r -d '' template; do + chezmoi --source "$repo_root" execute-template < "$template" >/dev/null + done < <(find "$repo_root" -type f -name '*.tmpl' -print0) +} + +case "${1:-all}" in + shell) validate_shell ;; + chezmoi) validate_chezmoi ;; + all) + validate_shell + validate_chezmoi + ;; + *) + printf 'usage: %s [shell|chezmoi|all]\n' "$0" >&2 + exit 2 + ;; +esac diff --git a/tests/windows/assert-allsigned.ps1 b/tests/windows/assert-allsigned.ps1 new file mode 100644 index 0000000..7811e75 --- /dev/null +++ b/tests/windows/assert-allsigned.ps1 @@ -0,0 +1,283 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)][string]$RepoRoot, + [Parameter(Mandatory)][string]$ThumbprintFile, + [string]$UpdateMarker +) + +$ErrorActionPreference = "Stop" +$certificateSubject = "CN=jsilverdev Dotfiles Code Signing" +$codeSigningOid = "1.3.6.1.5.5.7.3.3" +$enhancedKeyUsageOid = "2.5.29.37" + +function Fail([string]$Message) { throw "ASSERTION FAILED: $Message" } + +function Test-CodeSigningCertificate { + param([System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate) + + if ($null -eq $Certificate -or -not $Certificate.HasPrivateKey -or $Certificate.NotAfter -le (Get-Date)) { + return $false + } + + $ekuExtension = $Certificate.Extensions | + Where-Object { $_.Oid.Value -eq $enhancedKeyUsageOid } | + Select-Object -First 1 + if ($null -eq $ekuExtension) { return $false } + + try { + $eku = New-Object System.Security.Cryptography.X509Certificates.X509EnhancedKeyUsageExtension + $eku.CopyFrom($ekuExtension) + } + catch { + return $false + } + + return @($eku.EnhancedKeyUsages | Where-Object { $_.Value -eq $codeSigningOid }).Count -gt 0 +} + +function Get-StoreCertificates { + param( + [Parameter(Mandatory)][string]$StoreName, + [Parameter(Mandatory)][System.Security.Cryptography.X509Certificates.StoreLocation]$StoreLocation + ) + + $store = [System.Security.Cryptography.X509Certificates.X509Store]::new($StoreName, $StoreLocation) + try { + $store.Open([System.Security.Cryptography.X509Certificates.OpenFlags]::ReadOnly) + return @($store.Certificates) + } + finally { + $store.Close() + } +} + +function Test-CertificateInStore { + param( + [Parameter(Mandatory)][string]$StoreName, + [Parameter(Mandatory)][System.Security.Cryptography.X509Certificates.StoreLocation]$StoreLocation, + [Parameter(Mandatory)][string]$Thumbprint + ) + + return @(Get-StoreCertificates -StoreName $StoreName -StoreLocation $StoreLocation | + Where-Object Thumbprint -eq $Thumbprint).Count -gt 0 +} + +function Assert-AuthenticodeFiles { + param( + [Parameter(Mandatory)][string[]]$FilePath, + [Parameter(Mandatory)][string]$ExpectedThumbprint + ) + + if ($FilePath.Count -eq 0) { return } + + $windowsPowerShell = Join-Path $env:SystemRoot "System32\WindowsPowerShell\v1.0\powershell.exe" + if (-not (Test-Path -LiteralPath $windowsPowerShell -PathType Leaf)) { + Fail "Windows PowerShell is unavailable for Authenticode verification." + } + + $manifest = [IO.Path]::ChangeExtension([IO.Path]::GetTempFileName(), ".json") + $originalPSModulePath = $env:PSModulePath + try { + @($FilePath) | ConvertTo-Json -Compress | Set-Content -LiteralPath $manifest -Encoding UTF8 + $env:DOTFILES_SIGNATURE_MANIFEST = $manifest + $env:DOTFILES_EXPECTED_THUMBPRINT = $ExpectedThumbprint + $env:PSModulePath = @( + (Join-Path $HOME "Documents\WindowsPowerShell\Modules") + (Join-Path $env:ProgramFiles "WindowsPowerShell\Modules") + (Join-Path $env:SystemRoot "System32\WindowsPowerShell\v1.0\Modules") + ) -join [IO.Path]::PathSeparator + + $command = @' +$ErrorActionPreference = 'Stop' +$paths = @(Get-Content -LiteralPath $env:DOTFILES_SIGNATURE_MANIFEST -Raw | ConvertFrom-Json) +foreach ($path in $paths) { + $signature = Get-AuthenticodeSignature -LiteralPath $path + if ($signature.Status -ne 'Valid') { + throw "invalid Authenticode signature: $path ($($signature.Status))" + } + if ($null -eq $signature.SignerCertificate -or $signature.SignerCertificate.Thumbprint -ne $env:DOTFILES_EXPECTED_THUMBPRINT) { + throw "unexpected Authenticode signer: $path" + } + Write-Output "Valid signature: $path" +} +'@ + $encodedCommand = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($command)) + + & $windowsPowerShell -NoProfile -NonInteractive -EncodedCommand $encodedCommand + if ($LASTEXITCODE -ne 0) { + Fail "Windows PowerShell Authenticode verification failed with exit code $LASTEXITCODE." + } + } + finally { + $env:PSModulePath = $originalPSModulePath + Remove-Item Env:DOTFILES_SIGNATURE_MANIFEST -ErrorAction SilentlyContinue + Remove-Item Env:DOTFILES_EXPECTED_THUMBPRINT -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $manifest -Force -ErrorAction SilentlyContinue + } +} + +# The bridge sets this variable only on the signed execution path. The assertion +# avoids autoloading Microsoft.PowerShell.Security inside pwsh under AllSigned. +if ($env:DOTFILES_SIGNING_REQUIRED -ne "1") { + Fail "assert-allsigned.ps1 was not executed through the AllSigned signing path" +} +Write-Host "AllSigned bridge path confirmed." + +$currentUser = [System.Security.Cryptography.X509Certificates.StoreLocation]::CurrentUser +$localMachine = [System.Security.Cryptography.X509Certificates.StoreLocation]::LocalMachine +$certificate = @(Get-StoreCertificates -StoreName "My" -StoreLocation $currentUser | Where-Object { + $_.Subject -eq $certificateSubject -and (Test-CodeSigningCertificate $_) +} | Sort-Object NotAfter -Descending | Select-Object -First 1) +if ($certificate.Count -ne 1) { Fail "usable dotfiles Code Signing certificate was not found in CurrentUser\\My" } + +$thumbprint = $certificate[0].Thumbprint +if (-not (Test-CertificateInStore -StoreName "TrustedPublisher" -StoreLocation $currentUser -Thumbprint $thumbprint)) { + Fail "certificate $thumbprint is missing from CurrentUser\\TrustedPublisher" +} +$trustedRoot = + (Test-CertificateInStore -StoreName "Root" -StoreLocation $currentUser -Thumbprint $thumbprint) -or + (Test-CertificateInStore -StoreName "Root" -StoreLocation $localMachine -Thumbprint $thumbprint) +if (-not $trustedRoot) { Fail "certificate $thumbprint is missing from both CurrentUser\\Root and LocalMachine\\Root" } +Write-Host "Certificate: $($certificate[0].Subject) thumbprint=$thumbprint expires=$($certificate[0].NotAfter)" + +if (Test-Path -LiteralPath $ThumbprintFile) { + $previous = (Get-Content -LiteralPath $ThumbprintFile -Raw).Trim() + if ($previous -ne $thumbprint) { Fail "signing certificate changed from $previous to $thumbprint" } +} +Set-Content -LiteralPath $ThumbprintFile -Value $thumbprint -NoNewline + +function Invoke-NativeProcess { + param( + [Parameter(Mandatory)][string]$FilePath, + [string[]]$Arguments = @() + ) + + $startInfo = [System.Diagnostics.ProcessStartInfo]::new() + $startInfo.FileName = $FilePath + $startInfo.UseShellExecute = $false + $startInfo.CreateNoWindow = $true + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true + foreach ($argument in $Arguments) { + [void]$startInfo.ArgumentList.Add($argument) + } + + $process = [System.Diagnostics.Process]::new() + $process.StartInfo = $startInfo + try { + if (-not $process.Start()) { + Fail "failed to start native process: $FilePath" + } + + $stdoutTask = $process.StandardOutput.ReadToEndAsync() + $stderrTask = $process.StandardError.ReadToEndAsync() + $process.WaitForExit() + $stdout = $stdoutTask.GetAwaiter().GetResult() + $stderr = $stderrTask.GetAwaiter().GetResult() + + return [pscustomobject]@{ + ExitCode = $process.ExitCode + StdOut = $stdout + StdErr = $stderr + } + } + finally { + $process.Dispose() + } +} + +function Invoke-Chezmoi([string[]]$Arguments) { + $chezmoi = (Get-Command chezmoi.exe -ErrorAction Stop).Source + $result = Invoke-NativeProcess -FilePath $chezmoi -Arguments $Arguments + if ($result.ExitCode -ne 0) { + Fail "chezmoi $($Arguments -join ' ') failed with exit code $($result.ExitCode): $($result.StdErr.Trim())" + } + + if ([string]::IsNullOrWhiteSpace($result.StdOut)) { + return @() + } + + return @($result.StdOut -split "\r?\n" | Where-Object { $_ -ne "" }) +} + +function Assert-CleanChezMoi { + $status = @(Invoke-Chezmoi @("status", "--exclude=scripts")) + $pending = @($status | Where-Object { + $line = [string]$_ + $line.Length -ge 2 -and $line[1] -ne ' ' + }) + if ($pending.Count -ne 0) { Fail "chezmoi has pending target changes: $($pending -join [Environment]::NewLine)" } +} + +$sourcePath = ((Invoke-Chezmoi @("source-path")) -join "").Trim() +Write-Host "chezmoi source-path: $sourcePath" +$managedAppsPath = Join-Path $RepoRoot "scripts\windows\managed-apps.csv" +$coreApps = @(Import-Csv -LiteralPath $managedAppsPath | Where-Object Category -eq "core") +foreach ($app in $coreApps) { + if ([string]::IsNullOrWhiteSpace($app.Alias)) { continue } + if (-not (Get-Command -Name $app.Alias -ErrorAction SilentlyContinue)) { + Fail "baseline CLI tool is unavailable: $($app.Alias)" + } +} +& mise which starship *> $null +if ($LASTEXITCODE -ne 0) { + Fail "starship is not managed by mise" +} +Invoke-Chezmoi @("apply") | Out-Host +Invoke-Chezmoi @("apply") | Out-Host +Assert-CleanChezMoi + +$signatureFiles = [System.Collections.Generic.List[string]]::new() +$runtimeFiles = @( + (Join-Path $HOME ".config\pwsh\env.ps1"), + (Join-Path $HOME ".config\pwsh\lib\helpers.ps1"), + (Join-Path $HOME ".config\pwsh\lib\aliases.ps1"), + $PROFILE.CurrentUserAllHosts +) +foreach ($path in $runtimeFiles) { + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { Fail "runtime PowerShell file is missing: $path" } + $signatureFiles.Add($path) +} + +foreach ($moduleName in @(Get-Content -LiteralPath (Join-Path $RepoRoot "scripts\windows\managed-modules.txt") | Where-Object { $_.Trim() -and -not $_.Trim().StartsWith('#') })) { + if ($moduleName -eq "git-aliases") { + Import-Module $moduleName -Force -DisableNameChecking -ErrorAction Stop + } + else { + Import-Module $moduleName -Force -ErrorAction Stop + } + + $module = @(Get-Module -ListAvailable -Name $moduleName | Where-Object { + $_.ModuleBase -like "$(Join-Path $HOME 'Documents\PowerShell\Modules')*" -or + $_.ModuleBase -like "$(Join-Path $HOME '.local\share\powershell\Modules')*" + } | Select-Object -First 1) + if ($module.Count -ne 1) { Fail "managed module is not in a current-user module path: $moduleName" } + + $moduleFiles = @(Get-ChildItem -LiteralPath $module[0].ModuleBase -File -Recurse | + Where-Object Extension -in @(".ps1", ".psm1", ".psd1", ".ps1xml", ".cdxml", ".xaml")) + foreach ($file in $moduleFiles) { + $signatureFiles.Add($file.FullName) + } +} + +Assert-AuthenticodeFiles -FilePath @($signatureFiles) -ExpectedThumbprint $thumbprint + +if ($UpdateMarker) { + if (-not ((Get-Content (Join-Path $HOME ".fdignore") -Raw) -match [regex]::Escape($UpdateMarker))) { Fail "update marker did not reach .fdignore" } +} + +$sourceStatus = @(git -C $sourcePath status --porcelain) +if ($LASTEXITCODE -ne 0 -or $sourceStatus.Count -ne 0) { Fail "chezmoi source Git tree is dirty: $($sourceStatus -join [Environment]::NewLine)" } +$repoStatus = @(git -C $RepoRoot status --porcelain) +if ($LASTEXITCODE -ne 0 -or $repoStatus.Count -ne 0) { Fail "Actions checkout Git tree is dirty: $($repoStatus -join [Environment]::NewLine)" } +$signatureMatches = @(git -C $RepoRoot grep -n "^# SIG # Begin signature block" -- "*.ps1") +if ($LASTEXITCODE -eq 0 -or $signatureMatches.Count -ne 0) { Fail "repository PowerShell source contains an Authenticode signature block" } + +$pwsh = (Get-Command pwsh.exe -ErrorAction Stop).Source +$profileResult = Invoke-NativeProcess -FilePath $pwsh -Arguments @("-Command", "Write-Output 'profile-ok'") +$profileOutput = @($profileResult.StdOut -split "\r?\n" | Where-Object { $_ -ne "" }) +if ($profileResult.ExitCode -ne 0 -or -not ($profileOutput -contains "profile-ok")) { + Fail "PowerShell profile startup failed with exit code $($profileResult.ExitCode): $($profileResult.StdErr.Trim())" +} + +Write-Host "AllSigned assertions passed with certificate $thumbprint." diff --git a/tests/windows/assert-state.ps1 b/tests/windows/assert-state.ps1 new file mode 100644 index 0000000..e348df2 --- /dev/null +++ b/tests/windows/assert-state.ps1 @@ -0,0 +1,120 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)][string]$RepoRoot, + [string]$ExpectedCommit, + [string]$UpdateMarker +) + +$ErrorActionPreference = "Stop" + +function Fail([string]$Message) { throw "ASSERTION FAILED: $Message" } + +Write-Host "Windows: $([Environment]::OSVersion.Version)" +Write-Host "User: $env:USERNAME" +Write-Host "PowerShell: $($PSVersionTable.PSVersion)" +Write-Host "Execution policy: $(Get-ExecutionPolicy)" +Write-Host "Execution policies:" +Get-ExecutionPolicy -List | Format-Table -AutoSize | Out-Host + +if ((Get-ExecutionPolicy) -eq "AllSigned") { Fail "normal-policy test unexpectedly has AllSigned effective" } + +function Invoke-Chezmoi([string[]]$Arguments) { + $output = @(& chezmoi.exe @Arguments 2>&1) + if ($LASTEXITCODE -ne 0) { Fail "chezmoi $($Arguments -join ' ') failed: $($output -join [Environment]::NewLine)" } + return $output +} + +function Assert-CleanChezMoi { + # Always-run scripts appear as "R" and create-only files may legitimately + # differ in the first status column. Only the second column means apply + # still has work to do. + $status = @(Invoke-Chezmoi @("status", "--exclude=scripts")) + $pending = @($status | Where-Object { + $line = [string]$_ + $line.Length -ge 2 -and $line[1] -ne ' ' + }) + if ($pending.Count -ne 0) { Fail "chezmoi has pending target changes: $($pending -join [Environment]::NewLine)" } +} + +Write-Host "chezmoi: $((chezmoi.exe --version) -join ' ')" +$sourcePath = ((Invoke-Chezmoi @("source-path")) -join "").Trim() +Write-Host "chezmoi source-path: $sourcePath" +if (-not (Test-Path -LiteralPath $sourcePath)) { Fail "chezmoi source path does not exist: $sourcePath" } + +$requiredFiles = @( + (Join-Path $HOME ".gitconfig"), + (Join-Path $HOME ".gitconfig.local"), + (Join-Path $HOME ".fdignore"), + (Join-Path $HOME ".wslconfig"), + (Join-Path $HOME ".config\starship\config.toml"), + (Join-Path $HOME ".codex\AGENTS.md"), + (Join-Path $HOME ".codex\skills\mule-munit\SKILL.md"), + (Join-Path $HOME ".codex\skills\mule-munit\agents\openai.yaml"), + (Join-Path $HOME ".config\pwsh\env.ps1"), + (Join-Path $HOME ".config\pwsh\lib\helpers.ps1"), + (Join-Path $HOME ".config\pwsh\lib\aliases.ps1"), + $PROFILE.CurrentUserAllHosts +) +foreach ($path in $requiredFiles) { + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { Fail "expected deployed file is missing: $path" } +} + +if ($ExpectedCommit) { + $sourceCommit = ((git -C $sourcePath rev-parse HEAD) -join "").Trim() + if ($LASTEXITCODE -ne 0 -or $sourceCommit -ne $ExpectedCommit) { Fail "source commit $sourceCommit is not expected commit $ExpectedCommit" } +} + +$managedAppsPath = Join-Path $RepoRoot "scripts\windows\managed-apps.csv" +$coreApps = @(Import-Csv -LiteralPath $managedAppsPath | Where-Object Category -eq "core") +foreach ($app in $coreApps) { + if ([string]::IsNullOrWhiteSpace($app.Alias)) { continue } + if (-not (Get-Command -Name $app.Alias -ErrorAction SilentlyContinue)) { + Fail "baseline CLI tool is unavailable: $($app.Alias)" + } +} +& mise which starship *> $null +if ($LASTEXITCODE -ne 0) { + Fail "starship is not managed by mise" +} + +Invoke-Chezmoi @("apply") | Out-Host +Invoke-Chezmoi @("apply") | Out-Host +Assert-CleanChezMoi + +$configMarker = "ci-create-only-$([Guid]::NewGuid().ToString('N'))" +$rulesMarker = "ci-create-only-rule-$([Guid]::NewGuid().ToString('N'))" +Add-Content -LiteralPath (Join-Path $HOME ".codex\config.toml") -Value $configMarker +Add-Content -LiteralPath (Join-Path $HOME ".codex\rules\default.rules") -Value $rulesMarker +Invoke-Chezmoi @("apply") | Out-Host +if (-not ((Get-Content (Join-Path $HOME ".codex\config.toml") -Raw) -match [regex]::Escape($configMarker))) { Fail "chezmoi overwrote create-only config.toml" } +if (-not ((Get-Content (Join-Path $HOME ".codex\rules\default.rules") -Raw) -match [regex]::Escape($rulesMarker))) { Fail "chezmoi overwrote create-only default.rules" } +Assert-CleanChezMoi + +if ($UpdateMarker) { + if (-not ((Get-Content (Join-Path $HOME ".fdignore") -Raw) -match [regex]::Escape($UpdateMarker))) { Fail "update marker did not reach .fdignore" } +} + +foreach ($module in @(Get-Content -LiteralPath (Join-Path $RepoRoot "scripts\windows\managed-modules.txt") | Where-Object { $_.Trim() -and -not $_.Trim().StartsWith('#') })) { + if ($module -eq "git-aliases") { + Import-Module $module -Force -DisableNameChecking -ErrorAction Stop + } + else { + Import-Module $module -Force -ErrorAction Stop + } +} + +$certificateSubject = "CN=jsilverdev Dotfiles Code Signing" +$certificate = @(Get-ChildItem Cert:\CurrentUser\My | Where-Object Subject -eq $certificateSubject) +if ($certificate.Count -ne 0) { Fail "normal-policy run unexpectedly created a dotfiles signing certificate" } + +$sourceStatus = @(git -C $sourcePath status --porcelain) +if ($LASTEXITCODE -ne 0 -or $sourceStatus.Count -ne 0) { Fail "chezmoi source Git tree is dirty: $($sourceStatus -join [Environment]::NewLine)" } +$repoStatus = @(git -C $RepoRoot status --porcelain) +if ($LASTEXITCODE -ne 0 -or $repoStatus.Count -ne 0) { Fail "Actions checkout Git tree is dirty: $($repoStatus -join [Environment]::NewLine)" } +$signatureMatches = @(git -C $RepoRoot grep -n "^# SIG # Begin signature block" -- "*.ps1") +if ($LASTEXITCODE -eq 0 -or $signatureMatches.Count -ne 0) { Fail "repository PowerShell source contains an Authenticode signature block" } + +$profileOutput = @(& pwsh.exe -Command "Write-Output 'profile-ok'" 2>&1) +if ($LASTEXITCODE -ne 0 -or -not ($profileOutput -contains "profile-ok")) { Fail "PowerShell profile startup failed: $($profileOutput -join [Environment]::NewLine)" } + +Write-Host "Windows normal-policy assertions passed." diff --git a/tests/windows/create-update-fixture.cmd b/tests/windows/create-update-fixture.cmd new file mode 100644 index 0000000..27d6111 --- /dev/null +++ b/tests/windows/create-update-fixture.cmd @@ -0,0 +1,13 @@ +@echo off +setlocal EnableExtensions DisableDelayedExpansion +set "D=%RUNNER_TEMP%\dotfiles-update-%GITHUB_RUN_ID%" +if exist "%D%" rmdir /s /q "%D%" +git clone "%DOTFILES_REPO%" "%D%" || exit /b 1 +git -C "%D%" config user.name ci +git -C "%D%" config user.email ci@example.invalid +set "UPDATE_MARKER=ci-update-marker-%GITHUB_RUN_ID%" +>>"%D%\home\dot_fdignore" echo %UPDATE_MARKER% +git -C "%D%" add home/dot_fdignore +git -c commit.gpgsign=false -C "%D%" commit -m "CI update fixture" || exit /b 1 +git -C "%D%" push origin HEAD:refs/heads/main || exit /b 1 +>>"%GITHUB_ENV%" echo UPDATE_MARKER=%UPDATE_MARKER% diff --git a/tests/windows/enable-allsigned.ps1 b/tests/windows/enable-allsigned.ps1 new file mode 100644 index 0000000..ec161ca --- /dev/null +++ b/tests/windows/enable-allsigned.ps1 @@ -0,0 +1,34 @@ +$ErrorActionPreference = "Stop" + +$smoke = Join-Path $env:RUNNER_TEMP "allsigned-bridge-smoke.ps1" +@' +param([string]$Value) +if ($Value -ne "bridge-ok") { throw "unexpected bridge value: $Value" } +'@ | Set-Content -LiteralPath $smoke + +$subject = "CN=jsilverdev Dotfiles Code Signing" +$certificate = Get-ChildItem Cert:\CurrentUser\My | + Where-Object { $_.Subject -eq $subject -and $_.HasPrivateKey -and $_.NotAfter -gt (Get-Date) } | + Sort-Object NotAfter -Descending | + Select-Object -First 1 + +if ($null -eq $certificate) { + $certificate = New-SelfSignedCertificate -Type CodeSigningCert -Subject $subject -CertStoreLocation Cert:\CurrentUser\My -NotAfter (Get-Date).AddYears(10) -HashAlgorithm SHA256 +} + +$certificatePath = Join-Path $env:RUNNER_TEMP "dotfiles-signing.cer" +$thumbprintPath = Join-Path $env:RUNNER_TEMP "dotfiles-cert-thumbprint.txt" +Set-Content -LiteralPath $thumbprintPath -Value $certificate.Thumbprint -NoNewline +Export-Certificate -Cert $certificate -FilePath $certificatePath -Type CERT -Force | Out-Null + +foreach ($store in @("Cert:\LocalMachine\Root", "Cert:\CurrentUser\TrustedPublisher")) { + if (-not (Get-ChildItem $store | Where-Object Thumbprint -eq $certificate.Thumbprint | Select-Object -First 1)) { + Import-Certificate -FilePath $certificatePath -CertStoreLocation $store -Confirm:$false | Out-Null + } +} + +Set-AuthenticodeSignature -FilePath $smoke -Certificate $certificate -HashAlgorithm SHA256 | Out-Null +if ((Get-AuthenticodeSignature -FilePath $smoke).Status -ne "Valid") { throw "The AllSigned smoke script signature is invalid." } + +Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy AllSigned -Force +if ((Get-ExecutionPolicy) -ne "AllSigned") { throw "AllSigned is not the effective execution policy." } diff --git a/tests/windows/prepare-ci.ps1 b/tests/windows/prepare-ci.ps1 new file mode 100644 index 0000000..0772dbf --- /dev/null +++ b/tests/windows/prepare-ci.ps1 @@ -0,0 +1,44 @@ +$ErrorActionPreference = "Stop" + +function Ensure-WinGet { + cmd /c where winget.exe + if ($LASTEXITCODE -eq 0) { return } + + Install-PackageProvider -Name NuGet -Force | Out-Null + Install-Module -Name Microsoft.WinGet.Client -Force -Repository PSGallery | Out-Null + Import-Module Microsoft.WinGet.Client -Force + Repair-WinGetPackageManager -Force -Latest + + cmd /c where winget.exe + if ($LASTEXITCODE -ne 0) { throw "winget.exe is unavailable after repair." } +} + +function Add-CiUserPaths { + @( + (Join-Path $env:LOCALAPPDATA "Microsoft\WinGet\Links"), + (Join-Path $env:LOCALAPPDATA "Programs\Microsoft.PowerShell"), + (Join-Path $env:LOCALAPPDATA "Programs\chezmoi") + ) | ForEach-Object { Add-Content -Path $env:GITHUB_PATH -Value $_ } +} + +function New-ExactCommitRemote { + $remote = Join-Path $env:RUNNER_TEMP "dotfiles-remote-$env:GITHUB_RUN_ID.git" + git init --bare $remote + if ($LASTEXITCODE -ne 0) { throw "Unable to create the local bare Git remote." } + + $head = (git -C $env:GITHUB_WORKSPACE rev-parse HEAD).Trim() + if ($head -ne $env:GITHUB_SHA) { throw "Checkout $head does not match GITHUB_SHA $env:GITHUB_SHA." } + + $uri = "file:///" + $remote.Replace("\", "/") + git -C $env:GITHUB_WORKSPACE push $uri "$($env:GITHUB_SHA):refs/heads/main" + if ($LASTEXITCODE -ne 0) { throw "Unable to push the checked-out commit to the local remote." } + + git --git-dir=$remote symbolic-ref HEAD refs/heads/main + if ($LASTEXITCODE -ne 0) { throw "Unable to set the local remote HEAD." } + + Add-Content -Path $env:GITHUB_ENV -Value "DOTFILES_REPO=$uri" +} + +Ensure-WinGet +Add-CiUserPaths +New-ExactCommitRemote diff --git a/update.cmd b/update.cmd new file mode 100644 index 0000000..abc0807 --- /dev/null +++ b/update.cmd @@ -0,0 +1,34 @@ +@echo off +setlocal EnableExtensions + +where chezmoi.exe >nul 2>&1 +if errorlevel 1 ( + echo Core tools are unavailable; running bootstrap.cmd... + call "%~dp0bootstrap.cmd" + exit /b %ERRORLEVEL% +) + +chezmoi.exe update +if errorlevel 1 exit /b %ERRORLEVEL% + +call :resolve_repo_root +if not defined REPO_ROOT ( + echo Unable to resolve the chezmoi working tree. 1>&2 + exit /b 1 +) + +if /I "%DOTFILES_NONINTERACTIVE%"=="1" ( + call "%REPO_ROOT%\scripts\windows\invoke-ps-script.cmd" "%REPO_ROOT%\install.ps1" -Update -NonInteractive -RepoRoot "%REPO_ROOT%" +) else ( + call "%REPO_ROOT%\scripts\windows\invoke-ps-script.cmd" "%REPO_ROOT%\install.ps1" -Update -RepoRoot "%REPO_ROOT%" +) +exit /b %ERRORLEVEL% + +:resolve_repo_root +set "REPO_ROOT=" +for /f "delims=" %%R in ('chezmoi.exe execute-template "{{ .chezmoi.workingTree }}" 2^>nul') do set "REPO_ROOT=%%R" +if defined REPO_ROOT if exist "%REPO_ROOT%\install.ps1" exit /b 0 +for /f "delims=" %%R in ('chezmoi.exe source-path 2^>nul') do set "SOURCE_ROOT=%%R" +if defined SOURCE_ROOT if exist "%SOURCE_ROOT%\install.ps1" (set "REPO_ROOT=%SOURCE_ROOT%"& exit /b 0) +if defined SOURCE_ROOT for %%P in ("%SOURCE_ROOT%\..") do if exist "%%~fP\install.ps1" (set "REPO_ROOT=%%~fP"& exit /b 0) +exit /b 1 diff --git a/update.sh b/update.sh new file mode 100755 index 0000000..6ec14dc --- /dev/null +++ b/update.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +export PATH="$HOME/.local/bin:$PATH" +command -v chezmoi >/dev/null 2>&1 || { printf 'chezmoi is required; run bootstrap.sh first.\n' >&2; exit 1; } +chezmoi update + +resolve_repo_root() { + local candidate parent + for candidate in "$(chezmoi execute-template '{{ .chezmoi.workingTree }}' 2>/dev/null || true)" "$(chezmoi source-path)"; do + [[ -n "$candidate" ]] || continue + if [[ -f "$candidate/install.sh" ]]; then printf '%s\n' "$candidate"; return; fi + parent="$(dirname "$candidate")" + if [[ -f "$parent/install.sh" ]]; then printf '%s\n' "$parent"; return; fi + done + return 1 +} + +REPO_ROOT="$(resolve_repo_root)" || { printf 'Unable to resolve the chezmoi working tree.\n' >&2; exit 1; } +exec "$REPO_ROOT/install.sh" --update "$@"