diff --git a/.claude/commands/addEntry.md b/.claude/commands/addEntry.md new file mode 100644 index 0000000..f389328 --- /dev/null +++ b/.claude/commands/addEntry.md @@ -0,0 +1,205 @@ +# Agregar entrada al registro STP + +Agregá una nueva entrada al registro STP para: $ARGUMENTS + +--- + +## Procedimiento + +1. Leé `config/registry.yaml` para verificar que no exista ya una entrada con el mismo `id`. +2. Identificá el tipo correcto según la descripción (ver sección **Tipos** más abajo). +3. Insertá la nueva entrada en `config/registry.yaml`, dentro de la lista `registry:`, respetando el orden: + - Los `ppa` deben aparecer **antes** de cualquier `apt` que los requiera. + - Dentro de cada grupo, el orden es cosmético; seguí el orden existente. +4. Confirmá el resultado mostrando la entrada agregada. + +**Archivo a editar:** `config/registry.yaml` + +--- + +## Tipos + +### `apt` — paquete del gestor de paquetes + +```yaml +- id: nombrePaquete + type: apt + # package: nombre-real # solo si el nombre real difiere del id + # minVersion: "1.2.3" # versión mínima aceptable (opcional) + # maxVersion: "1.2.3" # versión máxima aceptable (opcional) +``` + +Campos: +- `id` — requerido, camelCase +- `package` — opcional; omitir si el id coincide con el nombre real del paquete apt +- `minVersion` — opcional; versión mínima aceptable (cadena de versión Debian, ej. `"2.38.1"`) +- `maxVersion` — opcional; versión máxima a instalar + +**Comportamiento de instalación según los campos presentes:** + +| `minVersion` | `maxVersion` | Qué instala | +|---|---|---| +| — | — | última versión disponible | +| `1.0` | — | última disponible (best-effort ≥ 1.0) | +| — | `2.0` | `package=2.0` | +| `1.0` | `2.0` | `package=2.0` (techo del rango aceptable) | +| `1.5` | `1.5` | `package=1.5` (versión exacta) | + +Cuando `maxVersion` está presente, el módulo siempre instala esa versión exacta con `--allow-downgrades`, lo que garantiza que la máquina no quede con una versión superior al límite aunque ya tuviera el paquete instalado. Si la versión instalada ya satisface el rango `[minVersion, maxVersion]`, el paso se omite sin reinstalar. + +### `ppa` — repositorio PPA de Ubuntu + +```yaml +- id: nombrePpa + type: ppa + address: ppa:usuario/repositorio +``` + +Campos: +- `id` — requerido, camelCase +- `address` — requerido, formato `ppa:usuario/repositorio` + +> Debe aparecer en el archivo **antes** del `apt` que instala paquetes de ese PPA. + +### `snap` — aplicación snap + +```yaml +- id: nombreApp + type: snap + # package: nombre-snap # solo si el nombre real difiere del id + # classic: true # solo si el snap requiere confinamiento clásico +``` + +Campos: +- `id` — requerido, camelCase +- `package` — opcional +- `classic` — opcional; omitir si es `false` + +### `flatpak` — aplicación Flatpak + +```yaml +- id: nombreApp + type: flatpak + appId: com.ejemplo.App + # remote: flathub # opcional; flathub por defecto +``` + +Campos: +- `id` — requerido, camelCase +- `appId` — requerido, ID completo de la aplicación (ej. `org.gimp.GIMP`) +- `remote` — opcional; omitir si es flathub + +### `dotfile` — configuración enlazada a `$HOME` + +```yaml +- id: nombreApp + type: dotfile +``` + +Campos: +- `id` — requerido, camelCase; debe coincidir con una carpeta dentro de `dotfiles/` + +Los archivos en `dotfiles/nombreApp/` se enlazan simbólicamente a su ruta equivalente en `$HOME`. +Ejemplo: `dotfiles/bash/.bashrc` → `~/.bashrc` + +Si la carpeta `dotfiles/nombreApp/` no existe, crearla y colocar los archivos adentro antes de agregar la entrada. + +### `service` — servicio systemd + +```yaml +- id: nombreServicio + type: service + # name: nombre-servicio # solo si el nombre real difiere del id + scope: system # system | user + state: enable # enable | disable | mask +``` + +Campos: +- `id` — requerido, camelCase +- `name` — opcional +- `scope` — requerido: `system` o `user` +- `state` — requerido: `enable`, `disable` o `mask` + +### `pipewire` — configuración completa de PipeWire + +```yaml +- id: pipewire + type: pipewire + replacePulseaudio: true + packages: + - pipewire + - pipewire-audio + - pipewire-pulse + - wireplumber + - libspa-0.2-bluetooth + userServices: + - pipewire + - pipewire-pulse + - wireplumber +``` + +Campos: +- `replacePulseaudio` — `true` para deshabilitar y enmascarar PulseAudio +- `packages` — lista de paquetes apt a instalar +- `userServices` — lista de servicios systemd de usuario a habilitar + +### `video` — drivers de GPU (detección automática con `lspci`) + +```yaml +- id: gpuDrivers + type: video + drivers: + nvidia: + packages: + - nvidia-driver-535 + - nvidia-settings + amd: + packages: + - mesa-vulkan-drivers + - radeontop + - vainfo + intel: + packages: + - intel-media-va-driver + - vainfo +``` + +Solo se instalan los paquetes del vendor detectado. Podés incluir uno, dos o los tres vendors. + +--- + +### `docker` — configuración Docker (Compose o Dockerfile) + +```yaml +- id: nombreServicio + type: docker + # destination: ~/docker/nombreServicio # destino en el sistema (opcional, ese es el valor por defecto) + # autostart: true # ejecutar docker compose up -d tras el deploy (opcional, false por defecto) +``` + +Campos: +- `id` — requerido, camelCase +- `destination` — opcional; directorio de destino donde se enlazan los archivos. Soporta `~`. Por defecto: `~/docker/` +- `autostart` — opcional; si `true`, ejecuta `docker compose up -d` después de enlazar los archivos. Requiere un archivo compose en el destino + +Los archivos del servicio deben estar en `docker//` dentro del repo. Se enlazan simbólicamente a `destination/`, respetando la estructura de subdirectorios. + +Ejemplo de estructura: +``` +docker/ + miServicio/ + compose.yaml + .env.example + config/ + nginx.conf +``` + +Si Docker no está instalado en la máquina, el paso se saltea con un aviso sin interrumpir el resto del STP. + +--- + +## Convenciones + +- `id` siempre en **camelCase**, sin guiones ni guiones bajos +- Omitir campos opcionales cuando no aplican — no usar `null` ni strings vacíos +- El campo `name`, `package` o `appId` solo es necesario cuando difiere del `id` diff --git a/.claude/commands/encrypt-ssh.md b/.claude/commands/encrypt-ssh.md new file mode 100644 index 0000000..c5f44c6 --- /dev/null +++ b/.claude/commands/encrypt-ssh.md @@ -0,0 +1,20 @@ +Cifrá las claves SSH para almacenarlas en el repo. + +Ejecutá el siguiente comando y seguí las instrucciones en pantalla: + +```bash +bash scripts/encryptSsh.sh +``` + +Si el archivo `secrets/sshKeys.tar.gz.age` se genera correctamente, hacé commit: + +```bash +git add secrets/sshKeys.tar.gz.age +git commit -m "update encrypted ssh keys" +``` + +Para verificar que el archivo se puede descifrar antes de hacer commit: + +```bash +age --decrypt secrets/sshKeys.tar.gz.age | tar -tzv +``` diff --git a/.claude/commands/new-module.md b/.claude/commands/new-module.md new file mode 100644 index 0000000..c46aae7 --- /dev/null +++ b/.claude/commands/new-module.md @@ -0,0 +1,47 @@ +Creá un nuevo módulo STP llamado `$ARGUMENTS`. + +Seguí estos pasos: + +1. Creá el archivo `modules/$ARGUMENTS.sh` respetando la guía de estilo de `.claude/style.md`: + +```bash +#!/usr/bin/env bash +set -euo pipefail +source "$stpRoot/lib/log.sh" +source "$stpRoot/lib/utils.sh" +source "$stpRoot/lib/yaml.sh" + +readonly nombreConfig="$stpRoot/config/$ARGUMENTS.yaml" + +# Funciones auxiliares (camelCase, una responsabilidad cada una) +helperEspecifico() { ... } + +# Función principal +configurar$ARGUMENTS() { + log::info "Configurando $ARGUMENTS..." + helperEspecifico + log::ok "$ARGUMENTS configurado" +} + +# Verificación inicial: salir si no hay configuración +if [[ ! -f "$nombreConfig" ]]; then + log::info "Sin configuración para $ARGUMENTS, salteando" + exit 0 +fi + +configurar$ARGUMENTS +``` + +2. Si el módulo necesita configuración propia, creá `config/$ARGUMENTS.yaml`. + +3. Agregá `$ARGUMENTS` en `config/modules` en la posición lógica dentro del flujo: + `bootstrap → ssh → registry → ` + +4. Dale permisos de ejecución: `chmod +x modules/$ARGUMENTS.sh` + +Convenciones obligatorias (ver `.claude/style.md`): +- camelCase para todas las variables y funciones, sin guión bajo +- Nombres completos, sin abreviaciones (`packageName` no `pkg`) +- Funciones booleanas como preguntas (`configuraciónEsVálida()`) +- Verificar estado antes de actuar (idempotencia) +- Comandos directos — no usar `util::run` diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..fbb5794 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,18 @@ +{ + "permissions": { + "allow": [ + "Bash(bash:*)", + "Bash(chmod:*)", + "Bash(find:*)", + "Bash(grep:*)", + "Bash(ls:*)", + "Bash(mv:*)", + "Bash(cp:*)", + "Bash(mkdir:*)", + "Bash(yq:*)", + "Bash(age:*)", + "Bash(tar:*)", + "Bash(git:*)" + ] + } +} diff --git a/.claude/style.md b/.claude/style.md new file mode 100644 index 0000000..5687507 --- /dev/null +++ b/.claude/style.md @@ -0,0 +1,146 @@ +# Guía de estilo — STP + +## Nomenclatura: camelCase sin excepciones + +Todo identificador del proyecto usa camelCase. No se usa guión bajo (`_`) ni guión (`-`) en nombres de variables, funciones o archivos Bash. + +| Tipo | Correcto | Incorrecto | +|------|----------|------------| +| Variable local | `packageName` | `package_name`, `pkg` | +| Variable exportada | `stpRoot`, `verbose` | `STP_ROOT`, `VERBOSE` | +| Constante (`readonly`) | `readonly packagesConfig` | `readonly PACKAGES_CONFIG` | +| Función | `installAptPackages()` | `install_apt_packages()` | +| Función privada | `log::emit` | `log::_emit`, `_emit` | +| Archivo Bash | `encryptSsh.sh` | `encrypt-ssh.sh`, `encrypt_ssh.sh` | + +Los nombres de archivos YAML (`packages.yaml`, `audio.yaml`) son palabras simples y no requieren separador. + +--- + +## Clean Code — Robert C. Martin + +### Una función, una responsabilidad + +Cada función hace exactamente una cosa. Un bloque de código que necesita un comentario para explicarse debe convertirse en una función con un nombre descriptivo. + +```bash +# MAL — lógica mezclada con comentario explicativo +if apt-cache show age &>/dev/null 2>&1; then + sudo apt-get install -y age # instala desde apt si está disponible +else + # descarga binario de GitHub... +fi + +# BIEN — la intención es legible sin comentarios +if ageIsAvailableInApt; then + installAgeFromApt +else + installAgeFromGithub +fi +``` + +### Nombres completos, sin abreviaciones + +```bash +# MAL +for pkg in "${packages[@]}"; do ... +for svc in "${services[@]}"; do ... + +# BIEN +for packageName in "${packages[@]}"; do ... +for serviceName in "${services[@]}"; do ... +``` + +Palabras prohibidas como nombre de variable: `pkg`, `svc`, `src`, `dest`, `dir`, `tmp`, `cfg`, `val`, `ret`, `f`, `i` (salvo índices numéricos en bucles simples). + +### Predicados como preguntas + +Las funciones booleanas se nombran como preguntas que responden verdadero o falso: + +```bash +pipewireIsEnabled() # ¿PipeWire está habilitado en la config? +isPpaAlreadyAdded() # ¿el PPA ya fue registrado en sources.list? +dotfilesDirectoryIsEmpty() # ¿no hay dotfiles para desplegar? +``` + +### Comentarios solo para el PORQUÉ + +El código expresa el QUÉ. Solo se agrega un comentario cuando el PORQUÉ no es evidente: una restricción externa, una solución provisional, un comportamiento sorpresivo. + +```bash +# MAL — el comentario dice lo mismo que el código +# Verifica si el PPA ya está en sources.list.d +isPpaAlreadyAdded "$ppaAddress" && continue + +# BIEN — el código habla por sí mismo; el comentario solo si hay un "porqué" no obvio +``` + +--- + +## Código funcional sin modo simulación + +No existe `--dry-run`. El sistema es **idempotente**: verifica el estado actual antes de actuar y omite lo que ya está configurado. + +```bash +# Antes de instalar un paquete +util::isAptInstalled "$packageName" && continue + +# Antes de agregar un PPA +isPpaAlreadyAdded "$ppaAddress" && continue + +# Antes de instalar una clave SSH +[[ -f "$destination" ]] && { log::warn "Ya existe: $keyFilename"; return 1; } +``` + +Ejecutar el STP dos veces produce exactamente el mismo resultado que ejecutarlo una vez. + +--- + +## Agregar una nueva configuración al sistema + +La forma principal de agregar algo al STP es agregar una entrada a `config/registry.yaml`. No se necesita crear un módulo nuevo para la mayoría de los casos. + +```yaml +# Basta con agregar una línea a la lista: +- id: neovim + type: apt + +# La próxima ejecución de stp.sh la detecta y aplica +``` + +Tipos disponibles en el registro: `ppa`, `apt`, `snap`, `flatpak`, `dotfile`, `service`, `pipewire`, `video`. + +Solo creá un módulo nuevo cuando la configuración requiera pasos que ningún tipo del registro puede manejar (compilación desde fuente, instaladores externos, configuración interactiva, etc.). + +--- + +## Estructura de un módulo + +```bash +#!/usr/bin/env bash +set -euo pipefail +source "$stpRoot/lib/log.sh" +source "$stpRoot/lib/utils.sh" +source "$stpRoot/lib/yaml.sh" # solo si el módulo lee YAML + +readonly nombreConfig="$stpRoot/config/nombre.yaml" + +# Funciones auxiliares específicas (las más pequeñas y concretas) +helperEspecifico() { ... } + +# Funciones principales que usan las auxiliares +hacerAlgo() { + log::info "Descripción breve..." + helperEspecifico + log::ok "Completado" +} + +# Verificación inicial: salir si no hay configuración +if [[ ! -f "$nombreConfig" ]]; then + log::info "Sin configuración para este módulo, salteando" + exit 0 +fi + +# Punto de entrada al final del archivo +hacerAlgo +``` diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..172de24 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +/tmp/ +*.log +.env +# El directorio secrets/ se commitea (contiene archivos cifrados con age), +# pero nunca claves SSH en texto plano +secrets/*.tar.gz +secrets/*.key diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..6ef44e6 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,187 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this project is + +STP (Sistema de Transferencia Personal) is a personal machine-setup automation tool. Running it on a fresh Ubuntu LTS install configures the machine to match the owner's environment: packages, PPAs, SSH keys, dotfiles, audio (PipeWire), video drivers, systemd services, and application configurations. Files (documents, media) are handled separately by Nextcloud and are out of scope. + +## Key commands + +```bash +# First-time setup on a new machine +bash <(curl -fsSL https://gitea.mateosaldain.uy/mateo/stp/raw/branch/main/bootstrap.sh) + +# Run all modules +bash stp.sh + +# Run a single module +bash stp.sh --module registry + +# Skip specific modules +bash stp.sh --skip ssh + +# List modules in execution order +bash stp.sh --list +``` + +## Architecture + +**Execution flow:** `bootstrap.sh` → clone repo from Gitea → `stp.sh` → modules in the order defined by `config/modules` + +**`stp.sh`** reads module order from `config/modules` (plain text, one name per line, `#` comments — no `yq` required at this level), then runs each `modules/.sh` as a subprocess inheriting the exported `stpRoot` variable. + +**Modules** (`modules/.sh`) source `lib/log.sh`, `lib/utils.sh`, and optionally `lib/yaml.sh`. All modules are idempotent — they check current state before acting. + +Current modules in execution order: +- `bootstrap` — installs `yq` and `age` (required by all other modules) +- `ssh` — decrypts `secrets/sshKeys.tar.gz.age` with `age` and installs keys into `~/.ssh` +- `registry` — reads `config/registry.yaml` and applies every entry (packages, dotfiles, services, drivers, audio) +- `thunderbird` — installs Thunderbird (snap) and restores profile from `secrets/thunderbirdProfile.zip` +- `claudeCode` — configures the Anthropic apt repository and installs `claude-code` +- `easyEffects` — restores EasyEffects config and presets from `secrets/easyEffectsConfig.zip` +- `wireplumber` — restores WirePlumber state (default audio device, Bluetooth profiles) from `secrets/wireplumberState.zip` +- `cups` — restores CUPS printer definitions and PPD drivers from `secrets/cupsConfig.zip` + +**`modules/registry.sh`** — the main workhorse. Reads all entries from `config/registry.yaml` and dispatches each one to a handler based on its `type`. Supported types: `ppa`, `apt`, `snap`, `flatpak`, `dotfile`, `service`, `pipewire`, `video`, `docker`. + +**`config/registry.yaml`** — the single list of everything to configure on the machine. Adding a new entry is all that's needed to have STP apply it on the next run. Entries are idempotent — each handler checks current system state before acting. + +**`config/settings.yaml`** — machine-independent settings: Gitea host/user/repo coordinates, user identity (`name`, `email`, `username`), and dotfiles `backup` flag. + +**`lib/`** — shared helpers sourced by modules: +- `log.sh` — `log::step`, `log::info`, `log::ok`, `log::warn`, `log::error`; internal emit via `log::emit` +- `utils.sh` — `util::cmdExists`, `util::isAptInstalled`, `util::isSnapInstalled`, `util::isFlatpakInstalled`, `util::isYamlNull`, `util::confirm`, `util::requireSudo`, `util::keepSudoAlive`, `util::aptUpdateOnce` +- `yaml.sh` — thin wrapper around `yq` v4: `yaml::get`, `yaml::getArray`, `yaml::has` + +**`dotfiles/`** — mirrors `$HOME/` structure. Files referenced by `type: dotfile` entries in the registry become symlinks in the real home directory. If a real file already exists at the destination, it is renamed to `${destination}.stpbackup` before the symlink is created. + +**`docker/`** — stores Docker configurations (Compose files, Dockerfiles, etc.) organized by service: `docker//`. Files are symlinked to the configured `destination` (default `~/docker/`). If `autostart: true`, `docker compose up -d` is run after deploying. + +**`secrets/`** — encrypted and configuration backups, all safe to commit: +- `sshKeys.tar.gz.age` — `age --passphrase`-encrypted tar of `~/.ssh` +- `thunderbirdProfile.zip` — Thunderbird profile (accounts, filters, extensions; excludes emails and cache) +- `easyEffectsConfig.zip` — EasyEffects settings and output presets +- `wireplumberState.zip` — WirePlumber state (default audio sink, Bluetooth device profiles, per-app volumes) +- `cupsConfig.zip` — CUPS `printers.conf` and PPD driver files for all configured printers + +**`scripts/`** — capture scripts to run on the current machine before pushing: +- `encryptSsh.sh` — encrypts `~/.ssh` into `secrets/sshKeys.tar.gz.age` +- `thunderbird/capture.sh` — captures Thunderbird Snap profile into `secrets/thunderbirdProfile.zip` +- `easyEffects/capture.sh` — captures EasyEffects Flatpak config into `secrets/easyEffectsConfig.zip` +- `wireplumber/capture.sh` — captures WirePlumber state into `secrets/wireplumberState.zip` +- `cups/capture.sh` — captures CUPS printer config into `secrets/cupsConfig.zip` (requires sudo) + +**`config/keys/`** — GPG public keys for third-party apt repositories: +- `claude-code.asc` — Anthropic signing key for the Claude Code apt repository + +**`.claude/`** — Claude Code project configuration: +- `settings.json` — allowed bash commands for this project +- `style.md` — style guide (camelCase, Clean Code, no dry-run) +- `commands/new-module.md` → `/new-module ` — scaffolds a new module +- `commands/addEntry.md` → `/addEntry ` — adds an entry to the registry +- `commands/encrypt-ssh.md` → `/encrypt-ssh` — guides SSH key encryption + +## Currently saved configurations + +### Packages (via registry) + +| ID | Type | Package | +|---|---|---| +| curl, wget, git, vim, htop, tree, unzip | apt | same as ID | +| buildEssential | apt | build-essential | +| virtualbox | apt | virtualbox | +| dbeaverCe | snap | dbeaver-ce | +| vscodium | flatpak | com.vscodium.codium | +| filezilla | flatpak | org.filezillaproject.Filezilla | +| angryIpScanner | flatpak | org.angryip.ipscan | +| anydesk | flatpak | com.anydesk.Anydesk | +| bitwarden | flatpak | com.bitwarden.desktop | +| bottles | flatpak | com.usebottles.bottles | +| bruno | flatpak | com.usebruno.Bruno | +| nextcloudDesktop | flatpak | com.nextcloud.desktopclient.nextcloud | +| easyEffects | flatpak | com.github.wwmm.easyeffects | +| flatseal | flatpak | com.github.tchx84.Flatseal | +| warehouse | flatpak | io.github.flattool.Warehouse | +| freecad | flatpak | org.freecad.FreeCAD | +| hidamari | flatpak | io.github.jeffshee.Hidamari | +| inkscape | flatpak | org.inkscape.Inkscape | +| libreoffice | flatpak | org.libreoffice.LibreOffice | +| logseq | flatpak | com.logseq.Logseq | +| obsStudio | flatpak | com.obsproject.Studio | +| openshot | flatpak | org.openshot.OpenShot | + +### Application configurations (via modules) + +| Module | Secret | What is restored | +|---|---|---| +| `ssh` | `secrets/sshKeys.tar.gz.age` | SSH keys with correct permissions | +| `thunderbird` | `secrets/thunderbirdProfile.zip` | Accounts, filters, extensions (Snap; excludes emails) | +| `claudeCode` | `config/keys/claude-code.asc` | Anthropic apt repo + claude-code package | +| `easyEffects` | `secrets/easyEffectsConfig.zip` | Settings DB and output presets (HB-Flat, HB-Lite, HB-Mid, Heavy Bass) | +| `wireplumber` | `secrets/wireplumberState.zip` | Default audio sink (Bluetooth headphones), A2DP profile, per-app volumes | +| `cups` | `secrets/cupsConfig.zip` | 5 printers: Epson L8050 WiFi, Canon MP230, Samsung M2020, POS80, Cups PDF | + +## Adding a new package + +Add an entry to `config/registry.yaml`. The registry handles: `ppa`, `apt`, `snap`, `flatpak`, `dotfile`, `service`, `pipewire`, `video`, `docker`. + +Use `/addEntry ` for guided entry creation. + +For complex setups that require steps beyond what the registry types support (GPG key import, profile restoration, custom installers), create a dedicated module with `/new-module `. + +## Adding a new application configuration backup + +1. Create `scripts//capture.sh` — reads from the app's data directory and writes a ZIP to `secrets/` +2. Create `modules/.sh` — checks if app is installed, checks if config already exists, restores from ZIP +3. Add `` to `config/modules` after `registry` +4. Run `bash scripts//capture.sh` on the current machine +5. Commit the generated file in `secrets/` + +Restoration is automatic on the next `stp.sh` run. All modules are idempotent — if the config already exists on the target machine, the restore is skipped. + +## Updating a saved configuration + +Re-run the corresponding capture script and commit the updated file: + +```bash +bash scripts/thunderbird/capture.sh && git add secrets/thunderbirdProfile.zip +bash scripts/easyEffects/capture.sh && git add secrets/easyEffectsConfig.zip +bash scripts/wireplumber/capture.sh && git add secrets/wireplumberState.zip +bash scripts/cups/capture.sh && git add secrets/cupsConfig.zip +bash scripts/encryptSsh.sh && git add secrets/sshKeys.tar.gz.age +``` + +## Restoring on a new machine + +```bash +# 1. Bootstrap (clones repo and runs all modules) +bash <(curl -fsSL https://gitea.mateosaldain.uy/mateo/stp/raw/branch/main/bootstrap.sh) +``` + +The bootstrap runs all modules automatically. Each module skips gracefully if its secret is missing or if the configuration is already in place. + +**After WirePlumber restore**, restart the session manager to apply the Bluetooth device as default: +```bash +systemctl --user restart wireplumber +``` + +## Environment variables + +| Variable | Default | Effect | +|---|---|---| +| `stpDir` | `~/.stp` | Clone destination used by `bootstrap.sh` | +| `stpLogFile` | `/tmp/stp-.log` | Path for the full execution log | + +## Style guide (enforced on all scripts) + +See `.claude/style.md` for the full guide. Key rules: + +- **camelCase everywhere**: variables, functions, file names — no underscores, no hyphens in identifiers +- **No abbreviations**: `packageName` not `pkg`, `serviceName` not `svc`, `sourceFile` not `src`; `temporaryDirectory` not `tempDir`, `sourceDirectory` not `sourceDir` +- **Small functions with one responsibility**: extract any named block into a function +- **Boolean functions named as questions**: `printersAreConfigured()`, `wireplumberStateExists()` +- **No `--dry-run`**: the system is idempotent — it checks state before acting +- **No `util::run` wrapper**: call commands directly +- **Comments only for WHY**, never for WHAT +- **Pre-increment `((++n))`** not post-increment `((n++))` — post-increment of 0 exits with code 1 under `set -e` diff --git a/README.md b/README.md index f044369..557c306 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,261 @@ -# STP +# STP — Sistema de Transferencia Personal -Sistema de Transferencia Personal \ No newline at end of file +Automatiza la configuración completa de una máquina Ubuntu LTS nueva: paquetes, PPAs, dotfiles, claves SSH, audio, drivers de video, servicios systemd y configuraciones de aplicaciones. + +> Los archivos (documentos, fotos, etc.) los maneja Nextcloud por separado. El STP se ocupa del *entorno* del usuario. + +--- + +## Primeros pasos en una máquina nueva + +**Opción A — ejecución directa:** + +```bash +bash <(curl -fsSL https://gitea.mateosaldain.uy/mateo/stp/raw/branch/main/bootstrap.sh) +``` + +**Opción B — manual:** + +```bash +curl -fsSLo bootstrap.sh https://gitea.mateosaldain.uy/mateo/stp/raw/branch/main/bootstrap.sh +bash bootstrap.sh +``` + +El `bootstrap.sh` instala `git`, `curl` y otras dependencias base si no están disponibles, clona el repo en `~/.stp` (configurable con `stpDir`), y lanza `stp.sh`. + +Durante la ejecución se pedirá la contraseña de las claves SSH cifradas por consola (módulo `ssh`) y permisos de administrador para restaurar impresoras (módulo `cups`). + +**Después de que termine**, reiniciá el gestor de audio para que los auriculares Bluetooth queden como dispositivo por defecto: + +```bash +systemctl --user restart wireplumber +``` + +--- + +## Comandos principales + +```bash +# Ejecutar todos los módulos en orden +bash stp.sh + +# Ejecutar un solo módulo +bash stp.sh --module registry + +# Saltear módulos específicos +bash stp.sh --skip ssh + +# Ver módulos disponibles (en orden de ejecución) +bash stp.sh --list + +# Salida detallada (muestra también los ítems ya instalados) +bash stp.sh --verbose +``` + +El STP es **idempotente**: verifica el estado antes de actuar. Ejecutarlo varias veces es seguro. + +--- + +## Orden de ejecución de módulos + +El orden está definido en `config/modules` (un módulo por línea, `#` para comentarios). + +| Módulo | Qué hace | +|---|---| +| `bootstrap` | Instala `yq` y `age` (requeridos por los demás módulos) | +| `ssh` | Descifra e instala claves SSH en `~/.ssh` | +| `registry` | Aplica todo lo declarado en `config/registry.yaml` | +| `thunderbird` | Instala Thunderbird (snap) y restaura el perfil | +| `claudeCode` | Configura el repositorio apt de Anthropic e instala `claude-code` | +| `easyEffects` | Restaura configuración y presets de EasyEffects | +| `wireplumber` | Restaura el estado de audio: dispositivo Bluetooth por defecto, perfiles, volúmenes por app | +| `cups` | Restaura las impresoras configuradas y sus drivers PPD | + +--- + +## Configuraciones actualmente guardadas + +### Paquetes (via registro) + +| ID | Tipo | Paquete | +|---|---|---| +| curl, wget, git, vim, htop, tree, unzip | apt | igual al ID | +| buildEssential | apt | build-essential | +| virtualbox | apt | virtualbox | +| dbeaverCe | snap | dbeaver-ce | +| vscodium | flatpak | com.vscodium.codium | +| filezilla | flatpak | org.filezillaproject.Filezilla | +| angryIpScanner | flatpak | org.angryip.ipscan | +| anydesk | flatpak | com.anydesk.Anydesk | +| bitwarden | flatpak | com.bitwarden.desktop | +| bottles | flatpak | com.usebottles.bottles | +| bruno | flatpak | com.usebruno.Bruno | +| nextcloudDesktop | flatpak | com.nextcloud.desktopclient.nextcloud | +| easyEffects | flatpak | com.github.wwmm.easyeffects | +| flatseal | flatpak | com.github.tchx84.Flatseal | +| warehouse | flatpak | io.github.flattool.Warehouse | +| freecad | flatpak | org.freecad.FreeCAD | +| hidamari | flatpak | io.github.jeffshee.Hidamari | +| inkscape | flatpak | org.inkscape.Inkscape | +| libreoffice | flatpak | org.libreoffice.LibreOffice | +| logseq | flatpak | com.logseq.Logseq | +| obsStudio | flatpak | com.obsproject.Studio | +| openshot | flatpak | org.openshot.OpenShot | + +### Configuraciones de aplicaciones (via módulos) + +| Módulo | Archivo | Qué se restaura | +|---|---|---| +| `ssh` | `secrets/sshKeys.tar.gz.age` | Claves SSH con permisos correctos | +| `thunderbird` | `secrets/thunderbirdProfile.zip` | Cuentas, filtros y extensiones (excluye emails) | +| `claudeCode` | `config/keys/claude-code.asc` | Repositorio apt de Anthropic + paquete claude-code | +| `easyEffects` | `secrets/easyEffectsConfig.zip` | Configuración y presets de salida (HB-Flat, HB-Lite, HB-Mid, Heavy Bass) | +| `wireplumber` | `secrets/wireplumberState.zip` | Auriculares Bluetooth como salida por defecto, perfil A2DP, volúmenes por app | +| `cups` | `secrets/cupsConfig.zip` | 5 impresoras: Epson L8050 WiFi, Canon MP230, Samsung M2020, POS80, Cups PDF | + +--- + +## Actualizar una configuración guardada + +Volvé a ejecutar el script de captura correspondiente y commiteá el archivo generado: + +```bash +bash scripts/encryptSsh.sh && git add secrets/sshKeys.tar.gz.age +bash scripts/thunderbird/capture.sh && git add secrets/thunderbirdProfile.zip +bash scripts/easyEffects/capture.sh && git add secrets/easyEffectsConfig.zip +bash scripts/wireplumber/capture.sh && git add secrets/wireplumberState.zip +bash scripts/cups/capture.sh && git add secrets/cupsConfig.zip +``` + +--- + +## Agregar un paquete nuevo + +Basta con agregar una entrada a `config/registry.yaml`. La próxima vez que corra el STP, detectará la nueva entrada y la aplicará. + +```bash +# Desde Claude Code — agrega la entrada correctamente formateada +/addEntry neovim apt +/addEntry gimp flatpak appId=org.gimp.GIMP +/addEntry bashrc dotfile +``` + +O editá directamente `config/registry.yaml`: + +```yaml +registry: + - id: neovim + type: apt + + - id: vscode + type: snap + package: code + classic: true + + - id: gimp + type: flatpak + appId: org.gimp.GIMP + + - id: bash + type: dotfile + + - id: networkManager + type: service + scope: system + state: enable +``` + +### Tipos disponibles en el registro + +| Tipo | Qué hace | +|---|---| +| `ppa` | Agrega un repositorio PPA a apt | +| `apt` | Instala un paquete con apt (soporta `minVersion` / `maxVersion`) | +| `snap` | Instala un paquete snap | +| `flatpak` | Instala una aplicación Flatpak | +| `dotfile` | Enlaza todos los archivos de `dotfiles//` a sus rutas en `$HOME/` | +| `service` | Habilita, deshabilita o mask un servicio systemd | +| `pipewire` | Instala y configura PipeWire como sistema de audio | +| `video` | Detecta la GPU y instala los drivers correspondientes | +| `docker` | Despliega archivos de configuración Docker desde `docker//` | + +--- + +## Agregar una nueva configuración de aplicación + +Para guardar y restaurar la configuración de una app nueva: + +1. Crear `scripts//capture.sh` — lee desde el directorio de datos de la app y genera un ZIP en `secrets/` +2. Crear `modules/.sh` — verifica si la app está instalada y si ya hay config; si no, restaura desde el ZIP +3. Agregar `` en `config/modules` después de `registry` +4. Ejecutar `bash scripts//capture.sh` en la máquina actual +5. Commitear el archivo generado en `secrets/` + +La restauración es automática en el próximo `stp.sh`. Si la config ya existe en la máquina destino, el módulo la saltea. + +```bash +# Desde Claude Code — genera la estructura completa +/new-module nombreApp +``` + +--- + +## Dotfiles + +Cada aplicación tiene su propia carpeta dentro de `dotfiles/`. Dentro de esa carpeta, los archivos respetan la misma ruta que tendrían en `$HOME/`: + +``` +dotfiles/ +├── bash/ +│ └── .bashrc +├── git/ +│ ├── .gitconfig +│ └── .gitignore_global +└── neovim/ + └── .config/ + └── nvim/ + └── init.lua +``` + +Para activar una carpeta, basta con agregar una entrada en el registro: + +```yaml +- id: bash + type: dotfile + +- id: neovim + type: dotfile +``` + +El STP recorre todos los archivos de `dotfiles/bash/` y crea un enlace simbólico por cada uno en su ruta equivalente dentro de `$HOME/`. Si un archivo ya existe, guarda una copia con extensión `.stpbackup` antes de reemplazarlo. + +--- + +## Claves SSH + +Las claves se almacenan cifradas en el repo como `secrets/sshKeys.tar.gz.age`. El archivo está cifrado con `age` usando una contraseña y **es seguro hacer commit de él**. + +**Cifrar las claves actuales** (correr en la máquina con las claves): + +```bash +bash scripts/encryptSsh.sh +# → genera secrets/sshKeys.tar.gz.age +git add secrets/sshKeys.tar.gz.age && git commit -m "update ssh keys" +``` + +**Verificar el cifrado antes de hacer commit:** + +```bash +age --decrypt secrets/sshKeys.tar.gz.age | tar -tzv +``` + +Durante la ejecución del STP, el módulo `ssh` pide la contraseña, descifra el archivo e instala las claves en `~/.ssh` con los permisos correctos. Las claves que ya existen se saltean sin sobrescribir. + +--- + +## Variables de entorno + +| Variable | Predeterminado | Efecto | +|---|---|---| +| `stpDir` | `~/.stp` | Directorio de clonado (bootstrap) | +| `stpLogFile` | `/tmp/stp-.log` | Ruta del log completo | diff --git a/bootstrap.sh b/bootstrap.sh new file mode 100755 index 0000000..9c998ac --- /dev/null +++ b/bootstrap.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Corré esto en una máquina nueva. Clona el repo STP y lanza stp.sh. +# +# Uso: +# bash <(curl -fsSL https://gitea.mateosaldain.uy/mateo/stp/raw/branch/main/bootstrap.sh) +# +# O descargá y ejecutá manualmente: +# curl -fsSLo bootstrap.sh https://gitea.mateosaldain.uy/mateo/stp/raw/branch/main/bootstrap.sh +# bash bootstrap.sh + +set -euo pipefail + +readonly giteaHost="gitea.mateosaldain.uy" +readonly giteaUser="mateo" +readonly repoName="stp" +readonly cloneUrl="https://${giteaHost}/${giteaUser}/${repoName}.git" +cloneDir="${stpDir:-$HOME/.stp}" + +echo "=== Sistema de Transferencia Personal ===" +echo "" + +if ! command -v git &>/dev/null || ! command -v curl &>/dev/null; then + echo "-> Instalando git y curl..." + sudo apt-get update -qq + sudo apt-get install -y git curl +fi + +if [[ -d "$cloneDir/.git" ]]; then + echo "-> Actualizando repositorio STP en $cloneDir..." + git -C "$cloneDir" pull --ff-only +else + echo "-> Clonando repositorio STP en $cloneDir..." + git clone "$cloneUrl" "$cloneDir" +fi + +chmod +x "$cloneDir/stp.sh" + +echo "" +echo "-> Lanzando STP..." +exec bash "$cloneDir/stp.sh" "$@" diff --git a/config/keys/claude-code.asc b/config/keys/claude-code.asc new file mode 100644 index 0000000..69a4aa5 --- /dev/null +++ b/config/keys/claude-code.asc @@ -0,0 +1,29 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQINBGnK73ABEACnbytJXkjweYrwIr0aLEFRlH+C0nF44KxFc7gQmJ6PjSPMGZAD +dxZcaixU7zZl8WxEpVO0wLmIH8cf2zGOdyuZg1Yaugk1vHb2b8WBhAGCQJdPgB8W +XquedepEYtk56uP/gCoTjJDUZluEGBHnlnuujSJ4orxEdhSykEoAUfJZGEILPpMd +bphFt/Sn+Eb/TxM5jpKPdwnv8AShNF/1mZU1fWTQq9tRKJUakZj04gdaDFElQXak +CtTij+GT6yoYCARSHwGO+PC/Pr6q4tc+D7LRjxSBvUWDoFSmlqb/PJ1hj9D/7I2O +e4XXniAPWMR56KvxHlzOzrNQdJujbJdSkCwh1ZijkSd3y8ayW5WYUTGdRab99NUw +agzlabe/VVF6kzJ0Scn5q3PihB2Y9Bwo0CKnkYk7a7KT77EWv0Kkq+VHmOtqX3a2 +hhX+b6a6ve9rzJ1qZYGj+obv/C3Sx1LzUjAfqVy7RJDf2uAoP5t2g8u/TkSpUxhM +VEjZBkSxYZhMyzQM6t8IgkUfnSrIPTHixbDWARZ4beMOBjxyPZK1nP7OOrNR3TkK +JtwLMQAabURCDnL0PjS0iwBTU4jtumBD1XSULyWuoTvMljrpQr1nV1oDyOt0OLqa +KA2McWtd9PdXhC8y2EIg7TmrTlJLfHYbdmkiCYj4J49Q8HWkN/6WE+RTUwARAQAB +tD5BbnRocm9waWMgQ2xhdWRlIENvZGUgUmVsZWFzZSBTaWduaW5nIDxzZWN1cml0 +eUBhbnRocm9waWMuY29tPokCUQQTAQoAOxYhBDHd3iTd+rZ59C170rqpKf8afsrO +BQJpyu9wAhsPBQsJCAcCAiICBhUKCQgLAgQWAgMBAh4HAheAAAoJELqpKf8afsrO +l5IP/2I8X1dFy5xYczWB/coIxGjuzS/V6ByZGZZEJsbr04pmuHiFUykJqPGWGQ6q +U0YF5iEwvEkaagS5m7DzhSEf3FM3Cgafax/6d70tar9Vr1D+w6uPfxetu7u/WYJp +aolIsdh5fTrBh9zSM1Njl8FM8wG8CwZQjS33Oa7d8cwRkgdUWbt6LXgz+cTQNuBn +BgW6Ks7oZFI25dfu0ojDR+aDFJg4+4wZoyDLPvJz1SIrJ5WFGs67zsx9SfS3yZnf +XKmBe+f0dUy+GJ2nFZrXFf99+c0dPEHYO8DCeAHZizjkFrdYtUHdDU0YDYEGkLJa +bE+pgcpkHf5EvsZzHsyDbl95W/eh7pcXMbwkN+W4CBYUE9X4uHhqzWaC5yAVRWUA +1BJ9V4LjZfHPLEJt0I3TxzXiEg9/BVeaTYq9RjaxIFo9Nfk158HqJY6SA5jslBlx +Gv/No8u+xVcze2UJyGVfEIUfm92+0UAIkny3+5cuVV0ICzJxXlXj0CnLM9Lt50wE +p3suVwuBEviCbZ08eAH1Ht8gbBdSsiOkIU8CX3v/scwHHx5q0+NBL6xLrQObg13a +tRXBlKObfElkPN3lTUbUnJOW4U8uSjH8VRP+AujKWMDFe7x0zCs+iYY1mTOvbrTS +9n3CmZUmbynZ+E/QWNENpW/pDNZdWFy43PASmML5FHu4m9Sn +=oqMI +-----END PGP PUBLIC KEY BLOCK----- diff --git a/config/modules b/config/modules new file mode 100644 index 0000000..3b98344 --- /dev/null +++ b/config/modules @@ -0,0 +1,12 @@ +# Orden de ejecución de los módulos STP. +# Una entrada por línea. Líneas con # son comentarios. +# El nombre corresponde a modules/.sh + +bootstrap +ssh +registry +thunderbird +claudeCode +easyEffects +wireplumber +cups diff --git a/config/registry.yaml b/config/registry.yaml new file mode 100644 index 0000000..2969ec5 --- /dev/null +++ b/config/registry.yaml @@ -0,0 +1,180 @@ +# Registro unificado de configuraciones STP +# +# Para agregar algo nuevo: basta con agregar una entrada a esta lista. +# El próximo run del STP detectará la entrada y la aplicará. +# Las entradas son idempotentes — ejecutar el STP múltiples veces es seguro. +# +# Tipos disponibles: +# ppa — repositorio PPA de Ubuntu +# apt — paquete del gestor apt +# snap — paquete snap +# flatpak — aplicación Flatpak +# dotfile — archivo de configuración (symlink en $HOME) +# service — servicio systemd (enable / disable / mask) +# pipewire — configuración completa de PipeWire +# video — drivers de video (detección automática de GPU) +# +# Orden importa: los PPAs deben aparecer antes de los paquetes que los requieren. + +registry: + + # PPAs + # - id: gitPpa + # type: ppa + # address: ppa:git-core/ppa + + # Paquetes apt + # El campo "package" es opcional si el id coincide con el nombre real del paquete. + - id: curl + type: apt + + - id: wget + type: apt + + - id: git + type: apt + + - id: vim + type: apt + + - id: htop + type: apt + + - id: tree + type: apt + + - id: unzip + type: apt + + - id: buildEssential + type: apt + package: build-essential + + - id: virtualbox + type: apt + + # Snaps + - id: dbeaverCe + type: snap + package: dbeaver-ce + + # Flatpaks + - id: vscodium + type: flatpak + appId: com.vscodium.codium + + - id: filezilla + type: flatpak + appId: org.filezillaproject.Filezilla + + - id: angryIpScanner + type: flatpak + appId: org.angryip.ipscan + + - id: anydesk + type: flatpak + appId: com.anydesk.Anydesk + + - id: bitwarden + type: flatpak + appId: com.bitwarden.desktop + + - id: bottles + type: flatpak + appId: com.usebottles.bottles + + - id: bruno + type: flatpak + appId: com.usebruno.Bruno + + - id: nextcloudDesktop + type: flatpak + appId: com.nextcloud.desktopclient.nextcloud + + - id: easyEffects + type: flatpak + appId: com.github.wwmm.easyeffects + + - id: flatseal + type: flatpak + appId: com.github.tchx84.Flatseal + + - id: warehouse + type: flatpak + appId: io.github.flattool.Warehouse + + - id: freecad + type: flatpak + appId: org.freecad.FreeCAD + + - id: hidamari + type: flatpak + appId: io.github.jeffshee.Hidamari + + - id: inkscape + type: flatpak + appId: org.inkscape.Inkscape + + - id: libreoffice + type: flatpak + appId: org.libreoffice.LibreOffice + + - id: logseq + type: flatpak + appId: com.logseq.Logseq + + - id: obsStudio + type: flatpak + appId: com.obsproject.Studio + + - id: openshot + type: flatpak + appId: org.openshot.OpenShot + + # VMware Workstation/Player requiere el instalador .bundle descargado desde vmware.com. + # No puede instalarse vía registro. Creá un módulo dedicado con: /new-module vmware + + # Dotfiles + # Cada entrada apunta a una carpeta dentro de dotfiles//. + # Los archivos dentro se enlazan automáticamente a su ruta equivalente en $HOME/. + # Ejemplo: dotfiles/bash/.bashrc → ~/.bashrc + + # Servicios systemd + # - id: networkManager + # type: service + # name: NetworkManager # nombre real del servicio (si difiere del id) + # scope: system # system o user + # state: enable # enable, disable o mask + + # PipeWire + # - id: pipewire + # type: pipewire + # replacePulseaudio: true + # packages: + # - pipewire + # - pipewire-audio + # - pipewire-pulse + # - wireplumber + # - libspa-0.2-bluetooth + # userServices: + # - pipewire + # - pipewire-pulse + # - wireplumber + + # Video (detección automática de GPU con lspci) + # - id: gpuDrivers + # type: video + # drivers: + # nvidia: + # packages: + # - nvidia-driver-535 + # - nvidia-settings + # amd: + # packages: + # - mesa-vulkan-drivers + # - radeontop + # - vainfo + # intel: + # packages: + # - intel-media-va-driver + # - vainfo diff --git a/config/settings.yaml b/config/settings.yaml new file mode 100644 index 0000000..bf31e93 --- /dev/null +++ b/config/settings.yaml @@ -0,0 +1,14 @@ +gitea: + host: gitea.mateosaldain.uy + user: mateo + repo: stp + +user: + name: Mateo Saldain + email: saldainmateo@gmail.com + username: mateo + +dotfiles: + # Crea symlinks de dotfiles/ hacia $HOME. Con backup:true guarda los archivos + # existentes en ~/.stp-backup// antes de reemplazarlos. + backup: true diff --git a/lib/log.sh b/lib/log.sh new file mode 100644 index 0000000..a5ce3d6 --- /dev/null +++ b/lib/log.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash + +readonly clrRed='\033[0;31m' +readonly clrGreen='\033[0;32m' +readonly clrYellow='\033[1;33m' +readonly clrBlue='\033[0;34m' +readonly clrCyan='\033[0;36m' +readonly clrBold='\033[1m' +readonly clrReset='\033[0m' + +stpLogFile="${stpLogFile:-/tmp/stp-$(date +%Y%m%d-%H%M%S).log}" +export stpLogFile + +log::emit() { + local level="$1" color="$2" symbol="$3" + shift 3 + local message="$*" + local timestamp + timestamp="$(date '+%H:%M:%S')" + printf "${color}${symbol}${clrReset} %s\n" "$message" + printf "[%s] [%-5s] %s\n" "$timestamp" "$level" "$message" >> "$stpLogFile" +} + +log::step() { log::emit "STEP" "${clrBold}${clrBlue}" "==>" "$@"; } +log::info() { log::emit "INFO" "${clrCyan}" " -> " "$@"; } +log::ok() { log::emit "OK" "${clrGreen}" " ✓ " "$@"; } +log::warn() { log::emit "WARN" "${clrYellow}" " ! " "$@"; } +log::error() { log::emit "ERROR" "${clrRed}" " ✗ " "$@"; } diff --git a/lib/utils.sh b/lib/utils.sh new file mode 100644 index 0000000..744c2f1 --- /dev/null +++ b/lib/utils.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash + +util::cmdExists() { command -v "$1" &>/dev/null; } +util::isAptInstalled() { dpkg -l "$1" 2>/dev/null | grep -q "^ii"; } +util::isSnapInstalled() { snap list "$1" &>/dev/null 2>&1; } +util::isFlatpakInstalled() { flatpak list --app --columns=application 2>/dev/null | grep -q "^$1$"; } +util::isYamlNull() { [[ -z "$1" || "$1" == "null" ]]; } + +util::confirm() { + local question="${1:-¿Continuar?} [s/N] " + local answer + read -rp "$question" answer + [[ "${answer,,}" == "s" || "${answer,,}" == "si" || "${answer,,}" == "sí" ]] +} + +util::keepSudoAlive() { + while true; do + sudo -n true + sleep 50 + kill -0 "$$" || exit + done 2>/dev/null & +} + +util::requireSudo() { + [[ "$EUID" -eq 0 ]] && return + if ! sudo -n true 2>/dev/null; then + log::info "Se requieren permisos de administrador" + sudo -v + fi + util::keepSudoAlive +} + +util::aptUpdateOnce() { + if [[ "${aptUpdated:-false}" != "true" ]]; then + log::info "Actualizando índices de apt..." + sudo apt-get update -qq + aptUpdated=true + export aptUpdated + fi +} diff --git a/lib/yaml.sh b/lib/yaml.sh new file mode 100644 index 0000000..c9d6c1f --- /dev/null +++ b/lib/yaml.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash + +yaml::get() { + local file="$1" query="$2" + yq "$query" "$file" +} + +yaml::getArray() { + local file="$1" query="$2" + yq -r "${query} | .[]" "$file" 2>/dev/null || true +} + +yaml::has() { + local file="$1" query="$2" + local value + value="$(yq "$query" "$file" 2>/dev/null)" + [[ -n "$value" && "$value" != "null" ]] +} diff --git a/modules/bootstrap.sh b/modules/bootstrap.sh new file mode 100755 index 0000000..9369983 --- /dev/null +++ b/modules/bootstrap.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +set -euo pipefail +source "$stpRoot/lib/log.sh" +source "$stpRoot/lib/utils.sh" + +latestGithubReleaseTag() { + local repository="$1" + curl -fsSL "https://api.github.com/repos/${repository}/releases/latest" \ + | grep '"tag_name"' \ + | cut -d'"' -f4 +} + +ensureBasePackagesAreInstalled() { + local requiredPackages=(curl wget git nano gpg ca-certificates) + local missingPackages=() + + for packageName in "${requiredPackages[@]}"; do + util::isAptInstalled "$packageName" || missingPackages+=("$packageName") + done + + if [[ ${#missingPackages[@]} -gt 0 ]]; then + util::aptUpdateOnce + sudo apt-get install -y "${missingPackages[@]}" + fi +} + +installYqFromGithub() { + local version + version="$(latestGithubReleaseTag "mikefarah/yq")" + sudo wget -qO /usr/local/bin/yq \ + "https://github.com/mikefarah/yq/releases/download/${version}/yq_linux_amd64" + sudo chmod +x /usr/local/bin/yq + log::ok "yq ${version} instalado" +} + +ensureYqIsInstalled() { + if util::cmdExists yq; then + log::info "yq ya disponible: $(yq --version 2>&1 | head -1)" + return + fi + log::info "Instalando yq..." + installYqFromGithub +} + +ageIsAvailableInApt() { + apt-cache show age &>/dev/null 2>&1 +} + +installAgeFromApt() { + sudo apt-get install -y age +} + +installAgeFromGithub() { + local version + version="$(latestGithubReleaseTag "FiloSottile/age")" + local temporaryDirectory + temporaryDirectory="$(mktemp -d)" + trap 'rm -rf "$temporaryDirectory"' RETURN + curl -fsSL \ + "https://github.com/FiloSottile/age/releases/download/${version}/age-${version}-linux-amd64.tar.gz" \ + | tar -xz -C "$temporaryDirectory" + sudo mv "$temporaryDirectory/age/age" "$temporaryDirectory/age/age-keygen" /usr/local/bin/ +} + +ensureAgeIsInstalled() { + if util::cmdExists age; then + log::info "age ya disponible: $(age --version 2>&1)" + return + fi + log::info "Instalando age..." + if ageIsAvailableInApt; then + installAgeFromApt + else + installAgeFromGithub + fi + log::ok "age instalado" +} + +log::info "Verificando dependencias base" +ensureBasePackagesAreInstalled +ensureYqIsInstalled +ensureAgeIsInstalled diff --git a/modules/claudeCode.sh b/modules/claudeCode.sh new file mode 100644 index 0000000..a759149 --- /dev/null +++ b/modules/claudeCode.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +set -euo pipefail +source "$stpRoot/lib/log.sh" +source "$stpRoot/lib/utils.sh" + +readonly claudeCodeKeyring="/etc/apt/keyrings/claude-code.asc" +readonly claudeCodeSources="/etc/apt/sources.list.d/claude-code.list" +readonly claudeCodeSourcesEntry="deb [signed-by=${claudeCodeKeyring}] https://downloads.claude.ai/claude-code/apt/stable stable main" + +repoIsConfigured() { + [[ -f "$claudeCodeSources" ]] +} + +addSigningKey() { + sudo install -m 644 "$stpRoot/config/keys/claude-code.asc" "$claudeCodeKeyring" + log::ok "Clave GPG instalada: $claudeCodeKeyring" +} + +addRepository() { + echo "$claudeCodeSourcesEntry" | sudo tee "$claudeCodeSources" > /dev/null + sudo apt-get update -qq + log::ok "Repositorio configurado: $claudeCodeSources" +} + +if repoIsConfigured; then + log::info "Repositorio claude-code ya configurado" +else + addSigningKey + addRepository +fi + +if util::isAptInstalled "claude-code"; then + log::info "Ya instalado (apt): claude-code" +else + sudo apt-get install -y claude-code + log::ok "Instalado: claude-code" +fi diff --git a/modules/cups.sh b/modules/cups.sh new file mode 100644 index 0000000..deaec42 --- /dev/null +++ b/modules/cups.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +set -euo pipefail +source "$stpRoot/lib/log.sh" +source "$stpRoot/lib/utils.sh" + +readonly cupsArchive="$stpRoot/secrets/cupsConfig.zip" + +cupsIsInstalled() { + util::cmdExists lpstat +} + +printersAreConfigured() { + sudo test -s /etc/cups/printers.conf +} + +restoreConfig() { + log::info "Restaurando configuración de CUPS desde secrets/cupsConfig.zip..." + unzip -q "$cupsArchive" -d "$cupsTemp" + + sudo cp "$cupsTemp/printers.conf" /etc/cups/printers.conf + sudo chown root:lp /etc/cups/printers.conf + sudo chmod 600 /etc/cups/printers.conf + + if [[ -d "$cupsTemp/ppd" ]]; then + sudo mkdir -p /etc/cups/ppd + sudo cp -r "$cupsTemp/ppd/." /etc/cups/ppd/ + sudo chown -R root:lp /etc/cups/ppd/ + sudo chmod 644 /etc/cups/ppd/*.ppd 2>/dev/null || true + fi + + sudo systemctl restart cups + log::ok "Configuración de CUPS restaurada" +} + +if ! cupsIsInstalled; then + log::info "CUPS no instalado, salteando" + exit 0 +fi + +if [[ ! -f "$cupsArchive" ]]; then + log::info "Sin respaldo de CUPS (secrets/cupsConfig.zip), salteando" + exit 0 +fi + +util::requireSudo + +if printersAreConfigured; then + log::info "CUPS ya tiene impresoras configuradas, salteando restauración" + exit 0 +fi + +cupsTemp="$(mktemp -d)" +trap 'rm -rf "$cupsTemp"' EXIT + +restoreConfig diff --git a/modules/easyEffects.sh b/modules/easyEffects.sh new file mode 100644 index 0000000..af310f5 --- /dev/null +++ b/modules/easyEffects.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +set -euo pipefail +source "$stpRoot/lib/log.sh" +source "$stpRoot/lib/utils.sh" + +readonly easyEffectsAppId="com.github.wwmm.easyeffects" +readonly easyEffectsApplicationDirectory="$HOME/.var/app/com.github.wwmm.easyeffects" +readonly easyEffectsConfig="$easyEffectsApplicationDirectory/config/easyeffects/db/easyeffectsrc" +readonly easyEffectsArchive="$stpRoot/secrets/easyEffectsConfig.zip" + +easyEffectsIsInstalled() { + util::isFlatpakInstalled "$easyEffectsAppId" +} + +easyEffectsConfigExists() { + [[ -f "$easyEffectsConfig" ]] +} + +restoreConfig() { + log::info "Restaurando configuración desde secrets/easyEffectsConfig.zip..." + mkdir -p "$easyEffectsApplicationDirectory" + unzip -qo "$easyEffectsArchive" -d "$easyEffectsApplicationDirectory" + log::ok "Configuración restaurada en: $easyEffectsApplicationDirectory" +} + +if ! easyEffectsIsInstalled; then + log::info "EasyEffects no instalado aún, salteando restauración de config" + exit 0 +fi + +if [[ ! -f "$easyEffectsArchive" ]]; then + log::info "Sin respaldo de configuración (secrets/easyEffectsConfig.zip), salteando" + exit 0 +fi + +if easyEffectsConfigExists; then + log::info "Configuración de EasyEffects ya existe, salteando restauración" + exit 0 +fi + +restoreConfig diff --git a/modules/registry.sh b/modules/registry.sh new file mode 100755 index 0000000..112d703 --- /dev/null +++ b/modules/registry.sh @@ -0,0 +1,414 @@ +#!/usr/bin/env bash +set -euo pipefail +source "$stpRoot/lib/log.sh" +source "$stpRoot/lib/utils.sh" +source "$stpRoot/lib/yaml.sh" + +readonly registryConfig="$stpRoot/config/registry.yaml" + +fieldOf() { + local entryId="$1" field="$2" default="${3:-}" + local value + value="$(yq ".registry[] | select(.id == \"$entryId\") | .${field}" "$registryConfig" 2>/dev/null)" + if util::isYamlNull "$value"; then + echo "$default" + else + echo "$value" + fi +} + +arrayFieldOf() { + local entryId="$1" field="$2" + yq -r ".registry[] | select(.id == \"$entryId\") | .${field}[]" "$registryConfig" 2>/dev/null || true +} + +# ---- PPA ---- + +ppaOwnerFrom() { + local ppaAddress="$1" + local owner="${ppaAddress#ppa:}" + echo "${owner%%/*}" +} + +ppaIsAlreadyAdded() { + local ppaAddress="$1" + find /etc/apt/sources.list.d/ -name "*$(ppaOwnerFrom "$ppaAddress")*" 2>/dev/null | grep -q . +} + +applyPpa() { + local entryId="$1" + local ppaAddress + ppaAddress="$(fieldOf "$entryId" "address")" + + if ppaIsAlreadyAdded "$ppaAddress"; then + log::info "PPA ya existe: $ppaAddress" + return + fi + + util::cmdExists add-apt-repository \ + || sudo apt-get install -y software-properties-common + + sudo add-apt-repository -y "$ppaAddress" + sudo apt-get update -qq + log::ok "PPA agregado: $ppaAddress" +} + +# ---- APT ---- + +installedVersionOf() { + local packageName="$1" + dpkg-query -W -f='${Version}' "$packageName" 2>/dev/null +} + +aptVersionIsInRange() { + local installedVersion="$1" minVersion="$2" maxVersion="$3" + if [[ -n "$minVersion" ]] && dpkg --compare-versions "$installedVersion" lt "$minVersion"; then + return 1 + fi + if [[ -n "$maxVersion" ]] && dpkg --compare-versions "$installedVersion" gt "$maxVersion"; then + return 1 + fi + return 0 +} + +applyAptPackage() { + local entryId="$1" + local packageName minVersion maxVersion + packageName="$(fieldOf "$entryId" "package" "$entryId")" + minVersion="$(fieldOf "$entryId" "minVersion")" + maxVersion="$(fieldOf "$entryId" "maxVersion")" + + if util::isAptInstalled "$packageName"; then + local installedVersion + installedVersion="$(installedVersionOf "$packageName")" + if aptVersionIsInRange "$installedVersion" "$minVersion" "$maxVersion"; then + log::info "Ya instalado (apt): $packageName ($installedVersion)" + return + fi + log::warn "$packageName: versión $installedVersion instalada, fuera del rango requerido" + fi + + util::aptUpdateOnce + if [[ -n "$maxVersion" ]]; then + sudo apt-get install -y --allow-downgrades "${packageName}=${maxVersion}" + log::ok "Instalado (apt): ${packageName}=${maxVersion}" + else + sudo apt-get install -y "$packageName" + log::ok "Instalado (apt): $packageName" + fi +} + +# ---- SNAP ---- + +applySnapPackage() { + local entryId="$1" + local packageName classicFlag installFlags + packageName="$(fieldOf "$entryId" "package" "$entryId")" + classicFlag="$(fieldOf "$entryId" "classic" "false")" + installFlags=() + [[ "$classicFlag" == "true" ]] && installFlags+=(--classic) + + if util::isSnapInstalled "$packageName"; then + log::info "Ya instalado (snap): $packageName" + return + fi + + if ! util::cmdExists snap; then + log::warn "snap no disponible, salteando: $entryId" + return + fi + + sudo snap install "$packageName" "${installFlags[@]}" + log::ok "Instalado (snap): $packageName" +} + +# ---- FLATPAK ---- + +applyFlatpakApp() { + local entryId="$1" + local appId remote + appId="$(fieldOf "$entryId" "appId" "$entryId")" + remote="$(fieldOf "$entryId" "remote" "flathub")" + + if util::isFlatpakInstalled "$appId"; then + log::info "Ya instalado (flatpak): $appId" + return + fi + + if ! util::cmdExists flatpak; then + log::warn "flatpak no disponible, salteando: $entryId" + return + fi + + flatpak install -y "$remote" "$appId" + log::ok "Instalado (flatpak): $appId" +} + +# ---- DOTFILE ---- + +dotfileSymlinkAlreadyExists() { + local destination="$1" + [[ -L "$destination" ]] +} + +backupExistingFile() { + local destination="$1" + [[ -e "$destination" ]] || return 0 + mv "$destination" "${destination}.stpbackup" + log::info "Copia de seguridad: ~/${destination#${HOME}/}" +} + +deployFileFromElement() { + local sourceFile="$1" elementSourceDirectory="$2" + local relativePath destination + relativePath="${sourceFile#${elementSourceDirectory}/}" + destination="$HOME/$relativePath" + + if dotfileSymlinkAlreadyExists "$destination"; then + log::info "Enlace ya existe: ~/$relativePath" + return + fi + + backupExistingFile "$destination" + mkdir -p "$(dirname "$destination")" + ln -sfn "$sourceFile" "$destination" + log::ok "Enlazado: ~/$relativePath" +} + +applyDotfile() { + local entryId="$1" + local elementSourceDirectory="$stpRoot/dotfiles/$entryId" + + if [[ ! -d "$elementSourceDirectory" ]]; then + log::warn "Carpeta no encontrada: dotfiles/$entryId/" + return + fi + + while IFS= read -r sourceFile; do + deployFileFromElement "$sourceFile" "$elementSourceDirectory" + done < <(find "$elementSourceDirectory" -not -type d | sort) +} + +# ---- SERVICE ---- + +applyService() { + local entryId="$1" + local serviceName scope state + serviceName="$(fieldOf "$entryId" "name" "$entryId")" + scope="$(fieldOf "$entryId" "scope" "system")" + state="$(fieldOf "$entryId" "state" "enable")" + + local scopeFlags=() + [[ "$scope" == "user" ]] && scopeFlags+=(--user) + + case "$state" in + enable) + systemctl "${scopeFlags[@]}" enable --now "$serviceName" 2>/dev/null \ + || log::warn "No se pudo habilitar: $serviceName" + ;; + disable) + systemctl "${scopeFlags[@]}" disable --now "$serviceName" 2>/dev/null || true + ;; + mask) + systemctl "${scopeFlags[@]}" mask "$serviceName" 2>/dev/null || true + ;; + esac + log::ok "Servicio [$scope] $state: $serviceName" +} + +# ---- PIPEWIRE ---- + +allPipewirePackagesAreInstalled() { + local entryId="$1" + while IFS= read -r packageName; do + util::isYamlNull "$packageName" && continue + util::isAptInstalled "$packageName" || return 1 + done < <(arrayFieldOf "$entryId" "packages") +} + +installMissingPipewirePackages() { + local entryId="$1" + local missingPackages=() + while IFS= read -r packageName; do + util::isYamlNull "$packageName" && continue + util::isAptInstalled "$packageName" || missingPackages+=("$packageName") + done < <(arrayFieldOf "$entryId" "packages") + + if [[ ${#missingPackages[@]} -gt 0 ]]; then + util::aptUpdateOnce + sudo apt-get install -y "${missingPackages[@]}" + fi +} + +disablePulseaudio() { + systemctl --user --now disable pulseaudio.service pulseaudio.socket 2>/dev/null || true + systemctl --user mask pulseaudio 2>/dev/null || true +} + +enablePipewireUserServices() { + local entryId="$1" + while IFS= read -r serviceName; do + util::isYamlNull "$serviceName" && continue + systemctl --user --now enable "$serviceName" 2>/dev/null \ + || log::warn "No se pudo habilitar servicio de usuario: $serviceName" + done < <(arrayFieldOf "$entryId" "userServices") +} + +applyPipewire() { + local entryId="$1" + + allPipewirePackagesAreInstalled "$entryId" \ + || installMissingPipewirePackages "$entryId" + + local shouldReplace + shouldReplace="$(fieldOf "$entryId" "replacePulseaudio" "false")" + [[ "$shouldReplace" == "true" ]] && disablePulseaudio + + enablePipewireUserServices "$entryId" + log::ok "PipeWire configurado" +} + +# ---- VIDEO ---- + +detectGpuVendor() { + if lspci 2>/dev/null | grep -qi nvidia; then echo "nvidia" + elif lspci 2>/dev/null | grep -qi "amd\|radeon"; then echo "amd" + elif lspci 2>/dev/null | grep -qi intel; then echo "intel" + else echo "unknown" + fi +} + +applyVideoDrivers() { + local entryId="$1" + local gpuVendor + gpuVendor="$(detectGpuVendor)" + log::info "GPU detectada: $gpuVendor" + + if [[ "$gpuVendor" == "unknown" ]]; then + log::warn "No se pudo identificar la GPU, salteando drivers" + return + fi + + local missingPackages=() + while IFS= read -r packageName; do + util::isYamlNull "$packageName" && continue + util::isAptInstalled "$packageName" || missingPackages+=("$packageName") + done < <(arrayFieldOf "$entryId" "drivers.${gpuVendor}.packages") + + if [[ ${#missingPackages[@]} -eq 0 ]]; then + log::info "Drivers $gpuVendor ya instalados" + return + fi + + util::aptUpdateOnce + sudo apt-get install -y "${missingPackages[@]}" + log::ok "Drivers $gpuVendor instalados" +} + +# ---- DOCKER ---- + +dockerIsInstalled() { + util::cmdExists docker +} + +findDockerComposeFile() { + local destinationDirectory="$1" + local composeFile + for composeFile in "compose.yaml" "compose.yml" "docker-compose.yaml" "docker-compose.yml"; do + [[ -f "$destinationDirectory/$composeFile" ]] && echo "$destinationDirectory/$composeFile" && return 0 + done + return 1 +} + +deployDockerConfigFile() { + local sourceFile="$1" sourceDirectory="$2" destinationDirectory="$3" + local relativePath destination + relativePath="${sourceFile#${sourceDirectory}/}" + destination="$destinationDirectory/$relativePath" + + if [[ -L "$destination" ]]; then + log::info "Enlace ya existe: $relativePath" + return + fi + + [[ -e "$destination" ]] && mv "$destination" "${destination}.stpbackup" + mkdir -p "$(dirname "$destination")" + ln -sfn "$sourceFile" "$destination" + log::ok "Enlazado: $relativePath" +} + +applyDocker() { + local entryId="$1" + local rawDestination destination autostart + rawDestination="$(fieldOf "$entryId" "destination" "~/docker/$entryId")" + destination="${rawDestination/#\~/$HOME}" + autostart="$(fieldOf "$entryId" "autostart" "false")" + + local sourceDirectory="$stpRoot/docker/$entryId" + + if [[ ! -d "$sourceDirectory" ]]; then + log::warn "Carpeta no encontrada: docker/$entryId/" + return + fi + + if ! dockerIsInstalled; then + log::warn "Docker no instalado, salteando: $entryId" + return + fi + + mkdir -p "$destination" + + while IFS= read -r sourceFile; do + deployDockerConfigFile "$sourceFile" "$sourceDirectory" "$destination" + done < <(find "$sourceDirectory" -not -type d | sort) + + if [[ "$autostart" == "true" ]]; then + local composeFile + if composeFile="$(findDockerComposeFile "$destination")"; then + docker compose -f "$composeFile" up -d \ + || log::warn "No se pudo iniciar el servicio: $entryId" + log::ok "Servicio Docker iniciado: $entryId" + else + log::warn "No se encontró archivo compose en: $destination" + fi + fi + + log::ok "Docker configurado: $entryId" +} + +# ---- Dispatcher ---- + +dispatchByType() { + local entryId="$1" entryType="$2" + case "$entryType" in + ppa) applyPpa "$entryId" ;; + apt) applyAptPackage "$entryId" ;; + snap) applySnapPackage "$entryId" ;; + flatpak) applyFlatpakApp "$entryId" ;; + dotfile) applyDotfile "$entryId" ;; + service) applyService "$entryId" ;; + pipewire) applyPipewire "$entryId" ;; + video) applyVideoDrivers "$entryId" ;; + docker) applyDocker "$entryId" ;; + *) log::warn "Tipo no reconocido '$entryType' en entrada: $entryId" ;; + esac +} + +processRegistry() { + local totalProcessed=0 + while IFS=$'\t' read -r entryId entryType; do + util::isYamlNull "$entryId" && continue + util::isYamlNull "$entryType" && continue + log::info "[$entryType] $entryId" + dispatchByType "$entryId" "$entryType" + ((++totalProcessed)) + done < <(yq -r '.registry[] | [.id, .type] | @tsv' "$registryConfig") + log::ok "$totalProcessed entrada(s) del registro procesadas" +} + +if ! yaml::has "$registryConfig" ".registry[0]"; then + log::info "Registro vacío, salteando" + exit 0 +fi + +processRegistry diff --git a/modules/ssh.sh b/modules/ssh.sh new file mode 100755 index 0000000..acf0fd0 --- /dev/null +++ b/modules/ssh.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +set -euo pipefail +source "$stpRoot/lib/log.sh" +source "$stpRoot/lib/utils.sh" + +readonly sshDestination="$HOME/.ssh" +readonly encryptedArchive="$stpRoot/secrets/sshKeys.tar.gz.age" + +sshPermissionsForFile() { + local keyFilename="$1" + case "$keyFilename" in + *.pub|known_hosts|config) echo 644 ;; + *) echo 600 ;; + esac +} + +ensureSshDirectoryExists() { + if [[ ! -d "$sshDestination" ]]; then + mkdir -p "$sshDestination" + chmod 700 "$sshDestination" + fi +} + +decryptArchiveInto() { + local workingDirectory="$1" + log::info "Ingresá la passphrase para descifrar las claves SSH:" + if ! age -d -o "$workingDirectory/sshKeys.tar.gz" "$encryptedArchive"; then + log::error "Error al descifrar. Verificá la passphrase." + return 1 + fi + tar -xzf "$workingDirectory/sshKeys.tar.gz" -C "$workingDirectory" +} + +installSshKey() { + local sourceFile="$1" + local keyFilename + keyFilename="$(basename "$sourceFile")" + local destination="$sshDestination/$keyFilename" + + if [[ -f "$destination" ]]; then + log::warn "Ya existe (salteando): $keyFilename" + return 1 + fi + + cp "$sourceFile" "$destination" + chmod "$(sshPermissionsForFile "$keyFilename")" "$destination" + log::ok "Instalada: $keyFilename" +} + +installAllKeysFrom() { + local sourceDirectory="$1" + local installedCount=0 skippedCount=0 + + for sourceFile in "$sourceDirectory/.ssh/"*; do + [[ -f "$sourceFile" ]] || continue + if installSshKey "$sourceFile"; then + ((++installedCount)) + else + ((++skippedCount)) + fi + done + + log::ok "$installedCount clave(s) instaladas, $skippedCount salteada(s)" +} + +if [[ ! -f "$encryptedArchive" ]]; then + log::warn "Archivo de claves no encontrado: secrets/sshKeys.tar.gz.age" + log::warn "Para cifrar tus claves actuales: bash scripts/encryptSsh.sh" + exit 0 +fi + +if ! util::cmdExists age; then + log::error "age no está instalado. Ejecutá primero el módulo bootstrap" + exit 1 +fi + +log::info "Restaurando claves SSH..." + +workingDirectory="$(mktemp -d)" +trap 'rm -rf "$workingDirectory"' EXIT + +decryptArchiveInto "$workingDirectory" +ensureSshDirectoryExists +installAllKeysFrom "$workingDirectory" diff --git a/modules/thunderbird.sh b/modules/thunderbird.sh new file mode 100644 index 0000000..eace472 --- /dev/null +++ b/modules/thunderbird.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +set -euo pipefail +source "$stpRoot/lib/log.sh" +source "$stpRoot/lib/utils.sh" + +readonly thunderbirdApplicationDirectory="$HOME/snap/thunderbird/common" +readonly thunderbirdProfileDirectory="$thunderbirdApplicationDirectory/.thunderbird" +readonly thunderbirdArchive="$stpRoot/secrets/thunderbirdProfile.zip" + +thunderbirdIsInstalled() { + util::isSnapInstalled "thunderbird" +} + +thunderbirdProfileExists() { + [[ -f "$thunderbirdProfileDirectory/profiles.ini" ]] +} + +installThunderbird() { + sudo snap install thunderbird + log::ok "Thunderbird instalado" +} + +restoreProfile() { + log::info "Restaurando perfil desde secrets/thunderbirdProfile.zip..." + mkdir -p "$thunderbirdApplicationDirectory" + unzip -qo "$thunderbirdArchive" -d "$thunderbirdApplicationDirectory" + log::ok "Perfil restaurado en: $thunderbirdProfileDirectory" +} + +if thunderbirdIsInstalled; then + log::info "Thunderbird ya instalado" +else + installThunderbird +fi + +if [[ ! -f "$thunderbirdArchive" ]]; then + log::info "Sin respaldo de perfil (secrets/thunderbirdProfile.zip), salteando restauración" + exit 0 +fi + +if thunderbirdProfileExists; then + log::info "Perfil de Thunderbird ya existe, salteando restauración" + exit 0 +fi + +restoreProfile diff --git a/modules/wireplumber.sh b/modules/wireplumber.sh new file mode 100644 index 0000000..84b4107 --- /dev/null +++ b/modules/wireplumber.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -euo pipefail +source "$stpRoot/lib/log.sh" +source "$stpRoot/lib/utils.sh" + +readonly wireplumberStateDirectory="$HOME/.local/state/wireplumber" +readonly wireplumberArchive="$stpRoot/secrets/wireplumberState.zip" + +wireplumberIsInstalled() { + util::cmdExists wireplumber +} + +wireplumberStateExists() { + [[ -f "$wireplumberStateDirectory/default-nodes" ]] +} + +restoreState() { + log::info "Restaurando estado desde secrets/wireplumberState.zip..." + mkdir -p "$HOME/.local/state" + unzip -qo "$wireplumberArchive" -d "$HOME/.local/state" + log::ok "Estado restaurado en: $wireplumberStateDirectory" + log::info "Reiniciá WirePlumber para aplicar los cambios: systemctl --user restart wireplumber" +} + +if ! wireplumberIsInstalled; then + log::info "WirePlumber no instalado, salteando" + exit 0 +fi + +if [[ ! -f "$wireplumberArchive" ]]; then + log::info "Sin respaldo de estado (secrets/wireplumberState.zip), salteando" + exit 0 +fi + +if wireplumberStateExists; then + log::info "Estado de WirePlumber ya existe, salteando restauración" + exit 0 +fi + +restoreState diff --git a/scripts/cups/capture.sh b/scripts/cups/capture.sh new file mode 100644 index 0000000..cf53815 --- /dev/null +++ b/scripts/cups/capture.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +set -euo pipefail +stpRoot="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +source "$stpRoot/lib/log.sh" +source "$stpRoot/lib/utils.sh" + +readonly outputFile="$stpRoot/secrets/cupsConfig.zip" + +cupsIsInstalled() { + util::cmdExists lpstat +} + +printersAreConfigured() { + sudo test -s /etc/cups/printers.conf +} + +copyFilesToTemporaryDirectory() { + local temporaryDirectory="$1" + sudo cp /etc/cups/printers.conf "$temporaryDirectory/printers.conf" + if sudo test -d /etc/cups/ppd && sudo test -n "$(sudo ls /etc/cups/ppd/)"; then + sudo cp -r /etc/cups/ppd "$temporaryDirectory/ppd" + fi + sudo chown -R "$USER" "$temporaryDirectory" +} + +if ! cupsIsInstalled; then + log::warn "CUPS no instalado" + exit 0 +fi + +util::requireSudo + +if ! printersAreConfigured; then + log::warn "No hay impresoras configuradas en /etc/cups/printers.conf" + exit 0 +fi + +log::step "Capturando configuración de CUPS" +mkdir -p "$stpRoot/secrets" +rm -f "$outputFile" + +temporaryDirectory="$(mktemp -d)" +trap 'rm -rf "$temporaryDirectory"' EXIT + +copyFilesToTemporaryDirectory "$temporaryDirectory" + +( + cd "$temporaryDirectory" + find . -not -type d | sed 's|^\./||' | zip -q "$outputFile" -@ +) + +log::ok "Guardado: secrets/cupsConfig.zip ($(du -sh "$outputFile" | cut -f1))" +log::info "Impresoras respaldadas: $(lpstat -a | awk '{print $1}' | tr '\n' ' ')" +log::info "Commiteá secrets/cupsConfig.zip antes de hacer push" diff --git a/scripts/easyEffects/capture.sh b/scripts/easyEffects/capture.sh new file mode 100644 index 0000000..42709a8 --- /dev/null +++ b/scripts/easyEffects/capture.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -euo pipefail +stpRoot="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +source "$stpRoot/lib/log.sh" + +readonly easyEffectsApplicationDirectory="$HOME/.var/app/com.github.wwmm.easyeffects" +readonly outputFile="$stpRoot/secrets/easyEffectsConfig.zip" + +easyEffectsDataExists() { + [[ -d "$easyEffectsApplicationDirectory/config/easyeffects" ]] || [[ -d "$easyEffectsApplicationDirectory/data/easyeffects" ]] +} + +if ! easyEffectsDataExists; then + log::warn "Datos de EasyEffects no encontrados en: $easyEffectsApplicationDirectory" + log::info "Asegurate de haber abierto EasyEffects al menos una vez antes de capturar" + exit 0 +fi + +log::step "Capturando configuración de EasyEffects" +mkdir -p "$stpRoot/secrets" +rm -f "$outputFile" + +( + cd "$easyEffectsApplicationDirectory" + find "config/easyeffects" "data/easyeffects" -not -type d 2>/dev/null \ + | zip -q "$outputFile" -@ +) + +log::ok "Guardado: secrets/easyEffectsConfig.zip ($(du -sh "$outputFile" | cut -f1))" +log::info "Commiteá secrets/easyEffectsConfig.zip antes de hacer push" diff --git a/scripts/encryptSsh.sh b/scripts/encryptSsh.sh new file mode 100755 index 0000000..9a36d58 --- /dev/null +++ b/scripts/encryptSsh.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Cifra ~/.ssh con age y guarda el resultado en secrets/sshKeys.tar.gz.age. +# Corré esto en tu máquina actual ANTES de hacer push del repo. +# +# Uso: ./scripts/encryptSsh.sh [ruta-a-.ssh] +set -euo pipefail + +stpRoot="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +sshSource="${1:-$HOME/.ssh}" + +if [[ ! -d "$sshSource" ]]; then + echo "Error: directorio no encontrado: $sshSource" + exit 1 +fi + +if ! command -v age &>/dev/null; then + echo "Error: age no está instalado. Instalalo con: sudo apt install age" + exit 1 +fi + +mkdir -p "$stpRoot/secrets" +readonly outputFile="$stpRoot/secrets/sshKeys.tar.gz.age" + +workingDirectory="$(mktemp -d)" +trap 'rm -rf "$workingDirectory"' EXIT + +echo "Empaquetando $sshSource..." +tar -czf "$workingDirectory/sshKeys.tar.gz" -C "$(dirname "$sshSource")" "$(basename "$sshSource")" + +echo "Cifrando con age (usá una passphrase segura que puedas recordar)..." +age --passphrase --output "$outputFile" "$workingDirectory/sshKeys.tar.gz" + +echo "" +echo "✓ Claves cifradas guardadas en: secrets/sshKeys.tar.gz.age" +echo " Este archivo es seguro para commitear — está cifrado simétricamente." +echo "" +echo "Para verificar que el cifrado funciona:" +echo " age --decrypt secrets/sshKeys.tar.gz.age | tar -tzv" diff --git a/scripts/thunderbird/capture.sh b/scripts/thunderbird/capture.sh new file mode 100644 index 0000000..169e718 --- /dev/null +++ b/scripts/thunderbird/capture.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Excluye emails, caché, crash dumps y archivos de bloqueo. +# Replica lo que hace el export integrado de Thunderbird (Ajustes → Exportar perfil). +set -euo pipefail +stpRoot="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +source "$stpRoot/lib/log.sh" + +readonly thunderbirdApplicationDirectory="$HOME/snap/thunderbird/common" +readonly thunderbirdProfileDirectory="$thunderbirdApplicationDirectory/.thunderbird" +readonly outputFile="$stpRoot/secrets/thunderbirdProfile.zip" + +thunderbirdDataExists() { + [[ -d "$thunderbirdProfileDirectory" ]] +} + +if ! thunderbirdDataExists; then + log::warn "Datos de Thunderbird Snap no encontrados en: $thunderbirdProfileDirectory" + log::info "Asegurate de haber abierto Thunderbird al menos una vez antes de capturar" + exit 0 +fi + +log::step "Capturando configuración de Thunderbird" +mkdir -p "$stpRoot/secrets" +rm -f "$outputFile" + +( + cd "$thunderbirdApplicationDirectory" + find ".thunderbird" \ + | grep -Ev "/(Mail|ImapMail|News|cache|cache2|startupCache|minidumps|crashes|storage|Crash Reports)(/|$)" \ + | grep -Ev "\.(msf|sqlite-wal|sqlite-shm)$" \ + | grep -Ev "/(parent\.lock|lock)$" \ + | zip -q "$outputFile" -@ +) + +log::ok "Guardado: secrets/thunderbirdProfile.zip ($(du -sh "$outputFile" | cut -f1))" +log::info "Commiteá secrets/thunderbirdProfile.zip antes de hacer push" diff --git a/scripts/wireplumber/capture.sh b/scripts/wireplumber/capture.sh new file mode 100644 index 0000000..7800ac7 --- /dev/null +++ b/scripts/wireplumber/capture.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -euo pipefail +stpRoot="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +source "$stpRoot/lib/log.sh" + +readonly wireplumberStateDirectory="$HOME/.local/state/wireplumber" +readonly outputFile="$stpRoot/secrets/wireplumberState.zip" + +wireplumberStateExists() { + [[ -d "$wireplumberStateDirectory" ]] +} + +if ! wireplumberStateExists; then + log::warn "Estado de WirePlumber no encontrado en: $wireplumberStateDirectory" + log::info "Asegurate de haber conectado los auriculares bluetooth al menos una vez" + exit 0 +fi + +log::step "Capturando estado de WirePlumber" +mkdir -p "$stpRoot/secrets" +rm -f "$outputFile" + +( + cd "$HOME/.local/state" + find "wireplumber" -not -type d | zip -q "$outputFile" -@ +) + +log::ok "Guardado: secrets/wireplumberState.zip ($(du -sh "$outputFile" | cut -f1))" +log::info "Commiteá secrets/wireplumberState.zip antes de hacer push" diff --git a/secrets/cupsConfig.zip b/secrets/cupsConfig.zip new file mode 100644 index 0000000..8000c1d Binary files /dev/null and b/secrets/cupsConfig.zip differ diff --git a/secrets/easyEffectsConfig.zip b/secrets/easyEffectsConfig.zip new file mode 100644 index 0000000..3fd0ae4 Binary files /dev/null and b/secrets/easyEffectsConfig.zip differ diff --git a/secrets/thunderbirdProfile.zip b/secrets/thunderbirdProfile.zip new file mode 100644 index 0000000..b3a60e0 Binary files /dev/null and b/secrets/thunderbirdProfile.zip differ diff --git a/secrets/wireplumberState.zip b/secrets/wireplumberState.zip new file mode 100644 index 0000000..f244d5d Binary files /dev/null and b/secrets/wireplumberState.zip differ diff --git a/stp.sh b/stp.sh new file mode 100755 index 0000000..e3f2591 --- /dev/null +++ b/stp.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +set -euo pipefail + +stpRoot="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +export stpRoot + +source "$stpRoot/lib/log.sh" +source "$stpRoot/lib/utils.sh" + +export verbose="${verbose:-false}" + +selectedModule="" +skipModules=() + +readModuleOrder() { + grep -v '^\s*#' "$stpRoot/config/modules" | grep -v '^\s*$' +} + +usage() { + cat < Ejecutar solo este módulo (ej: packages) + -s, --skip Saltear módulo, puede repetirse + -v, --verbose Salida detallada + -l, --list Listar módulos en orden de ejecución + -h, --help Mostrar esta ayuda + +Ejemplos: + stp.sh # Ejecutar todos los módulos + stp.sh -m packages # Solo instalar paquetes + stp.sh -s video -s audio # Saltear video y audio +EOF +} + +listModules() { + echo "Módulos (en orden de ejecución):" + while IFS= read -r name; do + local modulePath="$stpRoot/modules/${name}.sh" + if [[ -f "$modulePath" ]]; then + printf " %s\n" "$name" + else + printf " %s (⚠ no encontrado: modules/%s.sh)\n" "$name" "$name" + fi + done < <(readModuleOrder) +} + +shouldSkip() { + local moduleName="$1" + for skip in "${skipModules[@]:-}"; do + [[ "$moduleName" == "$skip" ]] && return 0 + done + return 1 +} + +runModule() { + local moduleName="$1" + local modulePath="$stpRoot/modules/${moduleName}.sh" + + if [[ ! -f "$modulePath" ]]; then + log::error "Módulo no encontrado: modules/${moduleName}.sh" + return 1 + fi + + if shouldSkip "$moduleName"; then + log::warn "Salteando: $moduleName" + return 0 + fi + + log::step "$moduleName" + if ! bash "$modulePath"; then + log::error "Falló el módulo: $moduleName (ver $stpLogFile)" + return 1 + fi +} + +while [[ $# -gt 0 ]]; do + case "$1" in + -m|--module) selectedModule="$2"; shift 2 ;; + -s|--skip) skipModules+=("$2"); shift 2 ;; + -v|--verbose) verbose=true; shift ;; + -l|--list) listModules; exit 0 ;; + -h|--help) usage; exit 0 ;; + *) log::error "Opción desconocida: $1"; usage; exit 1 ;; + esac +done + +printf '\n' +log::step "Sistema de Transferencia Personal" +log::info "Log: $stpLogFile" +printf '\n' + +util::requireSudo + +if [[ -n "$selectedModule" ]]; then + if [[ ! -f "$stpRoot/modules/${selectedModule}.sh" ]]; then + log::error "Módulo no encontrado: $selectedModule" + listModules + exit 1 + fi + runModule "$selectedModule" +else + while IFS= read -r moduleName; do + runModule "$moduleName" + printf '\n' + done < <(readModuleOrder) +fi + +printf '\n' +log::ok "STP completado. Log completo en: $stpLogFile"