13 Commits
Author SHA1 Message Date
dasandClaude Sonnet 5.5 8db767efb9 publisher, deployer: report ssh failure instead of BrokenPipeError (0.9.8)
build / build (push) Successful in 21m56s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 18:34:29 +02:00
dasandClaude Sonnet 5.5 1a62ddaeb8 Add curl to the desktop image (0.9.7)
build / build (push) Canceled after 0s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 18:22:03 +02:00
dasandClaude Sonnet 5.5 7e7bcbe017 Add nano to the desktop and server images (0.9.6)
build / build (push) Successful in 20m58s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 16:43:46 +02:00
dasandClaude Sonnet 5.5 60af93df74 publisher, deployer: same path logic as the originals
build / build (push) Successful in 21m27s
The packaged copies use config.default_path() and pipeline._history_path():
files next to the sources in a checkout, ~/.config/<name> and
~/.local/state/<name> when installed. The copies now differ from the
originals only in the neutral defaults.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 14:55:40 +02:00
dasandClaude Sonnet 5.5 396986aaf4 publisher, deployer: neutral defaults in the packaged copies
build / build (push) Canceled after 2m34s
Default host is localhost, user is "user", paths are under /home/user;
the publisher apps_dir no longer points at a personal Tomcat. Tests of the
copies follow.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 14:53:05 +02:00
dasandClaude Sonnet 5.5 62b8b98086 Fix pale Wi-Fi/Bluetooth tray icons, das-panel-icon help, publisher and deployer (0.9.5)
- Light theme uses Papirus-Light: the panel icons of Papirus are #dfdfdf,
  made for dark panels (xsettings, theme index, login screen).
- das-panel-icon help/-h/--help print usage instead of setting an icon;
  unknown options are rejected.
- New packages publisher (publish) and deployer (deploy) on the desktop
  image: config in ~/.config/<name>/ (neutral example copied on first run),
  history in ~/.local/state/<name>/.
- das-theme 0.3.0, das-settings 0.6.0.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 14:44:36 +02:00
dasandClaude Sonnet 5.5 ea62ec7a26 Panel icons, trident menu button, Ctrl+Q in dbanner (0.9.4)
build / build (push) Successful in 29m33s
- Light theme: panel icons and labels are dark instead of pale grey.
- das-trident icon and das-panel-icon star|trident for the menu button.
- dbanner quits with Ctrl+Q like dprompt ("q" stays on the start screen).
- das-shell 0.4.0, das-theme 0.2.0, das-settings 0.5.0.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 13:25:56 +02:00
dasandClaude Opus 5.5 22dc7832bc TODO: server checks of das-shell 0.3.0 passed [skip ci]
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:21:17 +03:00
dasandClaude Opus 5.5 e01e1786d6 Terminal: Das OS aliases and functions (0.9.3)
build / build (push) Successful in 23m2s
- das-shell 0.3.0: aliases and functions for every interactive bash
  (/usr/share/das/shell, loaded by /etc/bash/bashrc.d/das-aliases.sh,
  then the user's ~/.bash_aliases and ~/.bash_functions; off with
  ~/.config/das/no-aliases). Based on the author's own set, adapted to
  Void (xbps, nftables, das-update), with fixes (rmhost, netinfo,
  todo -r, dirsize, extract) and new ones (up, bigfiles, pkgowner,
  please, serve, weather, cheat, genpass, Docker shortcuts). lsfunc
  shows bilingual descriptions. Shell tests in tests/test_shell.py.
- Names that shadowed commands are gone: gh -> hgrep, mount -> mounts,
  no install or ed.
- README: "Aliases and functions" section; TODO: banner, dashboard
  service and prompt verified by the author on real hardware.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 19:35:58 +03:00
dasandClaude Opus 5.5 6564d1eb17 Hardware fixes: issue version, installer Cyrillic, console TUIs (0.9.2)
build / build (push) Canceled after 0s
- /etc/issue shows the Das OS version instead of the kernel's; the server
  installer carries it into the installed system.
- das-server-installer: setfont -C /dev/tty0 before the language menu
  (sudo runs it in its own pty, so the Cyrillic was drawn as Latin), and
  floppy drives are no longer offered as install targets.
- ncdu on the desktop image and in das-server 0.4.0.
- das-shell 0.2.0: each prompt line sets its own color; the dbanner start
  screen buttons are no longer covered by the key bar; on the Linux
  console (TERM=linux) dprompt and dbanner draw solid borders and swap
  the glyphs that no console font has (▊▎▔▁ and others) for ones it has.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 19:21:47 +03:00
dasandClaude Opus 5.5 94b1bf5f8b Docs: Ukrainian guide to building your own distribution [skip ci]
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 14:21:51 +03:00
dasandClaude Opus 5.5 a2bffc7fe9 Terminal: Das prompt, dprompt and dbanner (0.9.1)
build / build (push) Successful in 38m26s
- das-shell 0.1.0: the Das two-line bash prompt by default (replaces the
  PS1 of Void's skel .bashrc once), dprompt (Textual prompt builder with
  presets and live preview) and dbanner (SSH login banner editor: canvas
  tools, fields with values, widgets, das-banner service for cached ones,
  Das OS logo and tryzub pictures). Python tests in make test.
- das-server 0.3.0 depends on das-shell; the server installer mentions
  dprompt and dbanner; the desktop image includes it.
- run-qemu.sh forwards the guest's SSH to localhost:2222.
- Hardware fixes: Ventoy menu icon, boot menu titles "Das OS <version>".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 09:17:10 +03:00
dasandClaude Opus 5.5 66ea614e0d TODO: CI host on SSD, first release v0.8.0 published
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 16:49:09 +03:00
118 changed files with 10979 additions and 44 deletions
+4
View File
@@ -6,3 +6,7 @@ srcpkgs/das-theme/files/xfwm4gen/xfwm4gen
# IDE # IDE
.idea/ .idea/
# Python
.venv/
__pycache__/
+2 -1
View File
@@ -10,7 +10,7 @@ help:
@echo "make qemu-bios boot the newest ISO in QEMU with legacy BIOS" @echo "make qemu-bios boot the newest ISO in QEMU with legacy BIOS"
@echo "make qemu-disk boot with a 20 GB virtual disk attached (for installer tests)" @echo "make qemu-disk boot with a 20 GB virtual disk attached (for installer tests)"
@echo "make qemu-server boot the newest server ISO on a serial console, with the disk" @echo "make qemu-server boot the newest server ISO on a serial console, with the disk"
@echo "make test run Go tests" @echo "make test run Go and Python tests"
@echo "make clean remove build output (keeps download caches)" @echo "make clean remove build output (keeps download caches)"
packages: packages:
@@ -37,6 +37,7 @@ qemu-server:
test: test:
cd srcpkgs/das-theme/files/xfwm4gen && go test ./... cd srcpkgs/das-theme/files/xfwm4gen && go test ./...
cd srcpkgs/das-tools/files/src && go test ./... cd srcpkgs/das-tools/files/src && go test ./...
cd srcpkgs/das-shell/files && PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s tests -t .
# Build output may be owned by root when a build was interrupted, so remove it from the container # Build output may be owned by root when a build was interrupted, so remove it from the container
clean: clean:
+38 -8
View File
@@ -1,7 +1,7 @@
## Das OS ## Das OS
### _A light, friendly Void Linux–based desktop with Xfce and a Ukrainian touch, plus a console server edition_ ### _A light, friendly Void Linux–based desktop with Xfce and a Ukrainian touch, plus a console server edition_
[![Void Linux](https://img.shields.io/badge/Void_Linux-x86__64_glibc-green)](https://voidlinux.org) [![Xfce](https://img.shields.io/badge/Xfce-4.20-orange)](https://xfce.org) [![runit](https://img.shields.io/badge/init-runit-blue)](https://docs.voidlinux.org/config/services/) [![Go](https://img.shields.io/badge/Go-1.24+-blue)](https://go.dev) [![Docker](https://img.shields.io/badge/Docker-29-blue)](https://www.docker.com) [![Version](https://img.shields.io/badge/version-0.8.0-lightgrey)](#) [![Void Linux](https://img.shields.io/badge/Void_Linux-x86__64_glibc-green)](https://voidlinux.org) [![Xfce](https://img.shields.io/badge/Xfce-4.20-orange)](https://xfce.org) [![runit](https://img.shields.io/badge/init-runit-blue)](https://docs.voidlinux.org/config/services/) [![Go](https://img.shields.io/badge/Go-1.24+-blue)](https://go.dev) [![Docker](https://img.shields.io/badge/Docker-29-blue)](https://www.docker.com) [![Python](https://img.shields.io/badge/Python-3-blue)](https://www.python.org) [![Textual](https://img.shields.io/badge/Textual-8.2-blue)](https://textual.textualize.io) [![Version](https://img.shields.io/badge/version-0.9.3-lightgrey)](#)
🇺🇦 [Українська версія](README_ua.md) 🇺🇦 [Українська версія](README_ua.md)
@@ -16,6 +16,8 @@ Void Linux is fast, simple and independent: runit instead of systemd, and its ow
- [Welcome window](#Welcome) at the first login: language, home folders, updates, the developer profile and help - [Welcome window](#Welcome) at the first login: language, home folders, updates, the developer profile and help
- [Server image](#Server): a console live ISO with a text installer, SSH, firewall, fail2ban and Docker - [Server image](#Server): a console live ISO with a text installer, SSH, firewall, fail2ban and Docker
- [Command-line helpers](#Helpers) for everyday admin tasks: services (`dsv`), users (`duser`), firewall (`dfw`) and system settings (`dsys`) - [Command-line helpers](#Helpers) for everyday admin tasks: services (`dsv`), users (`duser`), firewall (`dfw`) and system settings (`dsys`)
- [Terminal prompt and SSH login banner](#Terminal): the Das prompt by default, the `dprompt` prompt builder and the `dbanner` banner editor with the Das OS logo and the tryzub
- [Aliases and functions](#Aliases) for every terminal: `extract`, `mkcd`, `netinfo`, `pkgowner`, Docker shortcuts and more, with `lsfunc` to list them
- [Developer profile](#DevProfile) in one command: Go, Python, Node.js, Docker, rootless Podman, VS Code and IDEs from Flathub - [Developer profile](#DevProfile) in one command: Go, Python, Node.js, Docker, rootless Podman, VS Code and IDEs from Flathub
- [Das OS look](#Look): light and dark themes, generated window frames, wallpapers and logo - [Das OS look](#Look): light and dark themes, generated window frames, wallpapers and logo
- [Desktop defaults](#Defaults) for Xfce that don't overwrite stock files - [Desktop defaults](#Defaults) for Xfce that don't overwrite stock files
@@ -134,7 +136,7 @@ Void Linux is fast, simple and independent: runit instead of systemd, and its ow
- The installed system runs `sshd`, `chronyd`, `nftables`, `fail2ban`, `docker`, `socklog` and the chosen network service. - The installed system runs `sshd`, `chronyd`, `nftables`, `fail2ban`, `docker`, `socklog` and the chosen network service.
- nftables lets in only SSH, ICMP and DHCPv6 (`/etc/nftables.conf`), more ports are opened with `dfw`. The rules live in their own table `inet das`, so reloading them keeps the Docker and fail2ban rules. Ports that containers publish pass through Docker's own rules. - nftables lets in only SSH, ICMP and DHCPv6 (`/etc/nftables.conf`), more ports are opened with `dfw`. The rules live in their own table `inet das`, so reloading them keeps the Docker and fail2ban rules. Ports that containers publish pass through Docker's own rules.
- fail2ban bans addresses that keep failing SSH logins, reading the socklog log. sshd doesn't accept root password logins. - fail2ban bans addresses that keep failing SSH logins, reading the socklog log. sshd doesn't accept root password logins.
- Tools: Docker with Compose, lazydocker, lazygit, Midnight Commander, tmux, htop, Python 3, curl, `das-update --cli`, the [helpers](#Helpers) and xtools (`xi`, `xq`, `xrs`). There are no graphical libraries, and no firmware for NVIDIA graphics or sound cards (`/etc/xbps.d/das-server.conf` keeps updates from bringing it back). - Tools: Docker with Compose, lazydocker, lazygit, Midnight Commander, tmux, htop, ncdu, Python 3, curl, `das-update --cli`, the [helpers](#Helpers), [`dprompt` and `dbanner`](#Terminal), [aliases and functions](#Aliases) and xtools (`xi`, `xq`, `xrs`). There are no graphical libraries, and no firmware for NVIDIA graphics or sound cards (`/etc/xbps.d/das-server.conf` keeps updates from bringing it back).
#### Command-line helpers<a id='Helpers' /> #### Command-line helpers<a id='Helpers' />
**_Short commands for tasks that systemd distributions handle with systemctl, hostnamectl and the like, and that Void leaves to manual editing._** **_Short commands for tasks that systemd distributions handle with systemctl, hostnamectl and the like, and that Void leaves to manual editing._**
@@ -146,6 +148,33 @@ Void Linux is fast, simple and independent: runit instead of systemd, and its ow
- `dsys` replaces hostnamectl, timedatectl and localectl: `info` (time sync, language, console, addresses), `hostname`, `timezone` (part of the name will do: `dsys timezone Kyiv`), `locale` (builds the glibc locale if needed) and `keymap` (with a Cyrillic console font for Cyrillic layouts). - `dsys` replaces hostnamectl, timedatectl and localectl: `info` (time sync, language, console, addresses), `hostname`, `timezone` (part of the name will do: `dsys timezone Kyiv`), `locale` (builds the glibc locale if needed) and `keymap` (with a Cyrillic console font for Cyrillic layouts).
- They come in the `das-helpers` package and are on the desktop too. Both images also include `xtools-minimal` from Void for package work: `xi` installs, `xrs` searches, `xq` shows package details and `xcheckrestart` finds programs to restart after an update. - They come in the `das-helpers` package and are on the desktop too. Both images also include `xtools-minimal` from Void for package work: `xi` installs, `xrs` searches, `xq` shows package details and `xcheckrestart` finds programs to restart after an update.
#### Terminal prompt and SSH login banner<a id='Terminal' />
**_A two-line bash prompt out of the box, and two terminal editors to change it and to draw the banner shown at SSH login._**
- The stock prompt: `┌─[✗]─[user@host]─[2026-10-21]─[08:12]─[~/dir]` and `└─╼ $` on the second line. `[✗]` appears only after a failed command, root's name is red, and without colours or on the Linux console the shape stays the same.
- It comes from `/etc/bash/bashrc.d/das-prompt.sh`: the user's `~/.config/das/prompt.sh`, else `/etc/das/prompt.sh`, else the stock one. Void's `/etc/skel/.bashrc` sets its own PS1 afterwards, so that one is replaced once, but a user's own PS1 is never touched.
- `dprompt` is a prompt builder: ready-made prompts (`das`, `das-git` with the git branch, venv and background jobs, `server`, `minimal`, `classic`), segments to add, move and colour, and a live preview as a normal user, after an error and as root. Ctrl+S applies it for yourself, Ctrl+G for every user through sudo. Without the editor: `dprompt list`, `dprompt show NAME`, `dprompt apply NAME [--system]`, `dprompt reset`.
- Conditional parts (exit status, git, venv, jobs, SSH, a command's output) are computed by a `PROMPT_COMMAND` hook and only read by PS1, so a branch name is never run as code.
- `dbanner` edits the banner shown at SSH login. It starts with a list of ready-made banners with a live preview: `das-classic` (a `#` frame with the name, IP and link), `double` (the host name in big figlet letters), `blocks` (the Das OS logo with system facts), `ukraine` (the lesser coat of arms of Ukraine), `tryzub`, `dashboard` and `minimal`.
- The editor draws with the mouse or the keyboard: pen, eraser, line, boxes (single, double, rounded, heavy, ASCII, `#`, blocks), fill, text, big letters, select with copy and paste, undo. A palette offers box and block symbols, 16 colours for text and background and bold. Ready pictures go in with one click: the Das OS logo and the tryzub, plain or on a shield, in two sizes.
- Fields show live values in a fixed width, so a frame never shifts: `{hostname}`, `{ip}`, `{uptime}`, `{mem}`, `{os}`, `{kernel}` and others, plus your own variables like `{url}`; `[[Ukrainian|English]]` labels follow the user's language.
- Widgets add blocks under or beside the picture: disks with usage bars, memory, load, network, Docker containers, available updates, weather from wttr.in, SMART disk health, runit services that are down, fail2ban bans, snapper snapshots, the previous login, or any command or file with a script's output.
- Quick widgets run at login with a short timeout. Slow or root-only ones (updates, weather, SMART, fail2ban) are refreshed by the `das-banner` runit service into `/var/cache/das/banner`, and `dbanner apply` enables it when a banner needs it. No cron is needed.
- Drafts are kept in `~/.config/das/banner`; Ctrl+A or `dbanner apply NAME` writes `/etc/das/banner.json` through sudo. `dbanner off` and `on` turn the banner off for everyone, `~/.hushlogin` for one user; `"local": true` shows it on console logins too.
#### Aliases and functions<a id='Aliases' />
**_Ready shortcuts for everyday terminal work in every interactive bash, rewritten for Void: xbps, nftables, runit and Docker._**
- They come from `/etc/bash/bashrc.d/das-aliases.sh` (package `das-shell`): `/usr/share/das/shell/aliases.sh` and `functions.sh`, then your own `~/.bash_aliases` and `~/.bash_functions`, which win. `touch ~/.config/das/no-aliases` turns the Das OS ones off for one user. `lsfunc` lists the functions with a description in the user's language, `alias` lists the aliases.
- `ls`: `ll`, `la`, `l`, `sl`, `lsl` (in `less`), `lsz` (by size), `lst` (by time), `lsd` (directories only); `grep`, `diff` and `ip` in colour.
- Directories: `..`, `...`, `....`, `cd..`, `up [N]` (N levels up), `mkcd DIR` (create and enter), `cl DIR` (enter and list), `path` (one `PATH` entry per line); `mkdir` makes parents.
- Files: `f PATTERN [DIR]` (find by name, any case), `fhere NAME`, `cnt` (number of files), `bigfiles [DIR] [N]`, `dirsize [DIR]` (sizes of everything in a directory), `whatsize [DIR]` (subdirectory sizes, `/` by default), `bu FILE...` (copy with the date in the name), `ednt FILE` (edit keeping the modification time).
- Archives: `extract ARCHIVE...` unpacks tar of any compression, `.xbps`, zip, rar, 7z, gz, bz2, xz, zst, lz4 and Z; `compress NAME.tar.gz|tar.xz|tar.zst|zip|7z FILE...` packs by the name's extension. A missing tool is named together with the package to install.
- Disks and memory: `df`, `dft` (with types and a total), `dftl`, `mnt` (mounted devices), `mounts`, `devid` (lsblk, blkid and lsusb), `snaps` (root snapshots), `free`, `meminfo`, `cpuinfo`, `gpumeminfo`.
- Processes and history: `psa TEXT`, `pst TEXT` (as a tree), `psg`, `psmem`/`psmem10`, `pscpu`/`pscpu10`, `j`, `hgrep TEXT`, `histat [N]` and `histop10` (most used commands), `EC` (the previous exit code), `please` (the previous command again with sudo).
- Packages: `upd` and `updy` (`das-update --cli` with snapshots, `updy` without questions), `pkgshow [TEXT]` (installed), `pkgfind TEXT` (in the repositories), `pkgowner FILE|COMMAND`, `pkgclean` (package cache and orphans).
- Network: `myip`, `netinfo` (addresses, gateway, DNS, public IP), `oports` and `gport TEXT` (listening ports), `ping5`, `fastping`, `header URL` and `headerc URL` (HTTP headers, with compression), `listen PORT`, `serve [PORT]` (the current directory over HTTP), `rmhost HOST|LINE` (clean `known_hosts`), `nftlist`, `firewall` (`dfw status`), `weather [CITY]`, `cheat COMMAND` (cheat.sh).
- Docker: `dps`, `dlog CONTAINER`, `dcu`, `dcd`, `dcl`, `dcp` (compose up, down, logs, pull), `lzd`, `lzg`.
- Small tools: `calc EXPR`, `epoch [SECONDS]`, `genpass [LENGTH]`, `now`, `nowdate`, `note [-c] [TEXT]` (`~/.notes`), `todo [-l|-r N|-c] [TEXT]` (`~/.todo`), `csrc FILE.c` (compile and run C), `man` with coloured headings.
#### Developer profile<a id='DevProfile' /> #### Developer profile<a id='DevProfile' />
**_`dprofile install dev` turns a desktop or a server into a development machine, with a snapshot taken before it._** **_`dprofile install dev` turns a desktop or a server into a development machine, with a snapshot taken before it._**
- Packages from the Void repository: `base-devel`, gdb, strace, Git with LFS, GitHub CLI, lazygit, Go with gopls, delve and golangci-lint, Python with pip, virtualenv and uv, Node.js, Docker with Compose and Buildx, lazydocker, Podman with podman-compose, jq, ripgrep, fd, fzf, direnv, shellcheck and SQLite. - Packages from the Void repository: `base-devel`, gdb, strace, Git with LFS, GitHub CLI, lazygit, Go with gopls, delve and golangci-lint, Python with pip, virtualenv and uv, Node.js, Docker with Compose and Buildx, lazydocker, Podman with podman-compose, jq, ripgrep, fd, fzf, direnv, shellcheck and SQLite.
@@ -160,7 +189,7 @@ Void Linux is fast, simple and independent: runit instead of systemd, and its ow
- The GTK themes `DasOS` and `DasOS-Dark` build on adw-gtk3 and add the Das OS accent colours. They also style the Xfce panel, which adw-gtk3 leaves black. - The GTK themes `DasOS` and `DasOS-Dark` build on adw-gtk3 and add the Das OS accent colours. They also style the Xfce panel, which adw-gtk3 leaves black.
- The xfwm4 window frames (rounded corners, round buttons, a red close button on hover) are generated as PNG files by `xfwm4gen` (Go) from a single palette. - The xfwm4 window frames (rounded corners, round buttons, a red close button on hover) are generated as PNG files by `xfwm4gen` (Go) from a single palette.
- Light and dark wallpapers show the star over a rushnyk-style embroidered ribbon. The same star is the Das OS logo and the menu button. - Light and dark wallpapers show the star over a rushnyk-style embroidered ribbon. The same star is the Das OS logo and the menu button.
- Fonts are Inter for the interface and JetBrains Mono in the terminal. Icons are Papirus and the cursor theme is Adwaita. - Fonts are Inter for the interface and JetBrains Mono in the terminal. Icons are Papirus (the Papirus-Light variant, so the panel icons are dark) and the cursor theme is Adwaita.
#### Desktop defaults<a id='Defaults' /> #### Desktop defaults<a id='Defaults' />
**_The das-settings package configures Xfce without touching files owned by other packages._** **_The das-settings package configures Xfce without touching files owned by other packages._**
@@ -178,6 +207,7 @@ Void Linux is fast, simple and independent: runit instead of systemd, and its ow
- `srcpkgs/das-snapshots` contains the snapper configuration, `das-snapper-setup` and the dracut module for booting snapshots. - `srcpkgs/das-snapshots` contains the snapper configuration, `das-snapper-setup` and the dracut module for booting snapshots.
- `srcpkgs/das-installer` contains the Calamares settings and branding, `srcpkgs/das-server-installer` the console installer. The steps both share (`mount-source`, `postinstall`) are in `srcpkgs/das-install-common`. They exist only on the live ISOs. - `srcpkgs/das-installer` contains the Calamares settings and branding, `srcpkgs/das-server-installer` the console installer. The steps both share (`mount-source`, `postinstall`) are in `srcpkgs/das-install-common`. They exist only on the live ISOs.
- `srcpkgs/das-server` is the server metapackage with the nftables, fail2ban, sshd and Docker defaults. `srcpkgs/das-helpers` contains `dsv`, `duser`, `dfw`, `dsys`, `dprofile` and the profiles. - `srcpkgs/das-server` is the server metapackage with the nftables, fail2ban, sshd and Docker defaults. `srcpkgs/das-helpers` contains `dsv`, `duser`, `dfw`, `dsys`, `dprofile` and the profiles.
- `srcpkgs/publisher` (`publish`) and `srcpkgs/deployer` (`deploy`) are the developer's TUI/CLI tools that publish builds and deploy projects to a server over ssh + rsync. Each one keeps its config in `~/.config/<name>/` (an example is copied there on the first run) and its history in `~/.local/state/<name>/`.
- `srcpkgs/calamares`, `srcpkgs/adw-gtk3` and `srcpkgs/font-jetbrains-mono` package upstream releases that Void does not have. - `srcpkgs/calamares`, `srcpkgs/adw-gtk3` and `srcpkgs/font-jetbrains-mono` package upstream releases that Void does not have.
- `make packages` lays the templates over a shallow clone of void-packages and builds them with `xbps-src` into `out/hostdir/binpkgs`. The ISO build installs them from there. - `make packages` lays the templates over a shallow clone of void-packages and builds them with `xbps-src` into `out/hostdir/binpkgs`. The ISO build installs them from there.
- A package is rebuilt only when its directory in `srcpkgs/` changed, its binary package is missing, or Void no longer provides a shared library it links to (checked with a dry-run install). `DASOS_REBUILD=1 make packages` rebuilds everything. - A package is rebuilt only when its directory in `srcpkgs/` changed, its binary package is missing, or Void no longer provides a shared library it links to (checked with a dry-run install). `DASOS_REBUILD=1 make packages` rebuilds everything.
@@ -186,7 +216,7 @@ Void Linux is fast, simple and independent: runit instead of systemd, and its ow
**_The whole build runs in a Docker container, so only Docker and QEMU are needed on the host._** **_The whole build runs in a Docker container, so only Docker and QEMU are needed on the host._**
1. `make packages` builds the local xbps repository. 1. `make packages` builds the local xbps repository.
2. `make iso` builds the packages and then the ISO with `void-mklive`, using the profile in `live/` (package list, services, root file system overlay). `make iso-server` does the same for the server profile in `server/`. 2. `make iso` builds the packages and then the ISO with `void-mklive`, using the profile in `live/` (package list, services, root file system overlay). `make iso-server` does the same for the server profile in `server/`.
3. `make qemu` boots the newest ISO with UEFI. `make qemu-bios` uses legacy BIOS, and `make qemu-disk` also attaches a 20 GB virtual disk to install on. `make qemu-server` boots the server ISO with the disk on a serial console in the terminal (Ctrl-A X quits). 3. `make qemu` boots the newest ISO with UEFI. `make qemu-bios` uses legacy BIOS, and `make qemu-disk` also attaches a 20 GB virtual disk to install on. `make qemu-server` boots the server ISO with the disk on a serial console in the terminal (Ctrl-A X quits). The guest's SSH is forwarded to `localhost:2222` (`DASOS_SSH_PORT` changes it).
#### CI build<a id='CI' /> #### CI build<a id='CI' />
**_Every push to `main` or `dev` and every `v*` tag builds the packages and both ISOs on git.das.org.ua._** **_Every push to `main` or `dev` and every `v*` tag builds the packages and both ISOs on git.das.org.ua._**
@@ -197,17 +227,17 @@ Void Linux is fast, simple and independent: runit instead of systemd, and its ow
- A `v*` tag that matches `VERSION` also publishes both ISOs and `SHA256SUMS` as a release (`ci/release.sh`, with the job's own token): `git tag v0.8.0 && git push origin v0.8.0`. Other builds keep the ISOs on the CI host in `/var/lib/dasos-ci/out/iso`. - A `v*` tag that matches `VERSION` also publishes both ISOs and `SHA256SUMS` as a release (`ci/release.sh`, with the job's own token): `git tag v0.8.0 && git push origin v0.8.0`. Other builds keep the ISOs on the CI host in `/var/lib/dasos-ci/out/iso`.
### 🚀 Building the application ### 🚀 Building the application
Requirements: Linux with Docker (the user must be in the `docker` group), QEMU and OVMF for testing, and Go 1.24+ with the GTK 3 development files for `make test`. About 10 GB of free disk space. Requirements: Linux with Docker (the user must be in the `docker` group), QEMU and OVMF for testing, and Go 1.24+ with the GTK 3 development files plus Python 3 for `make test`. About 10 GB of free disk space.
```shell ```shell
make iso # build packages and the live ISO (the first run downloads ~2 GB) make iso # build packages and the live ISO (the first run downloads ~2 GB)
make qemu # boot it in QEMU with UEFI make qemu # boot it in QEMU with UEFI
make iso-server # build packages and the server ISO make iso-server # build packages and the server ISO
make qemu-server # boot it on a serial console with a disk to install on make qemu-server # boot it on a serial console with a disk to install on
make test # run Go tests make test # run Go and Python tests
make clean # remove build output, keep the download cache make clean # remove build output, keep the download cache
``` ```
The ISOs appear in `out/iso/dasos-<version>-x86_64.iso` and `out/iso/dasos-server-<version>-x86_64.iso`. Write them to a USB stick with `dd` or Ventoy. The ISOs appear in `out/iso/dasos-<version>-x86_64.iso` and `out/iso/dasos-server-<version>-x86_64.iso`. Write them to a USB stick with `dd` or Ventoy. For a Ventoy theme there is a 48×48 menu icon `live/branding/ventoy/dasos.png`: copy it to the theme's `icons/` folder and add `{ "key": "dasos", "class": "dasos" }` to `menu_class` in `ventoy.json`.
**Note:** Das OS is an early version (0.8.0). Packages and ISOs are built in CI; signed repository hosting is planned for an upcoming stage. Until the repository is hosted, an installed system gets updates from Void only. Das OS is based on Void Linux but is not affiliated with it. **Note:** Das OS is an early version (0.9.3). Packages and ISOs are built in CI; signed repository hosting is planned for an upcoming stage. Until the repository is hosted, an installed system gets updates from Void only. Das OS is based on Void Linux but is not affiliated with it.
###### _Made by -=:dAs:=-_ ###### _Made by -=:dAs:=-_
+38 -8
View File
@@ -1,7 +1,7 @@
## Das OS ## Das OS
### _Легкий і дружній десктоп на базі Void Linux з Xfce та українським колоритом, а також консольна серверна версія_ ### _Легкий і дружній десктоп на базі Void Linux з Xfce та українським колоритом, а також консольна серверна версія_
[![Void Linux](https://img.shields.io/badge/Void_Linux-x86__64_glibc-green)](https://voidlinux.org) [![Xfce](https://img.shields.io/badge/Xfce-4.20-orange)](https://xfce.org) [![runit](https://img.shields.io/badge/init-runit-blue)](https://docs.voidlinux.org/config/services/) [![Go](https://img.shields.io/badge/Go-1.24+-blue)](https://go.dev) [![Docker](https://img.shields.io/badge/Docker-29-blue)](https://www.docker.com) [![Version](https://img.shields.io/badge/version-0.8.0-lightgrey)](#) [![Void Linux](https://img.shields.io/badge/Void_Linux-x86__64_glibc-green)](https://voidlinux.org) [![Xfce](https://img.shields.io/badge/Xfce-4.20-orange)](https://xfce.org) [![runit](https://img.shields.io/badge/init-runit-blue)](https://docs.voidlinux.org/config/services/) [![Go](https://img.shields.io/badge/Go-1.24+-blue)](https://go.dev) [![Docker](https://img.shields.io/badge/Docker-29-blue)](https://www.docker.com) [![Python](https://img.shields.io/badge/Python-3-blue)](https://www.python.org) [![Textual](https://img.shields.io/badge/Textual-8.2-blue)](https://textual.textualize.io) [![Version](https://img.shields.io/badge/version-0.9.3-lightgrey)](#)
🇬🇧 [English version](README.md) 🇬🇧 [English version](README.md)
@@ -16,6 +16,8 @@ Void Linux швидкий, простий і незалежний: runit зам
- [Вікно привітання](#Welcome) під час першого входу: мова, теки, оновлення, профіль розробника та довідка - [Вікно привітання](#Welcome) під час першого входу: мова, теки, оновлення, профіль розробника та довідка
- [Серверний образ](#Server): консольний live ISO з текстовим інсталятором, SSH, файрволом, fail2ban і Docker - [Серверний образ](#Server): консольний live ISO з текстовим інсталятором, SSH, файрволом, fail2ban і Docker
- [Консольні помічники](#Helpers) для щоденного адміністрування: служби (`dsv`), користувачі (`duser`), брандмауер (`dfw`) і системні налаштування (`dsys`) - [Консольні помічники](#Helpers) для щоденного адміністрування: служби (`dsv`), користувачі (`duser`), брандмауер (`dfw`) і системні налаштування (`dsys`)
- [Запрошення терміналу й банер при вході по SSH](#Terminal): запрошення Das за замовчуванням, конструктор запрошень `dprompt` і редактор банера `dbanner` з логотипом Das OS і тризубом
- [Аліаси й функції](#Aliases) у кожному терміналі: `extract`, `mkcd`, `netinfo`, `pkgowner`, скорочення для Docker та інші, а `lsfunc` показує їх список
- [Профіль розробника](#DevProfile) однією командою: Go, Python, Node.js, Docker, rootless Podman, VS Code і середовища з Flathub - [Профіль розробника](#DevProfile) однією командою: Go, Python, Node.js, Docker, rootless Podman, VS Code і середовища з Flathub
- [Оформлення Das OS](#Look): світла й темна теми, згенеровані рамки вікон, шпалери та логотип - [Оформлення Das OS](#Look): світла й темна теми, згенеровані рамки вікон, шпалери та логотип
- [Налаштування за замовчуванням](#Defaults) для Xfce, які не перезаписують стандартні файли - [Налаштування за замовчуванням](#Defaults) для Xfce, які не перезаписують стандартні файли
@@ -134,7 +136,7 @@ Void Linux швидкий, простий і незалежний: runit зам
- Встановлена система запускає `sshd`, `chronyd`, `nftables`, `fail2ban`, `docker`, `socklog` і вибрану мережеву службу. - Встановлена система запускає `sshd`, `chronyd`, `nftables`, `fail2ban`, `docker`, `socklog` і вибрану мережеву службу.
- nftables пропускає лише SSH, ICMP і DHCPv6 (`/etc/nftables.conf`), інші порти відкриває `dfw`. Правила лежать у власній таблиці `inet das`, тож їх перезавантаження не чіпає правил Docker і fail2ban. Порти, які публікують контейнери, проходять через власні правила Docker. - nftables пропускає лише SSH, ICMP і DHCPv6 (`/etc/nftables.conf`), інші порти відкриває `dfw`. Правила лежать у власній таблиці `inet das`, тож їх перезавантаження не чіпає правил Docker і fail2ban. Порти, які публікують контейнери, проходять через власні правила Docker.
- fail2ban блокує адреси, з яких раз у раз не вдається увійти по SSH, читаючи журнал socklog. sshd не приймає вхід root за паролем. - fail2ban блокує адреси, з яких раз у раз не вдається увійти по SSH, читаючи журнал socklog. sshd не приймає вхід root за паролем.
- Інструменти: Docker з Compose, lazydocker, lazygit, Midnight Commander, tmux, htop, Python 3, curl, `das-update --cli`, [помічники](#Helpers) і xtools (`xi`, `xq`, `xrs`). Графічних бібліотек немає, як і прошивок для відеокарт NVIDIA та звукових карт (`/etc/xbps.d/das-server.conf` не дає оновленням повернути їх). - Інструменти: Docker з Compose, lazydocker, lazygit, Midnight Commander, tmux, htop, ncdu, Python 3, curl, `das-update --cli`, [помічники](#Helpers), [`dprompt` і `dbanner`](#Terminal), [аліаси й функції](#Aliases) та xtools (`xi`, `xq`, `xrs`). Графічних бібліотек немає, як і прошивок для відеокарт NVIDIA та звукових карт (`/etc/xbps.d/das-server.conf` не дає оновленням повернути їх).
#### Консольні помічники<a id='Helpers' /> #### Консольні помічники<a id='Helpers' />
**_Короткі команди для задач, які в дистрибутивах із systemd розв'язують systemctl, hostnamectl тощо, а у Void доводиться робити вручну._** **_Короткі команди для задач, які в дистрибутивах із systemd розв'язують systemctl, hostnamectl тощо, а у Void доводиться робити вручну._**
@@ -146,6 +148,33 @@ Void Linux швидкий, простий і незалежний: runit зам
- `dsys` замінює hostnamectl, timedatectl і localectl: `info` (синхронізація часу, мова, консоль, адреси), `hostname`, `timezone` (досить частини назви: `dsys timezone Kyiv`), `locale` (збирає локаль glibc, якщо треба) і `keymap` (з кириличним шрифтом консолі для кириличних розкладок). - `dsys` замінює hostnamectl, timedatectl і localectl: `info` (синхронізація часу, мова, консоль, адреси), `hostname`, `timezone` (досить частини назви: `dsys timezone Kyiv`), `locale` (збирає локаль glibc, якщо треба) і `keymap` (з кириличним шрифтом консолі для кириличних розкладок).
- Вони входять у пакет `das-helpers` і є й на десктопі. Обидва образи також містять `xtools-minimal` з Void для роботи з пакетами: `xi` встановлює, `xrs` шукає, `xq` показує відомості про пакет, а `xcheckrestart` знаходить програми, які треба перезапустити після оновлення. - Вони входять у пакет `das-helpers` і є й на десктопі. Обидва образи також містять `xtools-minimal` з Void для роботи з пакетами: `xi` встановлює, `xrs` шукає, `xq` показує відомості про пакет, а `xcheckrestart` знаходить програми, які треба перезапустити після оновлення.
#### Запрошення терміналу й банер при вході по SSH<a id='Terminal' />
**_Дворядкове запрошення bash одразу після встановлення і два редактори в терміналі: для запрошення і для банера, який видно при вході по SSH._**
- Стандартне запрошення: `┌─[✗]─[user@host]─[2026-10-21]─[08:12]─[~/dir]` і `└─╼ $` у другому рядку. `[✗]` з'являється лише після команди з помилкою, ім'я root червоне, а без кольорів чи в консолі Linux вигляд той самий.
- Його ставить `/etc/bash/bashrc.d/das-prompt.sh`: `~/.config/das/prompt.sh` користувача, інакше `/etc/das/prompt.sh`, інакше стандартне. `/etc/skel/.bashrc` з Void потім задає власний PS1, тож його замінено один раз, а власний PS1 користувача не чіпається.
- `dprompt` — конструктор запрошень: готові варіанти (`das`, `das-git` з гілкою git, venv і фоновими задачами, `server`, `minimal`, `classic`), сегменти можна додавати, переставляти й фарбувати, а живий перегляд показує запрошення звичайного користувача, після помилки і для root. Ctrl+S застосовує його для себе, Ctrl+G — для всіх через sudo. Без редактора: `dprompt list`, `dprompt show НАЗВА`, `dprompt apply НАЗВА [--system]`, `dprompt reset`.
- Умовні частини (код помилки, git, venv, задачі, SSH, вивід команди) обчислює хук у `PROMPT_COMMAND`, а PS1 лише читає їх, тож назва гілки ніколи не виконується як код.
- `dbanner` редагує банер при вході по SSH. Він починає зі списку готових банерів із живим переглядом: `das-classic` (рамка з `#`, назва, IP і посилання), `double` (ім'я хоста великими літерами figlet), `blocks` (логотип Das OS і дані системи), `ukraine` (малий герб України), `tryzub`, `dashboard` і `minimal`.
- Редактор малює мишею або з клавіатури: олівець, гумка, лінія, рамки (одинарна, подвійна, заокруглена, жирна, ASCII, `#`, блоки), заливка, текст, великі літери, виділення з копіюванням і вставкою, скасування. Палітра — символи рамок і блоків, 16 кольорів тексту й тла, жирний шрифт. Готові малюнки вставляються одним клацанням: логотип Das OS і тризуб, окремо чи на щиті, у двох розмірах.
- Поля показують живі значення фіксованої ширини, тож рамка не зсувається: `{hostname}`, `{ip}`, `{uptime}`, `{mem}`, `{os}`, `{kernel}` та інші, а також власні змінні на кшталт `{url}`; підписи `[[українською|англійською]]` йдуть мовою користувача.
- Віджети додають блоки під малюнком або праворуч: диски зі смужками заповнення, пам'ять, навантаження, мережа, контейнери Docker, доступні оновлення, погода з wttr.in, стан дисків SMART, служби runit, що не працюють, блокування fail2ban, знімки snapper, попередній вхід або будь-яка команда чи файл із виводом скрипта.
- Швидкі віджети виконуються при вході з коротким тайм-аутом. Повільні чи ті, що потребують root (оновлення, погода, SMART, fail2ban), оновлює служба runit `das-banner` у `/var/cache/das/banner`, і `dbanner apply` вмикає її, коли банерові це треба. Cron не потрібен.
- Чернетки лежать у `~/.config/das/banner`; Ctrl+A або `dbanner apply НАЗВА` записує `/etc/das/banner.json` через sudo. `dbanner off` і `on` вимикають банер для всіх, `~/.hushlogin` — для одного користувача; `"local": true` показує його й при вході в консоль.
#### Аліаси й функції<a id='Aliases' />
**_Готові скорочення для щоденної роботи в терміналі в кожному інтерактивному bash, переписані під Void: xbps, nftables, runit і Docker._**
- Їх завантажує `/etc/bash/bashrc.d/das-aliases.sh` (пакет `das-shell`): `/usr/share/das/shell/aliases.sh` і `functions.sh`, а потім ваші `~/.bash_aliases` і `~/.bash_functions`, які мають перевагу. `touch ~/.config/das/no-aliases` вимикає аліаси Das OS для одного користувача. `lsfunc` показує функції з описом мовою користувача, `alias` — аліаси.
- `ls`: `ll`, `la`, `l`, `sl`, `lsl` (у `less`), `lsz` (за розміром), `lst` (за часом), `lsd` (лише каталоги); `grep`, `diff` і `ip` у кольорі.
- Каталоги: `..`, `...`, `....`, `cd..`, `up [N]` (на N рівнів вгору), `mkcd КАТАЛОГ` (створити й перейти), `cl КАТАЛОГ` (перейти й показати), `path` (`PATH` по одному запису в рядку); `mkdir` створює й батьківські каталоги.
- Файли: `f ШАБЛОН [КАТАЛОГ]` (пошук за назвою без регістру), `fhere НАЗВА`, `cnt` (кількість файлів), `bigfiles [КАТАЛОГ] [N]`, `dirsize [КАТАЛОГ]` (розміри всього в каталозі), `whatsize [КАТАЛОГ]` (розміри підкаталогів, типово `/`), `bu ФАЙЛ...` (копія з датою в назві), `ednt ФАЙЛ` (редагувати, зберігши час зміни).
- Архіви: `extract АРХІВ...` розпаковує tar з будь-яким стисненням, `.xbps`, zip, rar, 7z, gz, bz2, xz, zst, lz4 і Z; `compress НАЗВА.tar.gz|tar.xz|tar.zst|zip|7z ФАЙЛ...` пакує за розширенням назви. Якщо бракує програми, функція називає пакет, який треба встановити.
- Диски й пам'ять: `df`, `dft` (з типами й підсумком), `dftl`, `mnt` (змонтовані пристрої), `mounts`, `devid` (lsblk, blkid і lsusb), `snaps` (знімки кореня), `free`, `meminfo`, `cpuinfo`, `gpumeminfo`.
- Процеси й історія: `psa ТЕКСТ`, `pst ТЕКСТ` (деревом), `psg`, `psmem`/`psmem10`, `pscpu`/`pscpu10`, `j`, `hgrep ТЕКСТ`, `histat [N]` і `histop10` (найчастіші команди), `EC` (код попередньої команди), `please` (попередня команда ще раз через sudo).
- Пакети: `upd` і `updy` (`das-update --cli` зі знімками, `updy` без запитань), `pkgshow [ТЕКСТ]` (встановлені), `pkgfind ТЕКСТ` (у репозиторіях), `pkgowner ФАЙЛ|КОМАНДА`, `pkgclean` (кеш пакетів і сироти).
- Мережа: `myip`, `netinfo` (адреси, шлюз, DNS, зовнішня IP), `oports` і `gport ТЕКСТ` (відкриті порти), `ping5`, `fastping`, `header URL` і `headerc URL` (заголовки HTTP, зі стисненням), `listen ПОРТ`, `serve [ПОРТ]` (поточний каталог по HTTP), `rmhost ХОСТ|РЯДОК` (почистити `known_hosts`), `nftlist`, `firewall` (`dfw status`), `weather [МІСТО]`, `cheat КОМАНДА` (cheat.sh).
- Docker: `dps`, `dlog КОНТЕЙНЕР`, `dcu`, `dcd`, `dcl`, `dcp` (compose up, down, logs, pull), `lzd`, `lzg`.
- Дрібні інструменти: `calc ВИРАЗ`, `epoch [СЕКУНДИ]`, `genpass [ДОВЖИНА]`, `now`, `nowdate`, `note [-c] [ТЕКСТ]` (`~/.notes`), `todo [-l|-r N|-c] [ТЕКСТ]` (`~/.todo`), `csrc ФАЙЛ.c` (скомпілювати й запустити C), `man` з кольоровими заголовками.
#### Профіль розробника<a id='DevProfile' /> #### Профіль розробника<a id='DevProfile' />
**_`dprofile install dev` перетворює десктоп чи сервер на машину для розробки, перед цим створивши знімок системи._** **_`dprofile install dev` перетворює десктоп чи сервер на машину для розробки, перед цим створивши знімок системи._**
- Пакети з репозиторію Void: `base-devel`, gdb, strace, Git з LFS, GitHub CLI, lazygit, Go з gopls, delve і golangci-lint, Python з pip, virtualenv і uv, Node.js, Docker з Compose і Buildx, lazydocker, Podman з podman-compose, jq, ripgrep, fd, fzf, direnv, shellcheck і SQLite. - Пакети з репозиторію Void: `base-devel`, gdb, strace, Git з LFS, GitHub CLI, lazygit, Go з gopls, delve і golangci-lint, Python з pip, virtualenv і uv, Node.js, Docker з Compose і Buildx, lazydocker, Podman з podman-compose, jq, ripgrep, fd, fzf, direnv, shellcheck і SQLite.
@@ -160,7 +189,7 @@ Void Linux швидкий, простий і незалежний: runit зам
- GTK-теми `DasOS` і `DasOS-Dark` побудовані на adw-gtk3 і додають акцентні кольори Das OS. Вони також оформлюють панель Xfce, яку adw-gtk3 лишає чорною. - GTK-теми `DasOS` і `DasOS-Dark` побудовані на adw-gtk3 і додають акцентні кольори Das OS. Вони також оформлюють панель Xfce, яку adw-gtk3 лишає чорною.
- Рамки вікон xfwm4 (заокруглені кути, круглі кнопки, червона кнопка закриття при наведенні) генерує у PNG утиліта `xfwm4gen` (Go) з однієї палітри. - Рамки вікон xfwm4 (заокруглені кути, круглі кнопки, червона кнопка закриття при наведенні) генерує у PNG утиліта `xfwm4gen` (Go) з однієї палітри.
- Світлі й темні шпалери зображують зірку над смугою орнаменту в стилі рушника. Та сама зірка є логотипом Das OS і кнопкою меню. - Світлі й темні шпалери зображують зірку над смугою орнаменту в стилі рушника. Та сама зірка є логотипом Das OS і кнопкою меню.
- Шрифти: Inter для інтерфейсу і JetBrains Mono в терміналі. Іконки Papirus, тема курсора Adwaita. - Шрифти: Inter для інтерфейсу і JetBrains Mono в терміналі. Іконки Papirus (варіант Papirus-Light, тому значки панелі темні), тема курсора Adwaita.
#### Налаштування за замовчуванням<a id='Defaults' /> #### Налаштування за замовчуванням<a id='Defaults' />
**_Пакет das-settings налаштовує Xfce, не чіпаючи файлів інших пакетів._** **_Пакет das-settings налаштовує Xfce, не чіпаючи файлів інших пакетів._**
@@ -178,6 +207,7 @@ Void Linux швидкий, простий і незалежний: runit зам
- `srcpkgs/das-snapshots` містить конфігурацію snapper, `das-snapper-setup` і dracut-модуль для завантаження знімків. - `srcpkgs/das-snapshots` містить конфігурацію snapper, `das-snapper-setup` і dracut-модуль для завантаження знімків.
- `srcpkgs/das-installer` містить налаштування й брендинг Calamares, `srcpkgs/das-server-installer` — консольний інсталятор. Спільні для обох кроки (`mount-source`, `postinstall`) лежать у `srcpkgs/das-install-common`. Вони є лише на live-ISO. - `srcpkgs/das-installer` містить налаштування й брендинг Calamares, `srcpkgs/das-server-installer` — консольний інсталятор. Спільні для обох кроки (`mount-source`, `postinstall`) лежать у `srcpkgs/das-install-common`. Вони є лише на live-ISO.
- `srcpkgs/das-server` — серверний метапакет із налаштуваннями nftables, fail2ban, sshd і Docker. `srcpkgs/das-helpers` містить `dsv`, `duser`, `dfw`, `dsys`, `dprofile` і профілі. - `srcpkgs/das-server` — серверний метапакет із налаштуваннями nftables, fail2ban, sshd і Docker. `srcpkgs/das-helpers` містить `dsv`, `duser`, `dfw`, `dsys`, `dprofile` і профілі.
- `srcpkgs/publisher` (`publish`) і `srcpkgs/deployer` (`deploy`) — інструменти розробника з TUI і командним рядком: публікують збірки й розгортають проєкти на сервері через ssh + rsync. Конфіг кожного лежить у `~/.config/<назва>/` (приклад копіюється туди при першому запуску), історія — у `~/.local/state/<назва>/`.
- `srcpkgs/calamares`, `srcpkgs/adw-gtk3` і `srcpkgs/font-jetbrains-mono` пакують релізи, яких немає у Void. - `srcpkgs/calamares`, `srcpkgs/adw-gtk3` і `srcpkgs/font-jetbrains-mono` пакують релізи, яких немає у Void.
- `make packages` накладає шаблони на неглибокий клон void-packages і збирає їх через `xbps-src` в `out/hostdir/binpkgs`. Збирання ISO встановлює їх звідти. - `make packages` накладає шаблони на неглибокий клон void-packages і збирає їх через `xbps-src` в `out/hostdir/binpkgs`. Збирання ISO встановлює їх звідти.
- Пакет перезбирається, лише коли змінився його каталог у `srcpkgs/`, зник бінарний пакет або Void більше не надає бібліотеку, з якою він зібраний (перевіряється пробним встановленням). `DASOS_REBUILD=1 make packages` перезбирає все. - Пакет перезбирається, лише коли змінився його каталог у `srcpkgs/`, зник бінарний пакет або Void більше не надає бібліотеку, з якою він зібраний (перевіряється пробним встановленням). `DASOS_REBUILD=1 make packages` перезбирає все.
@@ -186,7 +216,7 @@ Void Linux швидкий, простий і незалежний: runit зам
**_Усе збирання відбувається в Docker-контейнері, тож на хості потрібні лише Docker і QEMU._** **_Усе збирання відбувається в Docker-контейнері, тож на хості потрібні лише Docker і QEMU._**
1. `make packages` збирає локальний xbps-репозиторій. 1. `make packages` збирає локальний xbps-репозиторій.
2. `make iso` збирає пакети, а потім ISO через `void-mklive` за профілем у `live/` (список пакетів, сервіси, оверлей кореневої файлової системи). `make iso-server` робить те саме для серверного профілю в `server/`. 2. `make iso` збирає пакети, а потім ISO через `void-mklive` за профілем у `live/` (список пакетів, сервіси, оверлей кореневої файлової системи). `make iso-server` робить те саме для серверного профілю в `server/`.
3. `make qemu` завантажує найновіший ISO з UEFI. `make qemu-bios` використовує старий BIOS, а `make qemu-disk` ще й підключає віртуальний диск на 20 ГБ для встановлення. `make qemu-server` завантажує серверний ISO з диском на послідовній консолі прямо в терміналі (вихід — Ctrl-A X). 3. `make qemu` завантажує найновіший ISO з UEFI. `make qemu-bios` використовує старий BIOS, а `make qemu-disk` ще й підключає віртуальний диск на 20 ГБ для встановлення. `make qemu-server` завантажує серверний ISO з диском на послідовній консолі прямо в терміналі (вихід — Ctrl-A X). SSH гостьової системи доступний на `localhost:2222` (змінює `DASOS_SSH_PORT`).
#### Збирання в CI<a id='CI' /> #### Збирання в CI<a id='CI' />
**_Кожен push у `main` чи `dev` і кожен тег `v*` збирає пакети й обидва ISO на git.das.org.ua._** **_Кожен push у `main` чи `dev` і кожен тег `v*` збирає пакети й обидва ISO на git.das.org.ua._**
@@ -197,17 +227,17 @@ Void Linux швидкий, простий і незалежний: runit зам
- Тег `v*`, що збігається з `VERSION`, ще й публікує обидва ISO та `SHA256SUMS` як реліз (`ci/release.sh`, з власним токеном завдання): `git tag v0.8.0 && git push origin v0.8.0`. Інші збирання залишають ISO на CI-хості в `/var/lib/dasos-ci/out/iso`. - Тег `v*`, що збігається з `VERSION`, ще й публікує обидва ISO та `SHA256SUMS` як реліз (`ci/release.sh`, з власним токеном завдання): `git tag v0.8.0 && git push origin v0.8.0`. Інші збирання залишають ISO на CI-хості в `/var/lib/dasos-ci/out/iso`.
### 🚀 Збирання застосунку ### 🚀 Збирання застосунку
Вимоги: Linux із Docker (користувач має бути в групі `docker`), QEMU та OVMF для тестування, Go 1.24+ і файли розробки GTK 3 для `make test`. Приблизно 10 ГБ вільного місця. Вимоги: Linux із Docker (користувач має бути в групі `docker`), QEMU та OVMF для тестування, Go 1.24+ і файли розробки GTK 3 та Python 3 для `make test`. Приблизно 10 ГБ вільного місця.
```shell ```shell
make iso # зібрати пакети й live-ISO (перший запуск завантажує ~2 ГБ) make iso # зібрати пакети й live-ISO (перший запуск завантажує ~2 ГБ)
make qemu # запустити його в QEMU з UEFI make qemu # запустити його в QEMU з UEFI
make iso-server # зібрати пакети й серверний ISO make iso-server # зібрати пакети й серверний ISO
make qemu-server # запустити його на послідовній консолі з диском для встановлення make qemu-server # запустити його на послідовній консолі з диском для встановлення
make test # запустити Go-тести make test # запустити тести Go і Python
make clean # видалити результати збирання, зберегти кеш завантажень make clean # видалити результати збирання, зберегти кеш завантажень
``` ```
ISO з'являються в `out/iso/dasos-<версія>-x86_64.iso` і `out/iso/dasos-server-<версія>-x86_64.iso`. Записати їх на флешку можна через `dd` або Ventoy. ISO з'являються в `out/iso/dasos-<версія>-x86_64.iso` і `out/iso/dasos-server-<версія>-x86_64.iso`. Записати їх на флешку можна через `dd` або Ventoy. Для теми Ventoy є значок меню 48×48 `live/branding/ventoy/dasos.png`: скопіюйте його в теку `icons/` теми й додайте `{ "key": "dasos", "class": "dasos" }` до `menu_class` у `ventoy.json`.
**Примітка:** Das OS — рання версія (0.8.0). Пакети й ISO збираються в CI; підписаний репозиторій на хостингу заплановано на наступний етап. Поки репозиторій не розміщено на хостингу, встановлена система отримує оновлення лише з Void. Das OS базується на Void Linux, але не пов'язана з ним офіційно. **Примітка:** Das OS — рання версія (0.9.3). Пакети й ISO збираються в CI; підписаний репозиторій на хостингу заплановано на наступний етап. Поки репозиторій не розміщено на хостингу, встановлена система отримує оновлення лише з Void. Das OS базується на Void Linux, але не пов'язана з ним офіційно.
###### _Made by -=:dAs:=-_ ###### _Made by -=:dAs:=-_
+38 -3
View File
@@ -1,6 +1,6 @@
# Das OS — що зробити далі # Das OS — що зробити далі
Стан на 2026-10-20: версія 0.8.0, етапи 1–8 готові (live ISO, власний репозиторій, оформлення, інсталятор Calamares зі знімками Btrfs, Go-утиліти `das-update` і `das-welcome`, серверний образ з консольним інсталятором, менші ISO, консольні помічники, профіль розробника), CI (етап 9: пакети й обидва ISO збираються на власному runner, ~45 хв). Стан на 2026-10-25: версія 0.9.8 (не закомічено), етапи 1–8 готові (live ISO, власний репозиторій, оформлення, інсталятор Calamares зі знімками Btrfs, Go-утиліти `das-update` і `das-welcome`, серверний образ з консольним інсталятором, менші ISO, консольні помічники, профіль розробника), CI (етап 9: пакети й обидва ISO збираються на власному runner, ~45 хв).
## Етап 5. Go-утиліти (готово, лишилися перевірки) ## Етап 5. Go-утиліти (готово, лишилися перевірки)
- [x] `das-update`: вікно, значок у треї (лише коли є оновлення), консольний режим `--cli`, знімок snapper pre/post, встановлення через pkexec, підказка про відкат. - [x] `das-update`: вікно, значок у треї (лише коли є оновлення), консольний режим `--cli`, знімок snapper pre/post, встановлення через pkexec, підказка про відкат.
@@ -27,6 +27,10 @@ Live ISO без графіки з консольним інсталятором:
- [x] Перевірено в QEMU: встановлення через TUI на UEFI (з SSH-ключем) і BIOS (з паролем, NetworkManager, `ua-utf`), вхід по SSH лише за ключем, `docker run hello-world`, опублікований порт nginx, fail2ban бачить невдалі входи, `das-update --cli` зі знімками pre/post, `dsv`. - [x] Перевірено в QEMU: встановлення через TUI на UEFI (з SSH-ключем) і BIOS (з паролем, NetworkManager, `ua-utf`), вхід по SSH лише за ключем, `docker run hello-world`, опублікований порт nginx, fail2ban бачить невдалі входи, `das-update --cli` зі знімками pre/post, `dsv`.
- [x] `das-server-install --config` перевірено (0.5.0, UEFI). - [x] `das-server-install --config` перевірено (0.5.0, UEFI).
- [x] Розмір ISO: див. нижче. - [x] Розмір ISO: див. нижче.
- [x] Запрошення до входу (`/etc/issue`) показувало версію ядра (`\r` agetty, 6.18): тепер версія Das OS з `VERSION` (`@VERSION@` у `branding/issue`), серверний інсталятор переносить перший рядок у встановлену систему (0.9.2).
- [x] Українська в `das-server-install` на консолі показувалася латиницею («Ukra■ns'ka»): стандартний шрифт підміняє кирилицю схожими латинськими літерами, а `setfont` не спрацьовував, бо `sudo` запускає інсталятор у власному pty (`tty` → `/dev/pts/N`). Тепер `setfont -C /dev/tty0 cyr-sun16` до вибору мови (перевірено в QEMU: «Гаразд їєіґ»).
- [x] Серед дисків для встановлення був дисковод `/dev/fd0`: тепер пропускається.
- [x] Перевірено серверний ISO 0.9.2 у QEMU: «Das OS Server 0.9.2 (tty1)» у запрошенні, «Українська» в меню мови, `/dev/fd0` немає серед дисків.
### Розмір ISO: 0.4.0 → 0.5.0 ### Розмір ISO: 0.4.0 → 0.5.0
Десктоп 1,96 → 1,72 ГБ, сервер 1,57 → 1,14 ГБ. Десктоп 1,96 → 1,72 ГБ, сервер 1,57 → 1,14 ГБ.
@@ -62,12 +66,33 @@ Live ISO без графіки з консольним інсталятором:
## Етап 9. CI (готово) ## Етап 9. CI (готово)
- [x] Workflow `.gitea/workflows/build.yml`: `make iso iso-server` на власному runner `dasci:host` (push у `main`/`dev`, теги `v*`, ручний запуск). - [x] Workflow `.gitea/workflows/build.yml`: `make iso iso-server` на власному runner `dasci:host` (push у `main`/`dev`, теги `v*`, ручний запуск).
- [x] CI-хост: Windows-машина (192.168.88.193), WSL2 Ubuntu 24.04 `dasci` у `D:\DasOS\wsl`, Docker Engine, `gitea-runner` 5.1.1 як служба systemd, автозапуск завданням планувальника (`ci/runner`). - [x] CI-хост: Windows-машина (192.168.88.193), WSL2 Ubuntu 24.04 `dasci` у `C:\DasOS\wsl` (SSD), Docker Engine, `gitea-runner` 5.1.1 як служба systemd, автозапуск завданням планувальника (`ci/runner`).
- [x] `DASOS_OUT` у `scripts/builder.sh`: кеш `out/` на CI-хості в `/var/lib/dasos-ci/out`. - [x] `DASOS_OUT` у `scripts/builder.sh`: кеш `out/` на CI-хості в `/var/lib/dasos-ci/out`.
- [x] Runner зареєстровано, перший запуск у Gitea (run 1) успішний за 46 хв. - [x] Runner зареєстровано, перший запуск у Gitea (run 1) успішний за 46 хв.
- [x] Після перезавантаження Windows runner піднімається сам, без входу в систему (завдання S4U). - [x] Після перезавантаження Windows runner піднімається сам, без входу в систему (завдання S4U).
- [x] Тег `v*` публікує обидва ISO і `SHA256SUMS` як реліз Gitea (`ci/release.sh`). Ліміт вкладення релізу — 2 ГБ на файл (`[repository.release] FILE_MAX_SIZE`). - [x] Тег `v*` публікує обидва ISO і `SHA256SUMS` як реліз Gitea (`ci/release.sh`). Ліміт вкладення релізу — 2 ГБ на файл (`[repository.release] FILE_MAX_SIZE`).
- [ ] Перевірити першу публікацію на справжньому тегу. - [x] Перша публікація: реліз v0.8.0 з обома ISO і `SHA256SUMS` (завантаження ~9 хв). Перша спроба впала на 502: Traefik на git.das.org.ua обривав запити довші за 60 с, `readTimeout` піднято до 30m.
## Етап 10. Термінал (0.9.1)
Пакет `das-shell` (десктоп і сервер), Python + Textual.
- [x] Запрошення bash за замовчуванням, як на робочому комп'ютері автора: `┌─[✗]─[user@host]─[дата]─[час]─[каталог]` / `└─╼ $` (`/etc/bash/bashrc.d/das-prompt.sh`, заміна PS1 зі `/etc/skel/.bashrc` Void).
- [x] `dprompt`: конструктор запрошень (5 готових, сегменти, кольори, живий перегляд), застосування для себе або для всіх.
- [x] `dbanner`: редактор банера при вході по SSH (7 готових, полотно з інструментами, поля зі змінними, віджети, служба `das-banner` для кешу), малюнки: логотип Das OS і тризуб (`tools/art`).
- [x] `scripts/run-qemu.sh`: SSH гостя на `localhost:2222`.
- [x] Запрошення за замовчуванням: `└─╼` другого рядка міг бути білим, бо колір успадковувався з першого рядка, а readline перемальовує лише останній. Тепер кожен рядок задає колір заново (das-shell 0.2.0).
- [x] Перевірено автором на сервері (das-shell 0.3.0): `└─╼` червоний, і після кольорового виводу, і після помилки.
- [x] `dbanner`: кнопки внизу стартового екрана перекривав рядок підказок клавіш (обидва були прикріплені до низу): тепер кнопки — звичайний рядок під списком.
- [x] У консолі Linux (`TERM=linux`) рамки кнопок, полів і списків `dprompt`/`dbanner` були квадратиками: Textual малює їх символами `▊▎▔▁`, яких немає в жодному консольному шрифті (до 512 символів; перевірено `cyr-sun16`, `default8x16`, `eurlatgr`, Terminus). Тепер там рамки `┌─┐`, а решта відсутніх символів замінюється на наявні. Символи палітри малювання (★ ☀ ♥…) лишаються як є: це вміст банера для SSH-клієнтів.
- [x] Перевірено автором у консолі сервера: `dprompt` і `dbanner` з рамками `┌─┐` без квадратиків, кнопки `dbanner` видно повністю.
- [x] Аліаси й функції для bash (das-shell 0.3.0, 0.9.3): на основі `~/.bash_aliases` і `~/.bash_functions` автора, переписані під Void (xbps, nftables, `das-update`), з виправленими помилками (`rmhost`, `netinfo`, `todo -r`, `dirsize`, `extract`), двомовними описами для `lsfunc` і новими функціями (`up`, `bigfiles`, `pkgowner`, `please`, `serve`, `weather`, `cheat`, `genpass`, скорочення Docker). Перейменовано те, що затіняло команди: `gh` → `hgrep` (GitHub CLI), `install` прибрано (є `xi`), `mount` → `mounts`, `ed` прибрано.
- [x] Аліаси й функції перевірено автором на сервері (das-shell 0.3.0 з локального репозиторію).
- [x] Аліаси й функції перевірено автором на десктопі (ISO 0.9.3).
- [x] Перевірено автором на реальному сервері (0.9.1): банер `das-classic` з IP при вході по SSH, свій банер і `dbanner apply ukraine`, `dbanner apply dashboard` вмикає службу `das-banner` (погода й оновлення з'являються), запрошення Das і в SSH, і в консолі (`>` замість `╼`).
- [x] `ssh host cmd` виконується без банера (перевірено автором).
- [x] Десктоп: `dprompt` і `dbanner` перевірено автором (ISO 0.9.3).
- [x] Вихід з `dprompt` і `dbanner` однаково Ctrl+Q (у `dbanner` на стартовому екрані лишилась і `q`); 0.9.4.
- [ ] Десктоп, xfce4-terminal: `└─╼` сірий, а не червоний (у консолі сервера червоний). З'ясувати.
- [ ] Узгодити решту клавіш: у `dprompt` Ctrl+S «застосувати», у `dbanner` Ctrl+S «зберегти», Ctrl+A «застосувати».
## Репозиторій пакетів ## Репозиторій пакетів
- [ ] Підпис через `xbps-rindex --sign`, ключ поза репозиторієм. - [ ] Підпис через `xbps-rindex --sign`, ключ поза репозиторієм.
@@ -87,9 +112,19 @@ Live ISO без графіки з консольним інсталятором:
- [ ] Прибирати старі корені `@<дата>`, які лишаються після відкату. - [ ] Прибирати старі корені `@<дата>`, які лишаються після відкату.
## Оформлення та дрібниці ## Оформлення та дрібниці
- [x] Світла тема: значки панелі (трей, звук, розкладка) були блідо-сірими на світлій панелі; тепер текст і значки `#1f2430` (`das-theme` 0.2.0, 0.9.4; перевірити на ISO).
- [x] Значок кнопки меню на вибір: `das-panel-icon star|trident` (зірка `das-logo` за замовчуванням, тризуб `das-trident` жовтий із синім контуром); 0.9.4, перевірити на ISO.
- [x] Значки Wi-Fi і Bluetooth у треї лишались блідими: панельні значки Papirus (`#dfdfdf`) зроблені для темних панелей, а CSS їх не перефарбовує. Світла тема тепер на `Papirus-Light` (`#444`): `xsettings.xml`, `index.theme`, екран входу; `das-theme` 0.3.0, `das-settings` 0.6.0, 0.9.5; перевірити на ISO.
- [x] `das-panel-icon help` (`-h`, `--help`) показує підказку, а не ставить значок «?»; невідомі `-ключі` відхиляються.
- [x] На десктопний образ додано `publisher` (`publish`) і `deployer` (`deploy`): копії проєктів з `~/Dev/Projects/Python`, конфіг у `~/.config/<назва>/` (створюється з прикладу при першому запуску), історія в `~/.local/state/<назва>/`; перевірити на ISO.
- [x] Зміну шляхів конфігу та `history.log` перенесено в оригінальні `publisher` і `deployer` (`config.default_path()`, `pipeline._history_path()`): у checkout усе як було, у пакеті — `~/.config` і `~/.local/state`. Копії пакетів відрізняються від оригіналів лише значеннями за замовчуванням (localhost, user).
- [x] У десктопний образ і в `das-server` 0.5.0 додано `nano`: `publish` і `deploy` відкривають конфіг у редакторі за F2 (`$VISUAL`/`$EDITOR`, інакше `nano`), без нього завершувались; 0.9.6.
- [x] У десктопний образ додано `curl` (у `das-server` він уже був); 0.9.7.
- [x] `publish` і `deploy` падали з `BrokenPipeError`, коли `ssh` завершується одразу (недоступний хост): тепер це `RemoteError` з повідомленням `ssh` (код 255 завжди помилка), тест з фіктивним `ssh`; `publisher`/`deployer` 0.3.1, 0.9.8; виправлено й в оригіналах.
- [ ] Прибрати значки томів live-носія («Том 6,6 ГБ») з робочого столу live-сесії. - [ ] Прибрати значки томів live-носія («Том 6,6 ГБ») з робочого столу live-сесії.
- [ ] Тема GRUB у встановленій системі (зараз стандартний чорний екран). - [ ] Тема GRUB у встановленій системі (зараз стандартний чорний екран).
- [ ] Перевірити меню Whisker вручну. - [ ] Перевірити меню Whisker вручну.
- [x] `ncdu` (аналіз зайнятого місця на диску в терміналі): у десктопному образі й у `das-server` 0.4.0.
- [x] Прискорити `make packages`: пакет перезбирається лише після змін у `srcpkgs/<назва>`, без бінарного пакета або коли у Void зникла потрібна йому бібліотека (`xbps-install -n`). `DASOS_REBUILD=1` перезбирає все. - [x] Прискорити `make packages`: пакет перезбирається лише після змін у `srcpkgs/<назва>`, без бінарного пакета або коли у Void зникла потрібна йому бібліотека (`xbps-install -n`). `DASOS_REBUILD=1` перезбирає все.
## Відкладено ## Відкладено
+1 -1
View File
@@ -1 +1 @@
0.8.0 0.9.8
+652
View File
@@ -0,0 +1,652 @@
# Як створити власний дистрибутив Linux
Докладна інструкція: від вибору основи до опублікованого релізу. Загальні поради підкріплено прикладами з Das OS (Void Linux + Xfce): шляхи на кшталт `scripts/container/build-iso.sh` ведуть до файлів цього репозиторію, а позначка **«Досвід Das OS»** — до реальних помилок, на які ми натрапили.
## Зміст
1. [Що таке «власний дистрибутив»](#1-що-таке-власний-дистрибутив)
2. [Планування перед першим рядком коду](#2-планування-перед-першим-рядком-коду)
3. [Вибір основи](#3-вибір-основи)
4. [Середовище збирання](#4-середовище-збирання)
5. [Власні пакети й репозиторій](#5-власні-пакети-й-репозиторій)
6. [Як влаштований live-образ](#6-як-влаштований-live-образ)
7. [Збирання ISO](#7-збирання-iso)
8. [Налаштування за замовчуванням і оформлення](#8-налаштування-за-замовчуванням-і-оформлення)
9. [Інсталятор](#9-інсталятор)
10. [Завантажувач, initramfs і розмітка диска](#10-завантажувач-initramfs-і-розмітка-диска)
11. [Оновлення, знімки й відкат](#11-оновлення-знімки-й-відкат)
12. [Тестування](#12-тестування)
13. [CI і релізи](#13-ci-і-релізи)
14. [Безпека](#14-безпека)
15. [Ліцензії й торгові марки](#15-ліцензії-й-торгові-марки)
16. [Локалізація й доступність](#16-локалізація-й-доступність)
17. [Документація та спільнота](#17-документація-та-спільнота)
18. [Типові пастки](#18-типові-пастки)
19. [Покроковий план з нуля](#19-покроковий-план-з-нуля)
20. [Чекліст перед релізом](#20-чекліст-перед-релізом)
21. [Корисні посилання](#21-корисні-посилання)
---
## 1. Що таке «власний дистрибутив»
Дистрибутив — це не ядро і не набір програм, а **рішення**: які пакети встановлені, як вони налаштовані, як система завантажується, встановлюється й оновлюється. Залежно від того, скільки рішень ви берете на себе, розрізняють кілька рівнів:
| Рівень | Що ви робите | Приклади | Складність |
|---|---|---|---|
| **Ремікс (респін)** | Готовий дистрибутив + свої пакети й налаштування, той самий репозиторій | Ремікси Ubuntu через Cubic, Fedora Remix | Низька |
| **Похідний дистрибутив** | Основа upstream + власний репозиторій пакетів, оформлення, інсталятор, утиліти | Linux Mint, Pop!_OS, EndeavourOS, **Das OS** | Середня |
| **Форк** | Власна копія всього дерева пакетів, власний цикл випусків | Devuan, Rocky Linux | Висока |
| **З нуля** | Збираєте кожен пакет самі, власний менеджер пакетів | Linux From Scratch, Void, Alpine | Дуже висока |
**Рекомендація:** починайте з похідного дистрибутива. Upstream дає безпекові оновлення тисяч пакетів, а ви зосереджуєтеся на тому, що відрізняє ваш дистрибутив. Кожен пакет, який ви «забираєте» собі, — це назавжди ваш обов'язок стежити за його CVE.
Головний принцип, який варто прийняти з першого дня: **усі зміни постачаються пакетами**. Не правте файли в образі руками й не «доналаштовуйте» систему скриптом після встановлення. Пакет можна оновити, відкотити, подивитися його вміст (`xbps-query -f das-settings`, `dpkg -L`, `pacman -Ql`), а скрипт — лише запустити ще раз і сподіватися.
---
## 2. Планування перед першим рядком коду
Запишіть відповіді на ці запитання в README або окремий документ. Без них проєкт розповзається.
### 2.1. Для кого і навіщо
- Хто користувач: новачок, розробник, адміністратор серверів, старий ноутбук, вбудована система?
- Яку конкретну проблему ви вирішуєте, якої не вирішує основа? «Гарніші шпалери» — не причина для дистрибутива, а «Void, готовий до роботи за 10 хвилин замість кількох годин налаштувань» — так.
- Які варіанти образів: десктоп, сервер, мінімальний, ARM? Кожен варіант множить тестування.
### 2.2. Технічні рішення, які важко змінити потім
| Рішення | Варіанти | Наслідки |
|---|---|---|
| Основа | Debian, Ubuntu, Fedora, Arch, Void, Alpine, NixOS… | Менеджер пакетів, цикл випусків, інструменти збирання |
| Модель випусків | Rolling / фіксовані версії / LTS | Rolling ламається частіше; фіксовані вимагають гілок і бекпортів |
| init | systemd / runit / OpenRC / s6 | Багато програм (GNOME, частина Flatpak-порталів, snap) очікують systemd |
| libc | glibc / musl | musl менший, але ламає бінарні програми (Steam, драйвери NVIDIA, частину Python-коліс) |
| Архітектури | x86_64, aarch64, i686… | Кожна — окреме збирання й тестування |
| Файлова система | ext4 / Btrfs / XFS / ZFS | Btrfs дає знімки й відкат; ZFS — ліцензійно складний |
| Середовище | Xfce, KDE, GNOME, без графіки | Розмір образу, вимоги до пам'яті, кількість налаштувань |
| Завантаження | UEFI, BIOS, Secure Boot | Secure Boot потребує підписаного ланцюжка (див. [10.5](#105-secure-boot)) |
**Das OS:** Void (rolling, runit, glibc), x86_64, Btrfs зі знімками, Xfce; окремий консольний серверний образ на тих самих пакетах.
### 2.3. Назва й ідентифікатори
- Перевірте назву: DistroWatch, GitHub, пошук торгових марок, домен. Назва не повинна містити чужих марок («Ubuntu», «Fedora», «Windows»).
- Оберіть короткий машинний ідентифікатор (`dasos`): він піде в `ID=` у `/etc/os-release`, імена пакетів (`das-*`), мітку ISO, ім'я файлу образу, теку бренду Calamares.
- Префікс для власних пакетів (`das-`) одразу показує, що пакет ваш, і не перетинається з upstream.
### 2.4. Версіонування
- Одна версія дистрибутива в одному місці (`VERSION`), з якої беруться ім'я ISO, меню завантаження, інсталятор і тег релізу.
- Версії власних пакетів незалежні від версії дистрибутива; підвищуйте їх при кожній зміні пакета, інакше менеджер пакетів не оновить установлені системи.
- Узгодьте правило: SemVer (`0.9.1`), календарні версії (`24.04`) або кодові імена. Головне — не змінювати схему посеред шляху.
---
## 3. Вибір основи
| Основа | Інструмент live-образу | Збирання пакетів | Сильні сторони | На що зважати |
|---|---|---|---|---|
| **Debian** | `live-build` (`lb config`, `lb build`) | `dpkg-buildpackage`, `sbuild`, `reprepro`/`aptly` для репозиторію | Стабільність, величезний вибір пакетів, дозвільна політика назв | Пакети «старі»; оформлення deb-пакетів багатослівне |
| **Ubuntu** | `livecd-rootfs`, для реміксів — Cubic | Launchpad PPA, `sbuild` | Драйвери, підтримка заліза, Secure Boot «з коробки» | Суворі правила торгових марок, snap |
| **Fedora** | KIWI, `livemedia-creator` + kickstart, `lorax` | `rpmbuild`, `mock`, COPR | Свіжий стек, SELinux, Secure Boot | Короткий цикл (≈13 міс. підтримки), треба замінити `fedora-logos` на `generic-logos` |
| **openSUSE** | KIWI | Open Build Service (OBS) | OBS збирає пакети й образи для багатьох дистрибутивів | Більше інфраструктури |
| **Arch** | `archiso` (`mkarchiso`) | `makepkg`, `repo-add` | Простий PKGBUILD, rolling, AUR | Часті ламкі оновлення; Secure Boot вручну |
| **Void** | `void-mklive` (`mklive.sh`) | `xbps-src`, `xbps-rindex` | Швидкий, простий, runit, чисті шаблони | Менша спільнота, немає Secure Boot з коробки |
| **Alpine** | `aports/scripts/mkimage.sh` | `abuild` | Дуже маленький, musl | musl: не всі бінарні програми працюють |
| **NixOS** | `nixos-generators`, `isoImage` у flake | Nix-вирази | Повністю декларативна й відтворювана система | Інша модель мислення, свій шлях до всього |
| **Gentoo** | `catalyst` | ebuild | Повний контроль над прапорцями збирання | Збирання з вихідних кодів займає години |
Критерії вибору:
1. **Ви вже знаєте цю систему?** Найважливіший критерій: налагоджувати доведеться саме менеджер пакетів і процес завантаження.
2. **Чи є інструмент для live-образу,** який підтримує upstream? Писати свій з нуля — тижні роботи.
3. **Чи легко зібрати власний пакет** і підключити свій репозиторій поруч з основним?
4. **Політика назв і логотипів** основи (див. [15](#15-ліцензії-й-торгові-марки)).
5. **Життєвий цикл:** скільки часу основу підтримують і як часто доведеться переходити на нову версію.
---
## 4. Середовище збирання
### 4.1. Збирайте в контейнері
Збирання на робочій машині з «чимось, що колись встановили» не відтворюється на іншому комп'ютері. Контейнер з основою дистрибутива вирішує це:
- інструменти (`xbps-src`, `mklive`, `live-build`, `archiso`) працюють у «рідній» системі, хост може бути будь-яким Linux із Docker;
- пакети збираються проти тієї самої glibc і бібліотек, з якими поставляються.
**Das OS:** `docker/builder/Dockerfile` — образ на основі `void-glibc-full`, `scripts/builder.sh` запускає в ньому будь-яку команду, проєкт змонтовано в `/work`.
> Вимоги до образу збирача беруться з основи дистрибутива, а не з загальних звичок. Наприклад, для Void це образ Void, а не Alpine: `xbps-src` і `mklive` потребують `xbps`.
### 4.2. Привілеї
Збирання live-образу монтує `/proc`, `/sys`, `/dev`, loop-пристрої та chroot, тому контейнер запускається з `--privileged` (або з `CAP_SYS_ADMIN` і доступом до `/dev/loop*`). Наслідки:
- не запускайте збирання на спільному сервері з недовіреним кодом;
- перерване збирання може лишити змонтовані файлові системи й файли, що належать root (`make clean` у Das OS прибирає їх зсередини контейнера);
- інструменти пакетування часто **відмовляються працювати від root** (`xbps-src`, `makepkg`): створіть у контейнері непривілейованого користувача з UID хоста, щоб файли в `out/` належали вам (`scripts/container/build-packages.sh`).
### 4.3. Закріплюйте версії інструментів
- Інструмент live-образу — на конкретному коміті (`ARG MKLIVE_COMMIT=8ca31d1`), оновлюйте свідомо й перевіряйте.
- Базовий образ контейнера бажано закріпити за дайджестом (`image@sha256:…`), а не `:latest`, якщо потрібна точна відтворюваність.
- Якщо ви **латаєте** upstream-інструмент (`sed` по скрипту mklive), спершу перевіряйте, що шуканий рядок існує, і падайте з помилкою, якщо його немає. Інакше після оновлення інструмента латка тихо перестане застосовуватися (так зроблено в `build-iso.sh`).
### 4.4. Кеші й місце на диску
- Кеш завантажених пакетів (`/var/cache/xbps`, `/var/cache/apt/archives`, `/var/cache/pacman/pkg`) монтуйте з хоста: повторне збирання не качатиме гігабайти.
- Дерево збирання пакетів (`void-packages`, `masterdir`/chroot) теж зберігайте між запусками.
- Закладіть **30–50 ГБ** вільного місця: chroot збирача, кеш, робоча коренева система образу, squashfs і сам ISO.
- SSD суттєво пришвидшує збирання (Das OS: перехід CI на SSD скоротив збирання ISO з ~45 до ~22 хв).
### 4.5. Відтворюваність
- `SOURCE_DATE_EPOCH` фіксує дати у файлах, які це підтримують.
- Не використовуйте в збиранні мережеві ресурси без версії («завантаж останній реліз з GitHub»).
- Збирайте з чистого дерева git; версію образу беріть із файлу в репозиторії, а не з дати.
---
## 5. Власні пакети й репозиторій
### 5.1. Що оформлювати пакетами
| Пакет | Вміст | Приклад у Das OS |
|---|---|---|
| Налаштування | Конфіги за замовчуванням, служби, drop-in файли | `das-settings` |
| Оформлення | Тема GTK/Qt, іконки, шпалери, логотипи, шрифти | `das-theme`, `font-jetbrains-mono`, `adw-gtk3` |
| Утиліти | Ваші програми | `das-tools`, `das-helpers`, `das-shell` |
| Інсталятор | Конфіги Calamares, скрипти | `das-installer`, `das-server-installer`, `das-install-common` |
| Метапакет | Лише залежності: «що входить у варіант» | `das-server` |
| Пакети з upstream | Новіша або перезібрана версія чужого пакета | `calamares` |
### 5.2. Правила гарного пакета
1. **Не перезаписуйте файли інших пакетів.** Менеджер пакетів або відмовиться, або наступне оновлення upstream поверне все назад. Замість цього:
- використовуйте каталоги drop-in: `/etc/sudoers.d/`, `/etc/sysctl.d/`, `/etc/xbps.d/`, `/etc/ssh/sshd_config.d/`, `/usr/share/lightdm/lightdm.conf.d/`, `/etc/profile.d/`, `/etc/bash/bashrc.d/`;
- для XDG-налаштувань — окремий каталог, доданий **на початок** `XDG_CONFIG_DIRS` (Das OS: `/etc/xdg/das` через `/etc/profile.d/das-xdg.sh`): стандартні файли лишаються недоторканими, а налаштування користувача в `~/.config` все одно мають пріоритет;
- для GNOME — dconf-профілі й `*.gschema.override`, для KDE — `/etc/xdg/kdeglobals`.
2. **Позначайте конфігураційні файли** (`conf_files` у xbps, `backup=()` у pacman, conffiles у deb), щоб оновлення не стерло зміни адміністратора.
3. **Не чіпайте `/home` і `/etc/skel` без потреби.** `/etc/skel` копіюється лише при створенні користувача, тож оновлення до вже створених користувачів не дійдуть. Краще системні значення за замовчуванням, які користувач може перевизначити.
4. **Залежності — явно.** Метапакет перелічує все, що потрібно варіанту; пакет налаштувань залежить від програм, які він налаштовує.
5. **Підвищуйте версію або ревізію** при кожній зміні. Інакше `xbps-install -Su`/`apt upgrade` нічого не побачить.
6. **Тести під час збирання** (`go test`, `python -m unittest`) ловлять помилки до того, як вони потраплять в образ.
7. **Ліцензія** в кожному пакеті (`vlicense`), навіть у пакеті з конфігами.
### 5.3. Збирання поверх дерева upstream
Інструменти пакетування зазвичай очікують повне дерево пакетів (спільні build-style, хуки, залежності). Робочий підхід:
- неглибокий клон дерева upstream (`git clone --depth 1 void-packages`);
- ваші шаблони копіюються поверх (`srcpkgs/<назва>`);
- збирання й публікація у власний локальний репозиторій (`out/hostdir/binpkgs`).
Нюанси, які ми врахували в `build-packages.sh`:
- **Інкрементальне збирання.** Пакет перезбирається лише тоді, коли змінився його каталог (хеш файлів), зник бінарний пакет або upstream оновив бібліотеку, з якою пакет зв'язаний (зміна soname). Інакше кожен коміт збирав би Calamares 17 хвилин.
- **Зміна soname в rolling-основі.** Якщо upstream оновив, скажімо, `libicu`, ваш старий бінарний пакет посилається на бібліотеку, якої вже немає. Перевірка «чи встановиться пакет у порожній корінь» (`xbps-install -n`) ловить це автоматично.
- **Залишки перерваного збирання** (`destdir`) потрапляють у наступний пакет. Чистіть їх перед збиранням.
- **Пакет з'явився в upstream.** Попереджайте, коли ваш шаблон має таку саму назву, як новий пакет upstream: можливо, свій уже можна прибрати.
### 5.4. Репозиторій
- Індекс репозиторію: `xbps-rindex -a`, `repo-add`, `reprepro`/`aptly`, `createrepo_c`.
- **Підпис:** `xbps-rindex --sign`, `gpg` для apt/pacman/rpm. Приватний ключ — поза репозиторієм і поза образом, у CI — лише як секрет. Публічний ключ — у пакеті, що підключає репозиторій.
- **Підключення в установленій системі:** окремий пакет або файл (`/etc/xbps.d/das.conf`, `/etc/apt/sources.list.d/`, `/etc/pacman.conf`). Без цього система після встановлення бачить лише upstream, і ваші пакети ніколи не оновляться (саме ця задача відкрита в TODO Das OS).
- **Пріоритет репозиторіїв:** якщо пакет є і у вас, і в upstream, визначте, чий важливіший (порядок `repository=` у xbps, pinning в apt). Інакше upstream може «оновити» ваш пакет своєю версією.
- **Хостинг:** будь-який статичний HTTPS-сервер (nginx, Gitea/GitHub Releases, S3, Cloudflare R2). Плануйте дзеркала, якщо користувачів стане багато.
- **Ігнорування пакетів:** якщо варіант свідомо не містить пакетів (Das OS Server — без `linux-firmware-nvidia`, `sof-firmware`), пропишіть це і для збирання образу (`mklive -g`), і для встановленої системи (`ignorepkg` в `/etc/xbps.d/`), інакше перше ж оновлення поверне їх.
---
## 6. Як влаштований live-образ
Щоб налагоджувати завантаження, треба розуміти ланцюжок:
```
Прошивка (UEFI або BIOS)
└─ завантажувач з ISO (GRUB для UEFI, isolinux/syslinux для BIOS)
└─ ядро vmlinuz + initramfs, параметри з меню (root=live:CDLABEL=..., quiet, nomodeset...)
└─ initramfs шукає носій за міткою тому → монтує ISO
└─ монтує squashfs з кореневою системою (лише читання)
└─ overlayfs: зверху tmpfs для запису (зміни зникають після вимкнення)
└─ switch_root → init (runit/systemd) → служби → вхід/робочий стіл
```
Що з цього випливає:
- **Мітка тому ISO** (volume label) — ключ, за яким initramfs шукає носій. Змінили мітку, але не параметри ядра → «не знайдено кореневу систему». Мітка ISO9660 обмежена 32 символами; уникайте пробілів і нелатинських літер.
- **Гібридний ISO** (UEFI + BIOS, CD + USB) складається з El Torito для BIOS, образу EFI-розділу (FAT) для UEFI та MBR/GPT для запису на флешку через `dd`. Інструменти (`xorriso`, mklive, live-build, archiso) роблять це самі — не збирайте ISO вручну без потреби.
- **initramfs live-образу** має лише знайти й змонтувати носій. Драйвери GPU і їхні прошивки (≈150 МБ) можна прибрати з нього: вони завантажаться з кореневої системи після `switch_root` (Das OS: `--omit-drivers "amdgpu radeon nouveau i915 xe"`, initrd 245 → 89 МБ). Перевіряйте на реальному залізі з різними відеокартами.
- **Режим копіювання в RAM** (`rd.live.ram`, `toram`) дозволяє вийняти флешку; потребує пам'яті більше за розмір squashfs.
- **Пункт `nomodeset`** у меню рятує на машинах, де екран лишається чорним.
- **Live-користувач** створюється під час завантаження (у Void це робить dracut-модуль mklive) і не повинен потрапити у встановлену систему.
### 6.1. Стиснення squashfs
| Алгоритм | Розмір | Швидкість розпакування | Коли |
|---|---|---|---|
| xz (+ BCJ x86, блок 1 МБ) | Найменший | Повільніше | Образи для завантаження/розповсюдження |
| zstd (рівень 15–19) | Трохи більший | Швидко | Коли важлива швидкість завантаження live |
| lz4 | Великий | Дуже швидко | Налагодження, швидкі ітерації |
**Das OS:** `-b 1M -Xbcj x86` дав −5 % на десктопі.
### 6.2. Що займає місце (для оптимізації)
- Прошивки (`linux-firmware*`) — сотні мегабайтів; мережеві не прибирайте, без них не буде Wi-Fi.
- Переклади `/usr/share/locale` (у Das OS — 265 МБ). Можна лишити лише потрібні мови (`noextract` у xbps, `path-exclude` у dpkg), але тоді інші мови інтерфейсу доведеться доставляти окремо.
- Дублікати тем іконок, документація (`/usr/share/doc`, `man`), заголовки.
- Подивитися найбільші пакети: `xbps-query -m | ...`, `expac -H M '%m %n' | sort -h`, `dpkg-query -Wf '${Installed-Size} ${Package}\n' | sort -n`.
---
## 7. Збирання ISO
### 7.1. Профіль образу
Тримайте опис кожного варіанта образу окремим каталогом, а не в скрипті:
```
live/ server/
├── packages.x86_64 ├── packages.x86_64 # список пакетів, коментарі через '#'
├── services ├── services # служби, увімкнені в live
├── branding/ └── branding/
│ ├── issue └── issue
│ ├── splash.svg
│ └── ventoy/dasos.png
└── include/ # файли, що накладаються на корінь образу
└── etc/...
```
Аналоги в інших основах: `config/package-lists/*.list.chroot` та `config/includes.chroot/` у live-build; `packages.x86_64`, `airootfs/`, `profiledef.sh` в archiso; `%packages` у kickstart.
### 7.2. Що класти в `include/`, а що — у пакет
- `include/` — лише те, що потрібне **тільки live-сесії** (автовхід, розкладка для live, заглушки гостьових інструментів). Пам'ятайте: інсталятор, який копіює образ на диск, перенесе і ці файли — їх доведеться прибирати (див. [9.4](#94-копіювання-образу-чи-встановлення-пакетів)).
- Усе, що має бути у встановленій системі, — у пакети.
### 7.3. Основні параметри (на прикладі mklive)
```sh
./mklive.sh \
-a x86_64 \ # архітектура
-r /work/out/hostdir/binpkgs \ # ваш репозиторій (додається перед upstream)
-c /var/cache/xbps \ # кеш пакетів
-p "$(read_list live/packages.x86_64)" \
-S "$(read_list live/services)" \ # служби runit
-I "$include" \ # накладка на корінь
-l uk_UA.UTF-8 -k us \ # локаль і розкладка live
-T "Das OS 0.9.1" \ # заголовок меню завантаження
-C "live.user=live quiet loglevel=3" \ # параметри ядра
-o out/iso/dasos-0.9.1-x86_64.iso
```
### 7.4. Нюанси
- **Меню завантаження** за замовчуванням показує версію ядра; користувачу корисніша версія дистрибутива. Латайте шаблони меню GRUB і isolinux, а не результат.
- **Серверний образ:** параметри `console=tty0 console=ttyS0,115200` дають вивід і на екран, і на послідовну консоль (IPMI, VPS, `qemu -nographic`). Останній `console=` стає основним для `/dev/console`.
- **Часовий пояс і локаль live** задавайте явно, інакше буде UTC і `en_US`.
- **Ventoy:** багато користувачів завантажують ISO через Ventoy. Перевірте, що образ працює і в ньому; Ventoy може показувати власний значок за назвою образу (`live/branding/ventoy/`).
- **Розмір файлу:** образ понад 4 ГБ не скопіюється на флешку з FAT32 (Ventoy за замовчуванням використовує exFAT, це нормально, але «записати файлом» на FAT32 не вийде).
- **Ім'я файлу ISO** — `назва-варіант-версія-архітектура.iso`: користувачі й скрипти покладаються на передбачуване ім'я.
---
## 8. Налаштування за замовчуванням і оформлення
### 8.1. Ідентичність системи
- `/etc/os-release` (`NAME`, `PRETTY_NAME`, `ID`, `ID_LIKE`, `VERSION_ID`, `HOME_URL`, `BUG_REPORT_URL`, `LOGO`, `ANSI_COLOR`). **Обережно з `ID`:** скрипти й програми (інсталятори, `neofetch`, Steam, драйвери, Calamares) перевіряють `ID=void`/`ID=ubuntu`. Якщо змінюєте `ID`, обов'язково вкажіть `ID_LIKE=<основа>`, і перевірте програми, які на це покладаються. Файл зазвичай належить базовому пакету upstream — постачайте свій через механізм, що не конфліктує з ним (альтернативи, `/usr/lib/os-release` vs `/etc/os-release`, drop-in, або власний пакет base-files, якщо ви форк).
- `/etc/issue` (текст перед входом у консоль) — у live він може підказувати логін/пароль, у встановленій системі — нейтральний.
- Ім'я хоста live (`dasos`), а не upstream-овий (`void-live`).
- Банер SSH, MOTD, запрошення shell (у Das OS — `das-shell`).
### 8.2. Візуальне оформлення
| Що | Де | Нюанси |
|---|---|---|
| Заставка меню завантаження | GRUB (UEFI), isolinux (BIOS) | Різні формати й роздільність; isolinux — 640×480, обмежена палітра |
| Тема GRUB у встановленій системі | `/boot/grub/themes/`, `GRUB_THEME` | Без неї — чорний екран зі стандартним текстом |
| Plymouth (графічне завантаження) | Тема Plymouth | Потребує KMS; ховає повідомлення ядра, тож залиште спосіб їх побачити |
| Екран входу | LightDM/SDDM/GDM конфіги | Через drop-in (`lightdm.conf.d`), не правкою основного файлу |
| Тема GTK/Qt, іконки, курсор, шрифти | Пакет теми | Перевіряйте світлий і темний варіанти, GTK3 і GTK4/libadwaita |
| Рамки вікон | Тема менеджера вікон | Das OS генерує рамки xfwm4 програмою (`xfwm4gen`), а не малює вручну |
| Шпалери, логотип | SVG у пакеті теми | Векторні джерела в репозиторії, растр генеруйте під час збирання |
| Слайди інсталятора | Бренд Calamares | Версію підставляйте під час збирання з `VERSION` |
- Зберігайте **джерела** зображень (SVG), а PNG генеруйте (`rsvg-convert`), щоб змінювати колір чи текст в одному місці.
- Перевіряйте ліцензії шрифтів, іконок і шпалер, які берете з інтернету (OFL, CC BY-SA вимагає зазначити автора).
### 8.3. Налаштування робочого середовища
- Готуйте налаштування в живій сесії, потім експортуйте у файли (`xfconf-query`, `dconf dump`, `kwriteconfig`), а не пишіть XML вручну.
- Розкладка клавіатури та її перемикання — і в X11 (`/etc/X11/xorg.conf.d/00-keyboard.conf`), і в консолі (`KEYMAP`), і в екрані входу.
- Мова інтерфейсу за замовчуванням, часовий пояс, формат дати.
- Назви тек користувача (`xdg-user-dirs`): реальна помилка, з якою ми стикнулися, описана в [18](#18-типові-пастки).
- Звук (PipeWire + WirePlumber), мережа (NetworkManager), Bluetooth, автомонтування (udisks2, gvfs), керування живленням.
- Агент polkit і `pkexec` для графічних програм, що потребують прав root.
---
## 9. Інсталятор
### 9.1. Вибір
| Інсталятор | Для чого | Плюси | Мінуси |
|---|---|---|---|
| **Calamares** | Графічний, незалежний від дистрибутива | Модульний, бренд, переклади, розмітка дисків, LUKS | Залежить від Qt/KDE Frameworks; під runit/не-systemd потребує налаштувань |
| Anaconda | Fedora/RHEL | Kickstart для автоматизації | Важко відірвати від Fedora |
| debian-installer, Subiquity | Debian, Ubuntu | Перевірені | Складно брендувати |
| archinstall | Arch | Python, скриптується | Лише Arch |
| **Власний TUI** | Сервери, мінімальні образи | Повний контроль, `dialog`/`whiptail`, режим без запитань | Розмітку, завантажувач і крайові випадки пишете самі |
**Das OS:** Calamares для десктопа, власний `das-server-install` на `dialog` для сервера, спільні кроки в `das-install-common` (`mount-source`, `postinstall`).
### 9.2. Що має зробити будь-який інсталятор
1. Розмітити диск (автоматично і вручну), створити файлові системи (і підтоми Btrfs).
2. Перенести систему на диск.
3. Згенерувати `/etc/fstab` за **UUID**, не за `/dev/sdX` (імена дисків змінюються).
4. Налаштувати локаль (і **зібрати** її: у Void — `/etc/default/libc-locales` + `xbps-reconfigure -f glibc-locales`, у Debian — `locale-gen`), часовий пояс, розкладку (X11 і консоль).
5. Створити користувача, групи (`wheel`, `audio`, `video`, `network`, `docker`…), sudo.
6. Задати ім'я хоста, мережу.
7. **Згенерувати унікальні дані машини** (див. 9.3).
8. Перезібрати initramfs під цю машину.
9. Встановити завантажувач.
10. Прибрати все, що потрібне лише live-сесії й самому інсталятору.
### 9.3. Унікальні дані, яких не повинно бути в образі
Усе, що копіюється з образу, буде однаковим на кожному комп'ютері. Тому в образі не повинно бути:
- `/etc/machine-id`, `/var/lib/dbus/machine-id` — генеруються під час встановлення (модуль `machineid` у Calamares) або першого запуску;
- **ключів хоста SSH** (`/etc/ssh/ssh_host_*`) — інакше всі сервери матимуть однаковий ключ, і MITM стане тривіальним; вони генеруються під час першого запуску sshd;
- зерна генератора випадкових чисел (`/var/lib/random-seed`);
- ключів, токенів, паролів збирача, історії shell, журналів збирання.
### 9.4. Копіювання образу чи встановлення пакетів
| Спосіб | Як | Плюси | Мінуси |
|---|---|---|---|
| Копіювання образу (`unpackfs`, rsync) | Розпакувати squashfs на диск | Швидко, без мережі, встановлюється те, що користувач бачив у live | Треба прибирати live-специфічне |
| Встановлення пакетів (`pacstrap`, `debootstrap`, `xbps-install -r`) | Ставити пакети з репозиторію в цільовий корінь | Чиста система, завжди найсвіжіші пакети | Потребує мережі або повного локального репозиторію |
При копіюванні:
- копіюйте **чистий** корінь (у Void — `/dev/mapper/live-base`, нижній шар без змін live-сесії), а не змонтований overlay з live-користувачем і його налаштуваннями;
- після копіювання видаліть live-користувача, автовхід, службові файли (`mklive-ignore.conf`), сам інсталятор і пакети, потрібні лише йому;
- гостьові інструменти віртуальних машин лишайте лише для того гіпервізора, у якому відбувається встановлення (`/sys/class/dmi/id/sys_vendor`, прапорець `hypervisor` у `/proc/cpuinfo`); на реальному залізі — жодних (у Das OS інструменти VirtualBox показували помилку при кожному вході в KVM);
- перевірте, що копіювання **не робить файли розрідженими** там, де це шкодить (див. [18](#18-типові-пастки) про GRUB і Btrfs).
### 9.5. Calamares під не-systemd систему
- `settings.conf` задає послідовність модулів. Свої кроки — через `shellprocess` з різними `id` (`shellprocess@postinstall`).
- Модулі, що викликають `systemctl` (`services-systemd`), замініть на свої (для runit — посилання в `/etc/runit/runsvdir/default/`).
- Модуль `displaymanager` налаштовує автовхід/екран входу; перевірте, що він знає ваш DM.
- `unpackfs` — джерело й тип файлової системи; `mount` — опції монтування й підтоми Btrfs; `partition` — файлові системи за замовчуванням, розмір EFI-розділу, swap.
- Бренд: `branding.desc`, логотипи, слайди, кольори; версію підставляйте під час збирання.
- Збирайте Calamares тієї версії, яку перевірили: оновлення може змінити формат конфігів.
- Запускайте з `-d` для налагодження; журнал — `~/.cache/calamares/session.log`, і його варто копіювати у встановлену систему для звітів про помилки.
### 9.6. Консольний інсталятор
- Режим без запитань (`--config file`) для автоматизації й тестів у CI.
- Перевіряйте введене (ім'я користувача, ім'я хоста, пароль, SSH-ключ) **до** того, як почати стирати диск.
- Явне підтвердження з назвою диска й розміром перед стиранням.
- Опція «вхід по SSH лише за ключем» і перевірка, що ключ справді додано, інакше сервер стане недоступним.
- Повідомлення наприкінці: як увійти, які є команди, де документація.
---
## 10. Завантажувач, initramfs і розмітка диска
### 10.1. Розмітка
| Режим | Таблиця | Обов'язкові розділи |
|---|---|---|
| UEFI | GPT | EFI System Partition (FAT32, 300–1024 МіБ; Das OS — 512 МіБ), корінь |
| BIOS + MBR | MBR | Корінь (позначений активним для деяких BIOS) |
| BIOS + GPT | GPT | **BIOS boot partition** 1 МіБ (тип `ef02`) для GRUB, корінь |
- Вирівнюйте розділи по 1 МіБ.
- Swap: розділ, файл (на Btrfs — з `chattr +C` і без стиснення) або zram. Для сплячого режиму (hibernate) потрібен swap не менший за обсяг RAM і `resume=` у параметрах ядра.
- `/tmp` на tmpfs.
### 10.2. Btrfs для знімків
Типова схема підтомів (як у Das OS, openSUSE, Ubuntu з zsys):
```
@ → /
@home → /home (особисті файли не відкочуються разом із системою)
@snapshots → /.snapshots
@var_log → /var/log (журнали лишаються після відкату — зручно розбиратися)
@var_cache_xbps → /var/cache/xbps (кеш пакетів не роздуває знімки)
```
Опції монтування: `compress=zstd`, `noatime`. Для баз даних і образів VM — `chattr +C` на каталог.
### 10.3. GRUB
- UEFI: `grub-install --target=x86_64-efi --efi-directory=/boot/efi --bootloader-id=DasOS`.
- **Запасний шлях `\EFI\BOOT\BOOTX64.EFI`** (`--removable` або копія): деякі прошивки «забувають» записи NVRAM або взагалі їх не підтримують — без запасного файлу система не завантажиться.
- BIOS: `grub-install --target=i386-pc /dev/sdX` (на диск, не на розділ).
- **Подвійне завантаження:** з GRUB 2.06 `os-prober` вимкнено за замовчуванням; щоб бачити Windows, потрібен `GRUB_DISABLE_OS_PROBER=false` у `/etc/default/grub`.
- Тема й назва пунктів меню (`GRUB_DISTRIBUTOR`).
- Годинник: Windows тримає апаратний годинник у місцевому часі, Linux — в UTC; при подвійному завантаженні час «стрибає». Виберіть одне й задокументуйте.
### 10.4. initramfs
- Live-образ має **загальний** initramfs з усіма драйверами; у встановленій системі збирайте **під машину** (host-only), як це робить основа при оновленні ядра (`dracut --force`, `mkinitcpio -P`, `update-initramfs -u`).
- Власні модулі dracut/хуки (Das OS: `90das-snapshot-overlay` для завантаження у знімок лише для читання) постачайте пакетом і перевіряйте, що вони потрапляють у initramfs.
- Шифрування (LUKS): `crypttab`, ключ-файл у initramfs, щоб не вводити пароль двічі (GRUB + initramfs), `GRUB_ENABLE_CRYPTODISK=y` для зашифрованого `/boot`. GRUB підтримує LUKS2 лише з PBKDF2, не з Argon2 — враховуйте при створенні тому.
### 10.5. Secure Boot
- Прошивка з увімкненим Secure Boot завантажує лише підписане ключем Microsoft (або вашим, якщо користувач його додав).
- Реалістичні варіанти для невеликого дистрибутива:
1. Основа вже має підписаний ланцюжок `shim → GRUB → ядро` (Debian, Ubuntu, Fedora, openSUSE) і ви **не змінюєте ядро й GRUB** — використовуйте їхні підписані пакети.
2. Власний ключ (`sbctl`, MOK через `mokutil`): користувач один раз реєструє ключ. Кожен модуль ядра (DKMS, NVIDIA) теж треба підписувати.
3. Документувати, що Secure Boot треба вимкнути (так у Void, Arch за замовчуванням).
- Власний shim, підписаний Microsoft, — тривалий процес перевірки, для малого проєкту зазвичай недосяжний.
---
## 11. Оновлення, знімки й відкат
- **Rolling-основа може зламатися будь-якого дня.** Захист: знімок перед кожним оновленням (Das OS: `das-update` робить пару snapper pre/post), меню GRUB зі знімками (`grub-btrfs`), команда відкату (`snapper-rollback`).
- Знімки — **не резервна копія**: вони на тому самому диску.
- Відкат має не чіпати `/home` (окремий підтом).
- Регулярне очищення старих знімків (snapper timeline/number cleanup), інакше диск заповниться.
- Після відкату лишаються старі корені — плануйте їх прибирання.
- Для дистрибутивів з фіксованими версіями: канали `stable`/`testing`, тестування оновлень перед публікацією, бекпорти безпеки.
- Оновлення ваших пакетів має дійти до встановлених систем — перевірте весь шлях: коміт → збирання → підписаний репозиторій → `xbps-install -Su` на чистій установці.
- Тримайте окремий консольний режим оновлення (`das-update --cli`): на сервері немає графіки, а графічна програма може зламатися саме під час оновлення.
- Розгляньте атомарні/незмінні системи (ostree, NixOS, A/B-розділи), якщо відкат — ключова цінність; це інший рівень складності.
---
## 12. Тестування
### 12.1. Віртуальні машини
```sh
# UEFI (OVMF), диск на 20 ГБ, KVM
qemu-system-x86_64 -enable-kvm -m 4G -smp 2 \
-drive if=pflash,format=raw,readonly=on,file=/usr/share/OVMF/OVMF_CODE.fd \
-drive if=pflash,format=raw,file=ovmf_vars.fd \
-cdrom out/iso/dasos-0.9.1-x86_64.iso \
-drive file=disk.qcow2,if=virtio \
-nic user,hostfwd=tcp::2222-:22
# BIOS: те саме без pflash (SeaBIOS за замовчуванням)
# Сервер: -nographic -serial mon:stdio
```
- Тестуйте **обидва** режими: UEFI і BIOS; і live, і встановлену систему.
- Копія `OVMF_VARS` для кожної ВМ, інакше записи завантаження змішуються.
- Перенаправлення порту SSH (`localhost:2222`) спрощує автоматичні перевірки.
- Знімки ВМ (`qemu-img snapshot`) перед встановленням пришвидшують повторні тести.
- Різні відеоадаптери: `virtio-vga`, `qxl`, `std` — і VirtualBox/VMware, бо там свої гостьові інструменти.
**Das OS:** `make qemu`, `make qemu-bios`, `make qemu-disk`, `make qemu-server` (`scripts/run-qemu.sh`).
### 12.2. Реальне залізо
ВМ не замінюють залізо. Обов'язковий мінімум перед стабільним релізом:
- Графіка Intel, AMD, NVIDIA (nouveau і пропрієтарний драйвер), гібридні ноутбуки.
- Wi-Fi: Intel (iwlwifi), Realtek, Broadcom (часто потребує пропрієтарного `broadcom-wl`), Bluetooth.
- NVMe, SATA, режим Intel RST/RAID (Linux не бачить диск, поки в BIOS не перемкнути на AHCI — задокументуйте).
- Старий BIOS без UEFI, UEFI з увімкненим/вимкненим Secure Boot.
- Подвійне завантаження з Windows (і BitLocker).
- Сплячий режим, яскравість, тачпад, звук через HDMI, мікрофон, вебкамера.
- Завантаження з флешки, записаної через `dd`, Ventoy, Rufus (режим DD).
### 12.3. Автоматизація
- Модульні тести ваших утиліт під час збирання пакетів (`make test`).
- `shellcheck` для shell-скриптів.
- Інсталятор без запитань (`--config`) у ВМ у CI, після цього вхід по SSH і перевірки (`openQA` — повноцінний варіант, як у openSUSE і Fedora).
- Чекліст ручних перевірок у TODO, з відмітками, на якій версії перевірено.
---
## 13. CI і релізи
### 13.1. Збирання в CI
- Self-hosted runner: збирання ISO потребує привілейованого контейнера, гігабайтів кешу й десятків хвилин — хмарні безкоштовні runner-и часто не підходять за часом і місцем.
- Кеш (`DASOS_OUT`) між запусками на хості runner-а, інакше кожне збирання з нуля.
- `concurrency` з `cancel-in-progress`, щоб новий push скасовував старе збирання.
- Збирайте **всі варіанти одним запуском**, щоб пакети збиралися один раз.
- Для змін, які не впливають на образ (документація), пропускайте збирання: `[skip ci]` у повідомленні коміту або `paths-ignore` у workflow.
- Runner має підніматися сам після перезавантаження хоста.
### 13.2. Публікація релізу
- Реліз — за тегом (`v0.9.1`), версія тегу має збігатися з `VERSION` (перевіряйте в CI).
- Публікуйте `SHA256SUMS` і **підпис** до нього (`gpg --detach-sign SHA256SUMS`), з публічним ключем на сайті.
- Великі файли: ліміти розміру вкладень (Gitea — `[repository.release] FILE_MAX_SIZE`), таймаути зворотного проксі. **Досвід Das OS:** Traefik обривав завантаження ISO через 60 секунд (502), довелося підняти `readTimeout`.
- Торенти й дзеркала для зменшення навантаження.
- Нотатки до релізу: що нового, відомі проблеми, як оновитися з попередньої версії, мінімальні вимоги.
- Не видаляйте старі релізи одразу: комусь потрібно відкотитися.
---
## 14. Безпека
- Підписані пакети й репозиторій, лише HTTPS.
- Приватні ключі — поза git, поза образом, у CI — як секрети з мінімальним доступом.
- Ніяких паролів за замовчуванням у встановленій системі. Пароль `live/live` допустимий лише в live-сесії.
- SSH: `PermitRootLogin prohibit-password`, вхід за ключем, fail2ban або аналог, файрвол «закрито все, крім потрібного» (Das OS Server: nftables у власній таблиці `inet das`, щоб не ламати правила Docker і fail2ban).
- Оновлення безпеки з upstream мають доходити швидко: rolling-основа дає їх сама; власні пакети — ваша відповідальність.
- Стежте за CVE пакетів, які ви збираєте самі (Calamares, власні версії бібліотек).
- `sudo` без пароля — лише в live.
- Мінімум запущених служб за замовчуванням.
- Телеметрія: краще без неї; якщо є — лише з явної згоди й з описом, що збирається.
- Перевіряйте, що в образ не потрапили секрети збирача: `unsquashfs -l` та пошук по `/root`, `/home`, `/tmp`, історії shell.
---
## 15. Ліцензії й торгові марки
> Це не юридична консультація. Для комерційного проєкту зверніться до юриста.
- **GPL:** розповсюджуючи бінарні файли під GPL (ядро, GRUB, coreutils…), ви зобов'язані надати вихідні коди **точно тих версій**. Найпростіше — публікувати поруч з ISO архів вихідних кодів або дзеркало; посилання на upstream надійне лише доти, доки там лежить саме та версія. Ваші власні латки й шаблони — у публічному репозиторії.
- **Ліцензія вашого коду:** оберіть явно (Das OS — MIT) і покладіть `LICENSE` у кожен пакет.
- **Невільні компоненти:** прошивки, драйвери NVIDIA, кодеки (патентні питання в деяких країнах), шрифти Microsoft. Деякі з них не можна перерозповсюджувати в модифікованому вигляді або зовсім — перевірте ліцензію кожного; у Void вони в репозиторії `nonfree`.
- **Торгові марки основи:**
- Ubuntu (Canonical) вимагає дозволу на використання назви для похідних і модифікованих образів — прибирайте їхні логотипи й назву;
- Fedora дозволяє «Remix», але логотипи Fedora треба замінити (`generic-logos`);
- Debian, Arch і Void значно ліберальніші, але й там не видавайте свій дистрибутив за офіційний.
- **Програми з торговими марками:** Firefox дозволяє розповсюджувати незмінені збірки під своєю назвою; якщо ви змінюєте код чи брендинг, перевірте правила Mozilla (історичний приклад — Iceweasel у Debian).
- **Графіка й шрифти:** ліцензії CC, OFL, вимоги зазначати авторство. Державна символіка (наприклад, тризуб) має свої правила використання — перевірте законодавство своєї країни.
---
## 16. Локалізація й доступність
- Мова за замовчуванням і вибір мови в інсталяторі та вікні привітання; зміна мови має діяти після перезавантаження/повторного входу.
- Зібрані локалі для вибраної мови і `en_US.UTF-8` як запасна.
- Переклади ваших утиліт (gettext або власний модуль `i18n` з тестами, що всі рядки перекладено).
- Переклади Calamares і бренду (слайди, назви).
- Розкладки: основна + англійська, зручне перемикання, однаково в X11, консолі й на екрані входу.
- Вікна, що лишаються англійською (у Das OS — агент polkit), — окремий пункт у TODO.
- Доступність: зчитувач екрана (Orca), висока контрастність, масштабування; хоча б перевірте, що їх можна увімкнути.
---
## 17. Документація та спільнота
- README двома мовами: призначення, можливості, скріншоти, як завантажити й встановити, як зібрати.
- Мінімальні вимоги до заліза.
- Відомі проблеми й обхідні шляхи.
- Як повідомити про помилку: які журнали додати (`~/.cache/calamares/session.log`, `dmesg`, `/var/log/socklog`, `xbps-query -l`).
- Журнал змін (CHANGELOG або нотатки до релізів).
- Канал зв'язку (форум, Telegram, Matrix, issues).
- Вкажіть, на чому базується дистрибутив, і подякуйте upstream — частину проблем користувачі мають повідомляти саме туди.
- Помилки, знайдені в upstream, повідомляйте авторам (як у TODO Das OS — про `xdg-user-dirs`).
- Після стабільного релізу — заявка на DistroWatch.
---
## 18. Типові пастки
Реальні проблеми з історії Das OS і загальновідомі:
| Симптом | Причина | Рішення |
|---|---|---|
| GRUB: «premature end of file» при завантаженні ядра з Btrfs | Інсталятор копіював `rsync --sparse`, хвіст ядра з нулів став «діркою» Btrfs, яку GRUB 2.12 не читає | Переписати ядра без розрідженості (`cp --sparse=never`) після копіювання |
| Теки користувача англійською попри українську локаль | `xdg-user-dirs` 0.20 шукає переклади лише в першій теці `XDG_DATA_DIRS` | Задати правильний порядок/обхід у сесії, перейменувати вже створені теки |
| Після перезапуску файрволу зникли правила Docker і fail2ban | Скрипт `finish` служби nftables у Void робить `nft flush ruleset` | Окрема таблиця й команда `dfw reload`, що перезавантажує лише її |
| snapper не працює у встановленій системі | Не запущено `dbus` | Інсталятор вмикає `dbus` разом зі snapper |
| Podman попереджає про спільне монтування | runit лишає `/` приватним | Скрипт ранньої ініціалізації `mount --make-rshared /` |
| Повідомлення VirtualBox при кожному вході в KVM/на залізі | Гостьові інструменти скопійовані з live | Видаляти інструменти не того гіпервізора під час встановлення |
| Версія в інсталяторі не та | Версія жорстко прописана в пакеті | Підставляти з `VERSION` під час збирання образу |
| Латка до upstream-інструмента «тихо» перестала діяти | Інструмент оновився, рядок змінився | Перевіряти наявність рядка перед `sed` і падати з помилкою |
| Пакет зібрано з чужими файлами | Залишки `destdir` після перерваного збирання | Чистити `destdir` перед кожним збиранням |
| Пакет не встановлюється після оновлення upstream | Upstream змінив soname бібліотеки | Автоматично перезбирати пакети з незадоволеними залежностями |
| 502 під час завантаження ISO в реліз | Таймаут зворотного проксі 60 с | Підняти `readTimeout` (Traefik) / `proxy_read_timeout` (nginx) |
| Live-сесія блокує екран під час довгого встановлення | Заставка/блокування за замовчуванням | Вимкнути блокування в live |
| Чорний екран на деяких відеокартах | Проблеми KMS | Пункт меню з `nomodeset`, тестування на залізі |
| Не видно Windows у меню GRUB | `os-prober` вимкнено з GRUB 2.06 | `GRUB_DISABLE_OS_PROBER=false` |
| Система не завантажується після оновлення прошивки | Прошивка стерла запис NVRAM | Запасний `\EFI\BOOT\BOOTX64.EFI` |
| Усі сервери мають однаковий ключ SSH | Ключі хоста потрапили в образ | Генерувати під час першого запуску |
| Користувацькі налаштування скидаються після оновлення | Пакет перезаписує файли в `/home` або основні конфіги | Значення за замовчуванням у `/etc/xdg/<дистр>` і drop-in, `conf_files` |
| Пакети, свідомо прибрані з образу, повертаються | Ігнор налаштовано лише для збирання образу | Той самий список `ignorepkg` у встановленій системі |
| Встановлена система не оновлює ваші пакети | Репозиторій дистрибутива не підключено після встановлення | Пакет з налаштуванням репозиторію й ключем |
---
## 19. Покроковий план з нуля
Порядок, у якому розвивався Das OS; кожен етап завершується робочим результатом, який можна перевірити.
1. **Репозиторій і контейнер збирання.** `git init`, `.gitignore` (`/out/`, `.idea/`), `Dockerfile` з основою, скрипт запуску команд у контейнері, `Makefile` з цілями `packages`, `iso`, `qemu`, `clean`.
2. **Чистий live-образ основи.** Зберіть ISO інструментом upstream без змін, завантажте в QEMU (UEFI і BIOS). Це база для порівняння: якщо щось зламається пізніше, ви знатимете, що це ваша зміна.
3. **Профіль образу.** Список пакетів, служби, локаль, часовий пояс, розкладка, меню завантаження з вашою назвою й версією, ім'я хоста.
4. **Перший власний пакет.** Шаблон з одним конфігом, збирання у власний репозиторій, підключення репозиторію при збиранні ISO. Переконайтеся, що пакет потрапив в образ.
5. **Налаштування робочого середовища пакетом** (`das-settings`): панель, теми, екран входу — через drop-in і `/etc/xdg/<дистр>`.
6. **Оформлення** (`das-theme`): шпалери, логотип, тема, заставки завантажувача, бренд інсталятора.
7. **Інсталятор.** Спершу автоматична розмітка на чистий диск, UEFI, одна файлова система. Потім BIOS, ручна розмітка, інші файлові системи, прибирання live-специфічного.
8. **Знімки й відкат** (якщо Btrfs): підтоми, snapper, меню GRUB, утиліта оновлення зі знімком.
9. **Власні утиліти** (вікно привітання, оновлення, консольні помічники) з тестами.
10. **Додаткові варіанти образу** (сервер) на тих самих пакетах.
11. **CI**: збирання на кожен push, реліз на тег, контрольні суми.
12. **Реальне залізо.** Виправлення окремими патч-версіями.
13. **Підписаний репозиторій і оновлення встановлених систем.**
14. **Перший стабільний реліз**: злиття в `main`, нотатки, документація, оголошення.
Мінімальна команда, з якої все починається (Void):
```sh
docker run --rm -it --privileged -v "$PWD:/work" ghcr.io/void-linux/void-glibc-full sh -c '
xbps-install -Syu xbps && xbps-install -y git bash &&
git clone https://github.com/void-linux/void-mklive /opt/mklive && cd /opt/mklive &&
./mklive.sh -a x86_64 -p "xfce4 xorg-minimal lightdm dbus" -S "dbus lightdm" -o /work/test.iso'
```
Для Arch аналог — `mkarchiso -v -w /tmp/work -o out /usr/share/archiso/configs/releng`, для Debian — `lb config && sudo lb build`.
---
## 20. Чекліст перед релізом
**Збирання**
- [ ] Версія підвищена в `VERSION` і в змінених пакетах; README оновлено.
- [ ] Збирання з чистого дерева git у CI пройшло, тести зелені.
- [ ] Розмір образу в межах очікуваного (порівняти з попереднім релізом).
- [ ] В образі немає секретів, ключів SSH хоста, `machine-id`.
**Live**
- [ ] Завантаження UEFI і BIOS, з `dd`-флешки і Ventoy.
- [ ] Меню показує правильну назву й версію; пункти `nomodeset` і RAM працюють.
- [ ] Мережа, звук, Bluetooth, розкладки, мова інтерфейсу.
**Встановлення**
- [ ] Автоматична й ручна розмітка; UEFI і BIOS; Btrfs і ext4.
- [ ] Перше завантаження встановленої системи, вхід, sudo.
- [ ] Live-користувач, інсталятор і непотрібні гостьові інструменти прибрані.
- [ ] Унікальні `machine-id` і ключі SSH.
- [ ] Оновлення зі знімками, завантаження у знімок, відкат.
- [ ] Подвійне завантаження з Windows (якщо підтримується).
**Сервер**
- [ ] Вхід по SSH за ключем, заборона паролів, файрвол, fail2ban.
- [ ] Послідовна консоль.
- [ ] Встановлення без запитань (`--config`).
**Публікація**
- [ ] Тег збігається з `VERSION`.
- [ ] ISO, `SHA256SUMS`, підпис опубліковано й перевірено завантаженням.
- [ ] Нотатки до релізу з відомими проблемами.
- [ ] Вихідні коди доступні.
---
## 21. Корисні посилання
- Void: [void-mklive](https://github.com/void-linux/void-mklive), [void-packages / xbps-src](https://github.com/void-linux/void-packages), [Void Handbook](https://docs.voidlinux.org)
- Arch: [archiso](https://wiki.archlinux.org/title/Archiso)
- Debian: [Debian Live Manual](https://live-team.pages.debian.net/live-manual/)
- Fedora: [Kiwi](https://osinside.github.io/kiwi/), [Fedora Remix](https://fedoraproject.org/wiki/Remix)
- openSUSE: [Open Build Service](https://openbuildservice.org)
- Ubuntu: [Cubic](https://github.com/PJ-Singh-001/Cubic), [Trademark Policy](https://ubuntu.com/legal/trademarks)
- [Calamares](https://calamares.io) і його [вікі розробника](https://github.com/calamares/calamares/wiki)
- [Linux From Scratch](https://www.linuxfromscratch.org)
- [os-release(5)](https://www.freedesktop.org/software/systemd/man/latest/os-release.html), [XDG Base Directory](https://specifications.freedesktop.org/basedir-spec/latest/)
- [Arch Wiki: Unified Extensible Firmware Interface/Secure Boot](https://wiki.archlinux.org/title/Unified_Extensible_Firmware_Interface/Secure_Boot), [sbctl](https://github.com/Foxboron/sbctl)
- [Reproducible Builds](https://reproducible-builds.org), [SOURCE_DATE_EPOCH](https://reproducible-builds.org/docs/source-date-epoch/)
- [openQA](https://open.qa)
- [DistroWatch](https://distrowatch.com)
+1 -1
View File
@@ -1,2 +1,2 @@
Das OS \r (\l) Das OS @VERSION@ (\l)
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

+6
View File
@@ -40,6 +40,7 @@ firefox-i18n-uk
udisks2 udisks2
xdg-user-dirs xdg-user-dirs
xdg-utils xdg-utils
ncdu
# Boot loader and disk tools, also kept by the installed system # Boot loader and disk tools, also kept by the installed system
btrfs-progs btrfs-progs
@@ -67,4 +68,9 @@ das-snapshots
das-theme das-theme
das-tools das-tools
das-helpers das-helpers
das-shell
nano
curl
publisher
deployer
xtools-minimal xtools-minimal
+15 -3
View File
@@ -82,8 +82,9 @@ done
sed -i -e 's/echo void-live >/echo dasos >/' -e 's/voidlinux/live/g' "$adduser" sed -i -e 's/echo void-live >/echo dasos >/' -e 's/voidlinux/live/g' "$adduser"
# mklive copies its /etc/issue (Void live logins) into the image, and the # mklive copies its /etc/issue (Void live logins) into the image, and the
# installers copy the image to disk, so use a Das OS one (the server's tells # installers copy the image to disk, so use a Das OS one (the server's tells
# how to log in to the live system; its installer writes a neutral one). # how to log in to the live system; its installer keeps only the first line).
cp "$dir/branding/issue" /opt/void-mklive/data/issue # It names the Das OS version instead of agetty's \r (the kernel version).
sed "s/@VERSION@/$version/g" "$dir/branding/issue" >/opt/void-mklive/data/issue
# Size, also patched into this container's mklive copy: # Size, also patched into this container's mklive copy:
# - the live initrd only has to find and mount the image, the GPU drivers and # - the live initrd only has to find and mount the image, the GPU drivers and
@@ -101,6 +102,17 @@ sed -i \
-e 's/-comp "${SQUASHFS_COMPRESSION}"/& -b 1M -Xbcj x86/' \ -e 's/-comp "${SQUASHFS_COMPRESSION}"/& -b 1M -Xbcj x86/' \
"$mklive" "$mklive"
# Boot menu entries name the Das OS version instead of mklive's
# "<title> <kernel version> <arch>" (GRUB for UEFI, isolinux for BIOS).
isolinux=/opt/void-mklive/isolinux/isolinux.cfg.in
entry_title='ENTRY_TITLE="${BOOT_TITLE} ${KERNELVERSION} ${title_sfx}(${TARGET_ARCH})"'
if ! grep -qF -- "$entry_title" "$mklive" || ! grep -qF '@@BOOT_TITLE@@ @@KERNVER@@ @@ARCH@@' "$isolinux"; then
echo "build-iso: boot menu titles not found in mklive, mklive changed; update this script" >&2
exit 1
fi
sed -i 's/ENTRY_TITLE="${BOOT_TITLE} ${KERNELVERSION} ${title_sfx}(${TARGET_ARCH})"/ENTRY_TITLE="${BOOT_TITLE}${title_sfx:+ ${title_sfx% }}"/' "$mklive"
sed -i 's/@@BOOT_TITLE@@ @@KERNVER@@ @@ARCH@@/@@BOOT_TITLE@@/' "$isolinux"
rsvg-convert /work/live/branding/splash.svg --output "$work/splash.png" rsvg-convert /work/live/branding/splash.svg --output "$work/splash.png"
rm -f "$iso" rm -f "$iso"
@@ -115,6 +127,6 @@ SPLASH_IMAGE=$work/splash.png ./mklive.sh \
-I "$include" \ -I "$include" \
-l "$locale" \ -l "$locale" \
-k us \ -k us \
-T "$title" \ -T "$title $version" \
-C "$cmdline" \ -C "$cmdline" \
-o "$iso" -o "$iso"
+3 -1
View File
@@ -6,6 +6,7 @@
# --disk attach out/qemu/disk.qcow2 (20 GB, created on first use) to test installing # --disk attach out/qemu/disk.qcow2 (20 GB, created on first use) to test installing
# --serial no window: the serial console in this terminal (server ISO), Ctrl-A X quits # --serial no window: the serial console in this terminal (server ISO), Ctrl-A X quits
# Without a path the newest desktop ISO in out/iso is used (server ISO with --serial). # Without a path the newest desktop ISO in out/iso is used (server ISO with --serial).
# The guest's SSH is on localhost:2222 (DASOS_SSH_PORT changes it): ssh -p 2222 user@localhost
set -euo pipefail set -euo pipefail
root=$(cd "$(dirname "$0")/.." && pwd) root=$(cd "$(dirname "$0")/.." && pwd)
@@ -13,6 +14,7 @@ state=$root/out/qemu
firmware=uefi firmware=uefi
disk=false disk=false
serial=false serial=false
ssh_port=${DASOS_SSH_PORT:-2222}
iso="" iso=""
die() { die() {
@@ -43,7 +45,7 @@ fi
args=( args=(
-machine q35 -smp 4 -m 4096 -machine q35 -smp 4 -m 4096
-device virtio-net-pci,netdev=net0 -netdev user,id=net0 -device virtio-net-pci,netdev=net0 -netdev "user,id=net0,hostfwd=tcp:127.0.0.1:${ssh_port}-:22"
-cdrom "$iso" -boot order=d -cdrom "$iso" -boot order=d
) )
if [[ $serial == true ]]; then if [[ $serial == true ]]; then
+1 -1
View File
@@ -1,4 +1,4 @@
Das OS Server \r (\l) Das OS Server @VERSION@ (\l)
Log in as "live" with the password "live", then run: sudo das-server-install Log in as "live" with the password "live", then run: sudo das-server-install
@@ -66,7 +66,7 @@ t() {
s_config) echo "Налаштування системи..." ;; s_config) echo "Налаштування системи..." ;;
s_post) echo "Локалі, знімки, initramfs (кілька хвилин)..." ;; s_post) echo "Локалі, знімки, initramfs (кілька хвилин)..." ;;
s_boot) echo "Встановлення завантажувача..." ;; s_boot) echo "Встановлення завантажувача..." ;;
finished) echo "Das OS Server встановлено. Вийміть інсталяційний носій і перезавантажте сервер.\n\nВхід: %s, далі sudo. Служби: dsv. Користувачі: duser. Порти: dfw. Налаштування: dsys. Оновлення: das-update. Профіль розробника: dprofile install dev." ;; finished) echo "Das OS Server встановлено. Вийміть інсталяційний носій і перезавантажте сервер.\n\nВхід: %s, далі sudo. Служби: dsv. Користувачі: duser. Порти: dfw. Налаштування: dsys. Оновлення: das-update. Профіль розробника: dprofile install dev. Запрошення терміналу: dprompt. Банер при вході: dbanner." ;;
reboot) echo "Перезавантажити зараз?" ;; reboot) echo "Перезавантажити зараз?" ;;
failed) echo "Помилка встановлення. Журнал: %s" ;; failed) echo "Помилка встановлення. Журнал: %s" ;;
root) echo "Інсталятор треба запускати від root (sudo das-server-install)." ;; root) echo "Інсталятор треба запускати від root (sudo das-server-install)." ;;
@@ -105,7 +105,7 @@ t() {
s_config) echo "Configuring the system..." ;; s_config) echo "Configuring the system..." ;;
s_post) echo "Locales, snapshots, initramfs (a few minutes)..." ;; s_post) echo "Locales, snapshots, initramfs (a few minutes)..." ;;
s_boot) echo "Installing the boot loader..." ;; s_boot) echo "Installing the boot loader..." ;;
finished) echo "Das OS Server is installed. Remove the installation medium and reboot.\n\nLog in as %s, then use sudo. Services: dsv. Users: duser. Ports: dfw. Settings: dsys. Updates: das-update. Developer profile: dprofile install dev." ;; finished) echo "Das OS Server is installed. Remove the installation medium and reboot.\n\nLog in as %s, then use sudo. Services: dsv. Users: duser. Ports: dfw. Settings: dsys. Updates: das-update. Developer profile: dprofile install dev. Terminal prompt: dprompt. Login banner: dbanner." ;;
reboot) echo "Reboot now?" ;; reboot) echo "Reboot now?" ;;
failed) echo "Installation failed. Log: %s" ;; failed) echo "Installation failed. Log: %s" ;;
root) echo "The installer must run as root (sudo das-server-install)." ;; root) echo "The installer must run as root (sudo das-server-install)." ;;
@@ -164,22 +164,24 @@ list_disks() {
fi fi
lsblk -dpno NAME,TYPE,RO,SIZE,MODEL | while read -r name type ro size model; do lsblk -dpno NAME,TYPE,RO,SIZE,MODEL | while read -r name type ro size model; do
[[ $type == disk && $ro == 0 && $name != "$live" ]] || continue [[ $type == disk && $ro == 0 && $name != "$live" ]] || continue
[[ $name == /dev/zram* || $name == /dev/loop* ]] && continue [[ $name == /dev/zram* || $name == /dev/loop* || $name == /dev/fd* ]] && continue
printf '%s\t%s %s\n' "$name" "$size" "${model:-}" printf '%s\t%s %s\n' "$name" "$size" "${model:-}"
done done
} }
ask_language() { ask_language() {
local choice local choice
# The default console font shows Cyrillic as Latin look-alikes, so load one
# with Cyrillic before the first "Українська". sudo runs us on its own pty
# (tty says /dev/pts/N), so name the console; serial and ssh have TERM other
# than linux and no font to set.
[[ $TERM == linux ]] && { setfont -C /dev/tty0 cyr-sun16 2>/dev/null || true; }
choice=$(dialog --backtitle "Das OS Server" --title "Das OS Server" \ choice=$(dialog --backtitle "Das OS Server" --title "Das OS Server" \
--default-item "$lang" --menu "Language / Мова:" 10 50 2 \ --default-item "$lang" --menu "Language / Мова:" 10 50 2 \
en "English" uk "Українська" 3>&1 1>&2 2>&3) || cancelled en "English" uk "Українська" 3>&1 1>&2 2>&3) || cancelled
lang=$choice lang=$choice
if [[ $lang == uk ]]; then if [[ $lang == uk ]]; then
LOCALE=uk_UA.UTF-8 LOCALE=uk_UA.UTF-8
# The default console font has no Cyrillic (a font needs a Unicode table,
# .psfu); there is no font to set on serial or ssh
case $(tty) in /dev/tty[0-9]*) setfont cyr-sun16 2>/dev/null || true ;; esac
fi fi
} }
@@ -404,8 +406,9 @@ disable_service() {
configure() { configure() {
local home local home
# The live image's /etc/issue tells how to log in to the live system # The live image's /etc/issue tells how to log in to the live system: keep
printf 'Das OS Server \\r (\\l)\n\n' >"$target/etc/issue" # its first line, with the Das OS version
printf '%s\n\n' "$(head -n1 /etc/issue)" >"$target/etc/issue"
echo "$HOST_NAME" >"$target/etc/hostname" echo "$HOST_NAME" >"$target/etc/hostname"
if ! grep -q "[[:space:]]$HOST_NAME\$" "$target/etc/hosts"; then if ! grep -q "[[:space:]]$HOST_NAME\$" "$target/etc/hosts"; then
printf '127.0.1.1\t%s\n' "$HOST_NAME" >>"$target/etc/hosts" printf '127.0.1.1\t%s\n' "$HOST_NAME" >>"$target/etc/hosts"
+1 -1
View File
@@ -1,6 +1,6 @@
# Template file for 'das-server-installer' # Template file for 'das-server-installer'
pkgname=das-server-installer pkgname=das-server-installer
version=0.3.0 version=0.5.0
revision=1 revision=1
depends="bash dialog das-install-common btrfs-progs dosfstools gptfdisk parted depends="bash dialog das-install-common btrfs-progs dosfstools gptfdisk parted
rsync util-linux grub grub-x86_64-efi efibootmgr" rsync util-linux grub grub-x86_64-efi efibootmgr"
+3 -3
View File
@@ -1,10 +1,10 @@
# Template file for 'das-server' # Template file for 'das-server'
pkgname=das-server pkgname=das-server
version=0.2.0 version=0.5.0
revision=1 revision=1
depends="openssh chrony dhcpcd NetworkManager docker docker-compose nftables depends="openssh chrony dhcpcd NetworkManager docker docker-compose nftables
fail2ban socklog-void python3 curl lazydocker lazygit mc tmux htop fail2ban socklog-void python3 curl lazydocker lazygit mc tmux htop ncdu nano
das-snapshots das-update das-helpers xtools-minimal" das-snapshots das-update das-helpers das-shell xtools-minimal"
short_desc="Das OS server: packages and secure defaults for SSH, firewall and Docker" short_desc="Das OS server: packages and secure defaults for SSH, firewall and Docker"
maintainer="Das <anry.das@gmail.com>" maintainer="Das <anry.das@gmail.com>"
license="MIT" license="MIT"
+35
View File
@@ -0,0 +1,35 @@
#!/bin/bash
# Picks the icon of the menu button on the panel: das-panel-icon star|trident|<icon name>.
set -u
usage() {
echo "usage: das-panel-icon star|trident|ICON-NAME" >&2
echo "now: $(xfconf-query -c xfce4-panel -p /plugins/plugin-1/button-icon 2>/dev/null || echo das-logo)" >&2
}
case ${1:-} in
star) icon=das-logo ;;
trident) icon=das-trident ;;
help | -h | --help)
usage
exit 0
;;
"")
usage
exit 1
;;
-*)
echo "das-panel-icon: unknown option $1" >&2
usage
exit 1
;;
*) icon=$1 ;;
esac
prop=/plugins/plugin-1/button-icon
xfconf-query -c xfce4-panel -p "$prop" -s "$icon" 2>/dev/null ||
xfconf-query -c xfce4-panel -p "$prop" -n -t string -s "$icon" || exit 1
# whiskermenu keeps its own copy in the rc file, which wins over xfconf
rc="${XDG_CONFIG_HOME:-$HOME/.config}/xfce4/panel/whiskermenu-1.rc"
[[ -f $rc ]] && sed -i "s|^button-icon=.*|button-icon=$icon|" "$rc"
xfce4-panel -r >/dev/null 2>&1 &
@@ -2,7 +2,7 @@
[greeter] [greeter]
background=/usr/share/backgrounds/das/das-light.svg background=/usr/share/backgrounds/das/das-light.svg
theme-name=DasOS theme-name=DasOS
icon-theme-name=Papirus icon-theme-name=Papirus-Light
font-name=Inter 11 font-name=Inter 11
cursor-theme-name=Adwaita cursor-theme-name=Adwaita
cursor-theme-size=24 cursor-theme-size=24
@@ -3,7 +3,7 @@
<channel name="xsettings" version="1.0"> <channel name="xsettings" version="1.0">
<property name="Net" type="empty"> <property name="Net" type="empty">
<property name="ThemeName" type="string" value="DasOS"/> <property name="ThemeName" type="string" value="DasOS"/>
<property name="IconThemeName" type="string" value="Papirus"/> <property name="IconThemeName" type="string" value="Papirus-Light"/>
</property> </property>
<property name="Xft" type="empty"> <property name="Xft" type="empty">
<property name="Antialias" type="int" value="1"/> <property name="Antialias" type="int" value="1"/>
+2 -1
View File
@@ -4,7 +4,7 @@
# Defaults go to /etc/xdg/das, which /etc/profile.d/das-xdg.sh puts in front of # Defaults go to /etc/xdg/das, which /etc/profile.d/das-xdg.sh puts in front of
# /etc/xdg: stock Xfce files stay untouched and users' own changes still win. # /etc/xdg: stock Xfce files stay untouched and users' own changes still win.
pkgname=das-settings pkgname=das-settings
version=0.4.0 version=0.6.0
revision=1 revision=1
create_wrksrc=yes create_wrksrc=yes
depends="das-theme lightdm lightdm-gtk-greeter xfce4-notifyd xfce4-panel depends="das-theme lightdm lightdm-gtk-greeter xfce4-notifyd xfce4-panel
@@ -25,6 +25,7 @@ do_install() {
done done
vinstall "${FILESDIR}/bin/das-session-init" 755 usr/libexec/das vinstall "${FILESDIR}/bin/das-session-init" 755 usr/libexec/das
vinstall "${FILESDIR}/bin/das-panel-icon" 755 usr/bin
vinstall "${FILESDIR}/autostart/das-session-init.desktop" 644 etc/xdg/autostart vinstall "${FILESDIR}/autostart/das-session-init.desktop" 644 etc/xdg/autostart
vinstall "${FILESDIR}/xinitrc.d/50-das-user-dirs" 755 etc/X11/xinit/xinitrc.d vinstall "${FILESDIR}/xinitrc.d/50-das-user-dirs" 755 etc/X11/xinit/xinitrc.d
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Das
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,18 @@
# Das OS aliases and functions (lsfunc lists the functions), then the user's
# own ~/.bash_aliases and ~/.bash_functions, which win. Off for one user:
# touch ~/.config/das/no-aliases (the user's own files are still read).
# shellcheck shell=bash
if [[ $- == *i* ]]; then
if [[ ! -e ${XDG_CONFIG_HOME:-$HOME/.config}/das/no-aliases ]]; then
for __das_f in /usr/share/das/shell/aliases.sh /usr/share/das/shell/functions.sh; do
# shellcheck source=/dev/null
[[ -r $__das_f ]] && . "$__das_f"
done
fi
for __das_f in ~/.bash_aliases ~/.bash_functions; do
# shellcheck source=/dev/null
[[ -r $__das_f ]] && . "$__das_f"
done
unset __das_f
fi
@@ -0,0 +1,14 @@
# Das OS bash prompt: ~/.config/das/prompt.sh, else /etc/das/prompt.sh (both
# written by dprompt), else the stock one.
# shellcheck shell=bash
if [[ $- == *i* && -r /usr/lib/das/prompt-lib.sh ]]; then
. /usr/lib/das/prompt-lib.sh
for __das_f in "${XDG_CONFIG_HOME:-$HOME/.config}/das/prompt.sh" /etc/das/prompt.sh /usr/share/das/prompt/default.sh; do
if [[ -r $__das_f ]]; then
. "$__das_f"
break
fi
done
unset __das_f
fi
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/python3
"""dbanner of Das OS; the code is in /usr/lib/das/python/das_shell."""
import sys
sys.path.insert(0, "/usr/lib/das/python")
from das_shell.cli_banner import main # noqa: E402
sys.exit(main())
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/python3
"""dprompt of Das OS; the code is in /usr/lib/das/python/das_shell."""
import sys
sys.path.insert(0, "/usr/lib/das/python")
from das_shell.cli_prompt import main # noqa: E402
sys.exit(main())
+4
View File
@@ -0,0 +1,4 @@
#!/bin/sh
# Keeps the cached widgets of the login banner (dbanner) fresh
exec 2>&1
exec /usr/bin/dbanner refresh --loop
@@ -0,0 +1,22 @@
# bash completion for dbanner
# shellcheck shell=bash
_dbanner() {
local cur=${COMP_WORDS[COMP_CWORD]}
if ((COMP_CWORD == 1)); then
mapfile -t COMPREPLY < <(compgen -W "list show apply off on refresh help" -- "$cur")
return
fi
case ${COMP_WORDS[1]} in
show | apply)
if [[ $cur == -* ]]; then
mapfile -t COMPREPLY < <(compgen -W "--no-color" -- "$cur")
else
local names
names=$(ls /usr/share/das/banner/presets "${XDG_CONFIG_HOME:-$HOME/.config}/das/banner" 2>/dev/null | sed -n 's/\.json$//p')
mapfile -t COMPREPLY < <(compgen -W "$names" -- "$cur"; compgen -f -X '!*.json' -- "$cur")
fi
;;
refresh) mapfile -t COMPREPLY < <(compgen -W "--force" -- "$cur") ;;
esac
}
complete -F _dbanner dbanner
@@ -0,0 +1,22 @@
# bash completion for dprompt
# shellcheck shell=bash
_dprompt() {
local cur=${COMP_WORDS[COMP_CWORD]}
if ((COMP_CWORD == 1)); then
mapfile -t COMPREPLY < <(compgen -W "list show apply reset help" -- "$cur")
return
fi
case ${COMP_WORDS[1]} in
show | apply)
if [[ $cur == -* ]]; then
mapfile -t COMPREPLY < <(compgen -W "--system --script" -- "$cur")
else
local presets
presets=$(ls /usr/share/das/prompt/presets 2>/dev/null | sed 's/\.json$//')
mapfile -t COMPREPLY < <(compgen -W "$presets" -- "$cur"; compgen -f -X '!*.json' -- "$cur")
fi
;;
reset) mapfile -t COMPREPLY < <(compgen -W "--system" -- "$cur") ;;
esac
}
complete -F _dprompt dprompt
@@ -0,0 +1,5 @@
# Das OS login banner, see dbanner. It decides itself whether to show up:
# SSH logins only (unless "local" is set), not after "dbanner off" or with ~/.hushlogin.
case $- in
*i*) [ -x /usr/bin/dbanner ] && /usr/bin/dbanner show --login ;;
esac
+53
View File
@@ -0,0 +1,53 @@
# Functions behind the Das OS bash prompt (see dprompt). Sourced by
# /etc/bash/bashrc.d/das-prompt.sh and by the prompt.sh files dprompt writes.
# shellcheck shell=bash
# Colors when the terminal has at least 8 of them
__das_has_color() {
case $TERM in
*color* | xterm* | rxvt* | screen* | tmux* | linux | alacritty | foot* | kitty*) return 0 ;;
esac
[[ $(tput colors 2>/dev/null) -ge 8 ]]
}
# Sets __das_git to the branch (or short commit) and "*" when there are changes
__das_git_info() {
command -v git >/dev/null || return 1
local branch
branch=$(git symbolic-ref --short -q HEAD 2>/dev/null || git rev-parse --short -q HEAD 2>/dev/null) || return 1
[[ -n $branch ]] || return 1
if [[ -n $(git status --porcelain -uno 2>/dev/null | head -c1) ]]; then
branch+="*"
fi
__das_git=$branch
}
# Sets __das_jobs to the number of background jobs; fails when there are none
__das_jobs_count() {
local count='\j'
__das_jobs=${count@P}
((__das_jobs > 0))
}
# Runs first in PROMPT_COMMAND: keeps the exit status for the prompt and fills
# the variables of its conditional parts
__das_prompt_hook() {
__das_rc=$?
# Void's /etc/skel/.bashrc sets its own PS1 after /etc/bash/bashrc.d: put
# the Das OS one back, once, unless the user has a PS1 of their own
if [[ -n ${__das_ps1+set} ]]; then
[[ $PS1 == '[\u@\h \W]\$ ' ]] && PS1=$__das_ps1
unset __das_ps1
fi
declare -F __das_prompt_dynamic >/dev/null && __das_prompt_dynamic
return "$__das_rc"
}
__das_prompt_install() {
__das_ps1=$PS1
local cmd
for cmd in "${PROMPT_COMMAND[@]}"; do
[[ $cmd == __das_prompt_hook ]] && return 0
done
PROMPT_COMMAND=(__das_prompt_hook "${PROMPT_COMMAND[@]}")
}
@@ -0,0 +1,17 @@
"""Das OS shell tools: dprompt (bash prompt builder) and dbanner (login banner editor)."""
import os
VERSION = "0.1.0"
# Installed layout; DAS_SHELL_DATA points the tools at the source tree for tests
DATA_DIR = os.environ.get("DAS_SHELL_DATA", "/usr/share/das")
LIB_DIR = os.environ.get("DAS_SHELL_LIB", "/usr/lib/das")
ETC_DIR = os.environ.get("DAS_SHELL_ETC", "/etc/das")
CACHE_DIR = os.environ.get("DAS_SHELL_CACHE", "/var/cache/das/banner")
def config_dir():
"""The user's Das OS settings directory (~/.config/das)."""
base = os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config")
return os.path.join(base, "das")
@@ -0,0 +1,426 @@
"""Login banner model of dbanner: a grid of styled characters, fields and widgets.
A design is JSON:
{"name": "...", "title": {"uk": ..., "en": ...}, "local": false,
"vars": {"url": "https://..."},
"styles": {"a": {"fg": "blue", "bg": null, "bold": false}, ...},
"lines": ["####", ...], "style_map": ["aaaa", ...],
"fields": [{"x": 4, "y": 1, "width": 20, "align": "left", "text": "{ip}", "style": "b"}],
"widgets_position": "below", "widgets": [{"type": "builtin", "name": "disks"}, ...]}
style_map has one style letter per character of lines; a space is the
terminal's own style. This module only needs the standard library: the
banner is printed at every SSH login.
"""
import copy
import json
import os
import re
import subprocess
import unicodedata
from . import DATA_DIR, ETC_DIR, colors, config_dir
from .i18n import UK, title
STYLE_KEYS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
ALIGNS = ("left", "center", "right")
SYSTEM_FILE = os.path.join(ETC_DIR, "banner.json")
OFF_FILE = os.path.join(ETC_DIR, "banner.off")
def char_width(ch):
return 2 if unicodedata.east_asian_width(ch) in ("W", "F") else 1
class Grid:
"""Characters with a style key each; the canvas of the editor."""
def __init__(self, width=40, height=8, styles=None):
self.width, self.height = width, height
self.cells = [[[" ", " "] for _ in range(width)] for _ in range(height)]
self.styles = styles if styles is not None else {}
# --- conversion -------------------------------------------------------
@classmethod
def from_design(cls, design):
lines = design.get("lines") or []
smap = design.get("style_map") or []
width = max([len(line) for line in lines] + [1])
grid = cls(width, max(len(lines), 1), copy.deepcopy(design.get("styles") or {}))
for y, line in enumerate(lines):
keys = smap[y] if y < len(smap) else ""
for x, ch in enumerate(line):
key = keys[x] if x < len(keys) else " "
grid.cells[y][x] = [ch, key if key in grid.styles else " "]
return grid
def to_design(self, design):
"""Stores the grid into a copy of the design, dropping unused styles."""
d = copy.deepcopy(design)
lines, smap = [], []
for row in self.cells:
line = "".join(c[0] for c in row)
keys = "".join(c[1] for c in row)
# Trailing blank cells carry nothing
n = len(line)
while n and line[n - 1] == " " and keys[n - 1] == " ":
n -= 1
lines.append(line[:n])
smap.append(keys[:n])
while lines and not lines[-1]:
lines.pop()
smap.pop()
used = {k for keys in smap for k in keys} | {f.get("style") for f in d.get("fields", [])}
d["styles"] = {k: v for k, v in self.styles.items() if k in used}
d["lines"], d["style_map"] = lines, smap
d["canvas"] = {"width": self.width, "height": self.height}
return d
def copy(self):
g = Grid(self.width, self.height, copy.deepcopy(self.styles))
g.cells = [[list(c) for c in row] for row in self.cells]
return g
# --- styles -------------------------------------------------------------
def style_key(self, fg=None, bg=None, bold=False):
"""The key of a style, added when it is new."""
if not fg and not bg and not bold:
return " "
want = {"fg": fg, "bg": bg, "bold": bool(bold)}
for key, st in self.styles.items():
if {"fg": st.get("fg"), "bg": st.get("bg"), "bold": bool(st.get("bold"))} == want:
return key
for key in STYLE_KEYS:
if key not in self.styles:
self.styles[key] = want
return key
raise ValueError("too many styles")
def style(self, key):
st = self.styles.get(key) or {}
return st.get("fg"), st.get("bg"), bool(st.get("bold"))
# --- drawing --------------------------------------------------------------
def inside(self, x, y):
return 0 <= x < self.width and 0 <= y < self.height
def get(self, x, y):
return self.cells[y][x] if self.inside(x, y) else [" ", " "]
def put(self, x, y, ch, key):
if self.inside(x, y):
self.cells[y][x] = [ch, key]
def resize(self, width, height):
width, height = max(1, width), max(1, height)
cells = [[[" ", " "] for _ in range(width)] for _ in range(height)]
for y in range(min(height, self.height)):
for x in range(min(width, self.width)):
cells[y][x] = self.cells[y][x]
self.width, self.height, self.cells = width, height, cells
def text(self, x, y, text, key):
for i, ch in enumerate(text):
self.put(x + i, y, ch, key)
def line(self, x0, y0, x1, y1, ch, key):
"""A straight line; horizontal and vertical ones use ch as given."""
dx, dy = abs(x1 - x0), -abs(y1 - y0)
sx, sy = (1 if x0 < x1 else -1), (1 if y0 < y1 else -1)
err = dx + dy
while True:
self.put(x0, y0, ch, key)
if x0 == x1 and y0 == y1:
return
e2 = 2 * err
if e2 >= dy:
err += dy
x0 += sx
if e2 <= dx:
err += dx
y0 += sy
def box(self, x0, y0, x1, y1, kind, key):
x0, x1 = sorted((x0, x1))
y0, y1 = sorted((y0, y1))
tl, tr, bl, br, h, v = BOXES[kind]
if x0 == x1 or y0 == y1:
self.line(x0, y0, x1, y1, v if x0 == x1 else h, key)
return
for x in range(x0 + 1, x1):
self.put(x, y0, h, key)
self.put(x, y1, h, key)
for y in range(y0 + 1, y1):
self.put(x0, y, v, key)
self.put(x1, y, v, key)
self.put(x0, y0, tl, key)
self.put(x1, y0, tr, key)
self.put(x0, y1, bl, key)
self.put(x1, y1, br, key)
def fill(self, x0, y0, x1, y1, ch, key):
x0, x1 = sorted((x0, x1))
y0, y1 = sorted((y0, y1))
for y in range(y0, y1 + 1):
for x in range(x0, x1 + 1):
self.put(x, y, ch, key)
def region(self, x0, y0, x1, y1):
"""A copy of a rectangle as a new grid with the same styles."""
x0, x1 = sorted((x0, x1))
y0, y1 = sorted((y0, y1))
g = Grid(x1 - x0 + 1, y1 - y0 + 1, copy.deepcopy(self.styles))
for y in range(g.height):
for x in range(g.width):
g.cells[y][x] = list(self.get(x0 + x, y0 + y))
return g
def stamp(self, x, y, other, transparent=True):
"""Draws another grid here; its styles are merged into ours."""
keymap = {" ": " "}
for key in {c[1] for row in other.cells for c in row}:
if key != " ":
fg, bg, bold = other.style(key)
keymap[key] = self.style_key(fg, bg, bold)
for dy, row in enumerate(other.cells):
for dx, (ch, key) in enumerate(row):
if transparent and ch == " " and key == " ":
continue
self.put(x + dx, y + dy, ch, keymap.get(key, " "))
BOXES = {
"single": "┌┐└┘─│",
"double": "╔╗╚╝═║",
"rounded": "╭╮╰╯─│",
"heavy": "┏┓┗┛━┃",
"ascii": "++++-|",
"hash": "######",
"block": "██████",
}
# --- designs ------------------------------------------------------------------
def normalize(design):
d = copy.deepcopy(design)
d.setdefault("name", "custom")
d.setdefault("local", False)
d.setdefault("vars", {})
d.setdefault("styles", {})
d.setdefault("lines", [])
d.setdefault("style_map", [])
d.setdefault("fields", [])
d.setdefault("widgets", [])
if d.get("widgets_position") not in ("below", "right"):
d["widgets_position"] = "below"
for f in d["fields"]:
f.setdefault("align", "left")
f.setdefault("style", " ")
f.setdefault("width", len(f.get("text", "")))
return d
def preset_dir():
return os.path.join(DATA_DIR, "banner", "presets")
def art_dir():
return os.path.join(DATA_DIR, "banner", "art")
def drafts_dir():
return os.path.join(config_dir(), "banner")
def _json_names(directory):
try:
return sorted(f[:-5] for f in os.listdir(directory) if f.endswith(".json"))
except OSError:
return []
def list_presets():
out = []
for name in sorted(_json_names(preset_dir()), key=lambda n: (n != "das-classic", n)):
try:
out.append((name, title(load_file(os.path.join(preset_dir(), name + ".json")).get("title"), name)))
except (OSError, ValueError):
continue
return out
def list_drafts():
return [(n, os.path.join(drafts_dir(), n + ".json")) for n in _json_names(drafts_dir())]
def list_art():
"""(name, title, design) of the ready-made pictures and the user's own."""
out = []
for directory in (art_dir(), os.path.join(config_dir(), "banner-art")):
for name in _json_names(directory):
try:
d = load_file(os.path.join(directory, name + ".json"))
except (OSError, ValueError):
continue
out.append((name, title(d.get("title"), name), d))
return out
def load_file(path):
with open(path, encoding="utf-8") as f:
return normalize(json.load(f))
def load(spec):
"""A preset name, a draft name or a path to a design file."""
if os.sep in spec or spec.endswith(".json"):
return load_file(spec)
for directory in (preset_dir(), drafts_dir()):
path = os.path.join(directory, spec + ".json")
if os.path.exists(path):
return load_file(path)
raise FileNotFoundError(spec)
def system_design():
"""The banner shown at login: /etc/das/banner.json or the das-classic preset."""
try:
return load_file(SYSTEM_FILE)
except (OSError, ValueError):
return load_file(os.path.join(preset_dir(), "das-classic.json"))
def dumps(design):
return json.dumps(design, ensure_ascii=False, indent=2) + "\n"
def save(design, path):
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
tmp = path + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
f.write(dumps(design))
os.replace(tmp, path)
# --- rendering ------------------------------------------------------------------
_VAR = re.compile(r"\{([A-Za-z_][A-Za-z0-9_]*)\}")
_LANG = re.compile(r"\[\[([^|\]]*)\|([^\]]*)\]\]")
FIGLET_FONTS = ("standard", "small", "big", "slant", "smslant", "mini", "banner", "block",
"shadow", "smshadow", "lean", "script", "digital")
def expand(text, values):
"""[[Ukrainian|English]] in the user's language, {name} replaced by its
value; unknown names stay as they are."""
text = _LANG.sub(lambda m: m.group(1) if UK else m.group(2), text)
def repl(m):
value = values.get(m.group(1))
return m.group(0) if value is None else str(value)
return _VAR.sub(repl, text)
def figlet(text, font):
"""Big letters by figlet, or the text itself when figlet is missing."""
try:
out = subprocess.run(["figlet", "-f", font, "-w", "1000", text], capture_output=True,
text=True, timeout=2).stdout
except (OSError, subprocess.SubprocessError):
out = ""
lines = out.rstrip("\n").split("\n") if out.strip() else [text]
while lines and not lines[-1].strip():
lines.pop()
return lines
def fit(text, width, align):
if len(text) > width:
return text[:max(width - 1, 0)] + "…" if width else ""
pad = width - len(text)
if align == "right":
return " " * pad + text
if align == "center":
return " " * (pad // 2) + text + " " * (pad - pad // 2)
return text + " " * pad
def field_lines(f, values):
"""The lines a field draws: one, or several for big letters."""
# An empty value leaves no dangling separator: "{host} · {url}" -> "host"
text = expand(f.get("text", ""), values).strip(" ·|")
width, align = int(f.get("width", 0)), f.get("align", "left")
if f.get("font"):
big = figlet(text, f["font"])
if max(len(line) for line in big) <= width:
# Align the block as a whole, so the letters stay together
block = max(len(line) for line in big)
return [fit(fit(line, block, "left"), width, align) for line in big]
return [fit(text, width, align)]
return [fit(text, width, align)]
def draw_fields(grid, design, values):
for f in design.get("fields", []):
key = f.get("style", " ")
x, y = int(f.get("x", 0)), int(f.get("y", 0))
for i, line in enumerate(field_lines(f, values)):
# Spaces of a field do not cover the picture under it
for j, ch in enumerate(line):
if ch != " " or not f.get("transparent", True):
grid.put(x + j, y + i, ch, key if key in grid.styles else " ")
def grid_lines(grid, color=True):
"""The grid as terminal lines, trailing blanks removed."""
out = []
for row in grid.cells:
n = len(row)
while n and row[n - 1][0] == " " and (row[n - 1][1] == " " or not grid.style(row[n - 1][1])[1]):
n -= 1
line, current = [], " "
for ch, key in row[:n]:
if color and key != current:
line.append(colors.sgr(*grid.style(key)) if key != " " else colors.RESET)
current = key
line.append(ch)
if color and current != " ":
line.append(colors.RESET)
out.append("".join(line))
while out and not out[-1].strip():
out.pop()
return out
def visible_len(text):
return sum(char_width(c) for c in re.sub(r"\x1b\[[0-9;?]*[A-Za-z]", "", text))
def render(design, values, widget_blocks, color=True):
"""The whole banner as text: the picture with fields, then the widgets."""
design = normalize(design)
grid = Grid.from_design(design)
if design.get("canvas"):
grid.resize(max(grid.width, design["canvas"].get("width", 0)), grid.height)
draw_fields(grid, design, values)
art = grid_lines(grid, color)
blocks = [b for b in widget_blocks if b]
if not color:
blocks = [[re.sub(r"\x1b\[[0-9;?]*[A-Za-z]", "", line) for line in b] for b in blocks]
widget_lines = [line for b in blocks for line in b]
if design["widgets_position"] == "right" and art:
width = max(visible_len(line) for line in art) + 3
out = []
for i in range(max(len(art), len(widget_lines))):
left = art[i] if i < len(art) else ""
right = widget_lines[i] if i < len(widget_lines) else ""
out.append((left + " " * (width - visible_len(left)) + right) if right else left)
return "\n".join(out) + "\n"
out = list(art)
if widget_lines:
if out:
out.append("")
out += widget_lines
return "\n".join(out) + "\n"
@@ -0,0 +1,782 @@
"""dbanner editor: a list of banners with a preview, and the drawing editor."""
import copy
import os
import re
from rich.text import Text
from textual import work
from textual.app import App
from textual.binding import Binding
from textual.containers import Horizontal, Vertical, VerticalScroll
from textual.message import Message
from textual.screen import ModalScreen, Screen
from textual.widgets import (Button, Checkbox, Footer, Header, Input, Label, ListItem, ListView, RadioButton,
RadioSet, Select, Static, TabbedContent, TabPane)
from . import banner, widgets
from .canvas import TOOLS, Canvas
from .i18n import t, title
from .tui_common import AskDialog, Swatches, console_mode, run_as_root
TOOL_NAMES = {
"pen": ("Олівець", "Pen"), "eraser": ("Гумка", "Eraser"), "line": ("Лінія", "Line"),
"box": ("Рамка", "Box"), "fill": ("Заливка", "Fill"), "text": ("Текст", "Text"),
"bigtext": ("Великі літери", "Big letters"), "field": ("Поле зі змінною", "Field with a value"),
"art": ("Малюнок", "Picture"), "select": ("Виділення", "Select"),
}
TOOL_HELP = {
"pen": ("Клацніть і ведіть мишею; або Enter і стрілки.", "Click and drag; or Enter and the arrows."),
"eraser": ("Стирає клітинки під мишею.", "Clears the cells under the mouse."),
"line": ("Тягніть від початку до кінця.", "Drag from start to end."),
"box": ("Тягніть з кута в кут; вигляд рамки — нижче.", "Drag corner to corner; the box style is below."),
"fill": ("Тягніть прямокутник: заповнить символом.", "Drag a rectangle: fills it with the symbol."),
"text": ("Клацніть і друкуйте; Enter — новий рядок, Esc — кінець.",
"Click and type; Enter for a new line, Esc to stop."),
"bigtext": ("Клацніть, куди поставити великі літери (figlet).", "Click where the big letters go (figlet)."),
"field": ("Тягніть по рядку: місце для {ip}, {hostname}… Клацання по полю — змінити.",
"Drag along a row: room for {ip}, {hostname}… Click a field to change it."),
"art": ("Клацніть, куди вставити логотип Das OS, тризуб чи ваш фрагмент.",
"Click where to put the Das OS logo, the tryzub or your piece."),
"select": ("Тягніть: Ctrl+C копіювати, Ctrl+X вирізати, Ctrl+V вставити біля курсора, Del — стерти.",
"Drag: Ctrl+C copy, Ctrl+X cut, Ctrl+V paste at the cursor, Del clears."),
}
PALETTE = [
"█▓▒░▀▄▌▐■□▪▫",
"─│┌┐└┘├┤┬┴┼",
"═║╔╗╚╝╠╣╦╩╬",
"━┃┏┓┗┛╭╮╰╯╱╲",
"★☆●○◆◇►◄▲▼♥♦",
"#*+=-_|/\\<>~",
"·•°…✓✗☀☁⚡✦❖⬢",
]
BOX_KINDS = [("single", "┌─┐"), ("double", "╔═╗"), ("rounded", "╭─╮"), ("heavy", "┏━┓"),
("ascii", "+-+"), ("hash", "###"), ("block", "███")]
def ansi_text(text):
return Text.from_ansi(text, no_wrap=True)
def unique_draft_name(base):
taken = {n for n, _ in banner.list_drafts()} | {n for n, _ in banner.list_presets()}
base = re.sub(r"[^\w.-]+", "-", base).strip("-") or "banner"
name, n = base, 1
while name in taken:
n += 1
name = f"{base}-{n}"
return name
# --- dialogs ------------------------------------------------------------------
class ArtDialog(ModalScreen):
"""Choose a ready-made picture; dismisses with its design or None."""
DEFAULT_CSS = """
ArtDialog { align: center middle; }
ArtDialog > Horizontal { width: 90%; height: 80%; border: round $accent; background: $surface; }
ArtDialog ListView { width: 40; }
ArtDialog #art_preview { padding: 1 2; }
"""
BINDINGS = [("escape", "cancel", t("Скасувати", "Cancel"))]
def __init__(self):
super().__init__()
self.items = banner.list_art()
def compose(self):
with Horizontal():
yield ListView(*[ListItem(Label(Text(title_))) for _, title_, _ in self.items], id="art_list")
with Vertical():
yield VerticalScroll(Static(id="art_preview"))
yield Button(t("Вставити", "Insert"), variant="primary", id="ok")
def on_list_view_highlighted(self, event):
i = event.list_view.index
if i is not None and i < len(self.items):
d = self.items[i][2]
self.query_one("#art_preview", Static).update(
ansi_text("\n".join(banner.grid_lines(banner.Grid.from_design(d)))))
def on_list_view_selected(self, event):
self._ok()
def on_button_pressed(self, event):
self._ok()
def _ok(self):
i = self.query_one("#art_list", ListView).index
self.dismiss(self.items[i][2] if i is not None and i < len(self.items) else None)
def action_cancel(self):
self.dismiss(None)
def field_dialog(f, new):
variables = ", ".join("{" + k + "}" for k in widgets.PROVIDERS)
fields = [
("text", t(f"Текст зі змінними: {variables}, ваші змінні; [[укр|eng]] — двомовний підпис",
f"Text with values: {variables}, your variables; [[ukr|eng]] for two languages"),
f.get("text", "{hostname}")),
("width", t("Ширина", "Width"), str(f.get("width", 20))),
("align", t("Вирівнювання", "Alignment"),
[(t("ліворуч", "left"), "left"), (t("по центру", "center"), "center"), (t("праворуч", "right"), "right")],
f.get("align", "left")),
("font", t("Великі літери (figlet)", "Big letters (figlet)"),
[(t("ні", "no"), "")] + [(n, n) for n in banner.FIGLET_FONTS], f.get("font", "")),
]
if not new:
fields.append(("delete", t("Видалити поле?", "Delete the field?"),
[(t("ні", "no"), "no"), (t("так, видалити", "yes, delete"), "yes")], "no"))
return AskDialog(t("Нове поле" if new else "Поле", "New field" if new else "Field"), fields)
class Palette(Static):
"""Symbols to click; posts Picked with the symbol."""
class Picked(Message):
def __init__(self, char):
super().__init__()
self.char = char
def on_click(self, event):
row, col = event.y, event.x // 2
if 0 <= row < len(PALETTE) and 0 <= col < len(PALETTE[row]) and event.x % 2 == 0:
self.post_message(self.Picked(PALETTE[row][col]))
# --- the editor -----------------------------------------------------------------
class EditorScreen(Screen):
CSS = """
#tools { width: 40; border-right: solid $primary; padding: 0 1; }
#tools Label.h { text-style: bold; color: $accent; margin-top: 1; }
#help { color: $text-muted; height: auto; }
#palette { height: auto; }
#status { height: 1; color: $text-muted; padding: 0 1; }
RadioSet { width: 100%; }
.buttons { height: auto; }
.buttons Button { margin-right: 1; min-width: 5; }
.row { height: auto; }
.row Input { width: 1fr; }
#fields_list, #widget_list { height: 1fr; min-height: 6; }
#preview_box { padding: 0 1; }
#widget_out { height: auto; max-height: 12; border: round $primary; }
"""
BINDINGS = [
Binding("ctrl+s", "save", t("Зберегти", "Save")),
Binding("ctrl+z", "undo", t("Скасувати дію", "Undo")),
Binding("ctrl+y", "redo", t("Повторити", "Redo")),
Binding("ctrl+r", "resize", t("Розмір", "Size")),
Binding("ctrl+c", "copy", t("Копіювати", "Copy"), show=False),
Binding("ctrl+x", "cut", t("Вирізати", "Cut"), show=False),
Binding("ctrl+v", "paste", t("Вставити", "Paste"), show=False),
Binding("f5", "preview", t("Перегляд", "Preview")),
Binding("ctrl+a", "apply", t("Застосувати", "Apply")),
Binding("escape", "back", t("Назад", "Back")),
]
def __init__(self, design, draft_name):
super().__init__()
self.design = banner.normalize(design)
self.name_ = draft_name
self.grid = banner.Grid.from_design(self.design)
canvas = self.design.get("canvas") or {}
self.grid.resize(max(self.grid.width, canvas.get("width", 0), 60),
max(self.grid.height, canvas.get("height", 0), 10))
self.fields = self.design["fields"]
self.undo_stack, self.redo_stack = [], []
self.dirty = False
# --- layout ------------------------------------------------------------
def compose(self):
yield Header()
with Horizontal():
with VerticalScroll(id="tools"):
yield Label(Text(t("Інструмент", "Tool")), classes="h")
with RadioSet(id="tool"):
for i, tool in enumerate(TOOLS):
yield RadioButton(t(*TOOL_NAMES[tool]), value=i == 0)
yield Static(Text(t(*TOOL_HELP["pen"])), id="help")
yield Label(Text(t("Символ", "Symbol")), classes="h")
yield Palette(id="palette")
with Horizontal(classes="row"):
yield Input("█", max_length=1, id="char")
yield Label(Text(t("Вигляд рамки", "Box style")), classes="h")
yield Select([(f"{sample} {kind}", kind) for kind, sample in BOX_KINDS], value="single",
allow_blank=False, id="box_kind")
yield Label(Text(t("Колір символу", "Symbol color")), classes="h")
yield Swatches("cyan", id="fg")
yield Label(Text(t("Колір тла", "Background")), classes="h")
yield Swatches(None, id="bg")
yield Checkbox(t("Жирний", "Bold"), id="bold")
with TabbedContent(id="tabs"):
with TabPane(t("Малюнок", "Picture"), id="tab_draw"):
yield Canvas(self.grid, self.fields, before=self.snapshot, id="canvas")
yield Static(id="status")
with TabPane(t("Поля", "Fields"), id="tab_fields"):
yield Static(Text(t("Поля показують змінні: ім'я, IP, URL… Додаються інструментом "
"«Поле зі змінною» на полотні.",
"Fields show values: name, IP, URL… Add them with the \"Field with "
"a value\" tool on the canvas.")))
yield ListView(id="fields_list")
with Horizontal(classes="buttons"):
yield Button(t("Змінити", "Edit"), id="field_edit")
yield Button(t("Видалити", "Delete"), id="field_del", variant="error")
with TabPane(t("Віджети", "Widgets"), id="tab_widgets"):
yield Select([(t("під малюнком", "below the picture"), "below"),
(t("праворуч від малюнка", "right of the picture"), "right")],
value=self.design["widgets_position"], allow_blank=False, id="position")
yield ListView(id="widget_list")
with Horizontal(classes="buttons"):
yield Button("↑", id="w_up")
yield Button("↓", id="w_down")
yield Button(t("Вимк/увімк", "Off/on"), id="w_toggle")
yield Button(t("Налаштувати", "Settings"), id="w_edit")
yield Button(t("Запустити", "Run"), id="w_run")
yield Button(t("Видалити", "Delete"), id="w_del", variant="error")
yield Select([(f"{name_} — {title_}", name_) for name_, title_, _ in widgets.available()],
prompt=t("+ готовий віджет", "+ ready-made widget"), id="w_add")
with Horizontal(classes="row"):
yield Input(placeholder=t("своя команда, напр. df -h /data", "own command, e.g. df -h /data"),
id="w_cmd")
yield Button(t("+ команда", "+ command"), id="w_add_cmd")
with Horizontal(classes="row"):
yield Input(placeholder=t("файл з виводом скрипта, напр. ~/scripts/out/hdd",
"file with a script's output, e.g. ~/scripts/out/hdd"), id="w_file")
yield Button(t("+ файл", "+ file"), id="w_add_file")
yield VerticalScroll(Static(id="widget_out_text"), id="widget_out")
with TabPane(t("Змінні", "Variables"), id="tab_vars"):
yield Static(Text(t("Свої змінні для полів: {url}, {title}, {city} (місто для погоди)…",
"Your own values for fields: {url}, {title}, {city} (weather city)…")))
yield VerticalScroll(id="vars_box")
with Horizontal(classes="row"):
yield Input(placeholder=t("назва", "name"), id="var_name")
yield Input(placeholder=t("значення", "value"), id="var_value")
yield Button(t("Додати", "Add"), id="var_add")
yield Static(Text("\n".join(f"{{{k}}} — {t(*v)}" for k, v in widgets.VAR_HELP.items()),
style="dim"))
yield Checkbox(t("Показувати й при вході в консоль (не лише SSH)",
"Also show on console logins (not only SSH)"),
bool(self.design.get("local")), id="local")
with TabPane(t("Перегляд", "Preview"), id="tab_preview"):
yield VerticalScroll(Static(id="preview"), id="preview_box")
yield Footer()
async def on_mount(self):
self.sub_title = self.name_
self.render_palette()
self.refresh_fields()
self.refresh_widgets()
await self.refresh_vars()
self.update_status()
self.query_one(Canvas).focus()
# --- tools panel ------------------------------------------------------------
@property
def canvas(self):
return self.query_one(Canvas)
def render_palette(self):
text = Text()
for row in PALETTE:
for ch in row:
style = "reverse bold" if ch == self.canvas.char else ""
text.append(ch, style=style)
text.append(" ")
text.append("\n")
self.query_one("#palette", Static).update(text)
def on_palette_picked(self, event):
self.set_char(event.char)
def set_char(self, ch):
self.canvas.char = ch
inp = self.query_one("#char", Input)
if inp.value != ch:
inp.value = ch
self.render_palette()
self.update_status()
def on_radio_set_changed(self, event: RadioSet.Changed):
tool = TOOLS[event.index]
self.canvas.tool = tool
self.canvas.anchor = self.canvas.text_origin = None
self.query_one("#help", Static).update(Text(t(*TOOL_HELP[tool])))
self.update_status()
self.canvas.focus()
def on_swatches_picked(self, event: Swatches.Picked):
if event.swatches.id == "fg":
self.canvas.fg = event.color
else:
self.canvas.bg = event.color
self.update_status()
def update_status(self):
c = self.canvas
x, y = c.cursor
style = " ".join(filter(None, ["bold" if c.bold else "", c.fg or "default", ("on " + c.bg) if c.bg else ""]))
text = Text.assemble(
f" {t(*TOOL_NAMES[c.tool])} │ ", (c.char * 3, style), f" │ x={x} y={y} │ "
f"{self.grid.width}×{self.grid.height} │ {t('поля', 'fields')}: {len(self.fields)}")
self.query_one("#status", Static).update(text)
def snapshot(self):
self.undo_stack.append((self.grid.copy(), copy.deepcopy(self.fields)))
del self.undo_stack[:-100]
self.redo_stack.clear()
self.dirty = True
def on_canvas_changed(self, event):
self.update_status()
def on_canvas_request(self, event: Canvas.Request):
if event.tool == "art":
def put(d):
if d:
self.snapshot()
self.grid.stamp(event.x, event.y, banner.Grid.from_design(d))
self.canvas.refresh()
self.app.push_screen(ArtDialog(), put)
elif event.tool == "bigtext":
def put_big(result):
if not result or not result["text"]:
return
lines = banner.figlet(result["text"], result["font"])
self.snapshot()
key = self.canvas.key_now()
for i, line in enumerate(lines):
for j, ch in enumerate(line):
if ch != " ":
self.grid.put(event.x + j, event.y + i, ch, key)
self.canvas.refresh()
self.app.push_screen(AskDialog(t("Великі літери", "Big letters"), [
("text", t("Текст", "Text"), "Das OS"),
("font", t("Шрифт figlet", "figlet font"), [(n, n) for n in banner.FIGLET_FONTS], "standard")]),
put_big)
elif event.tool == "field":
self.edit_field(event.field_index, event.x, event.y, event.width)
def edit_field(self, index, x=0, y=0, width=20):
new = index is None
f = {"x": x, "y": y, "width": max(width, 4) if width > 1 else 20, "text": "{hostname}"} if new \
else self.fields[index]
def done(result):
if not result:
return
self.snapshot()
if result.get("delete") == "yes":
del self.fields[index]
else:
target = dict(f) if new else f
target.update(text=result["text"], align=result["align"],
width=int(result["width"]) if result["width"].isdigit() else 20,
style=self.canvas.key_now() if new else f.get("style", " "))
if result["font"]:
target["font"] = result["font"]
else:
target.pop("font", None)
if new:
self.fields.append(target)
self.canvas.refresh()
self.refresh_fields()
self.update_status()
self.app.push_screen(field_dialog(f, new), done)
# --- inputs ----------------------------------------------------------------------
def on_input_changed(self, event: Input.Changed):
if event.input.id == "char" and event.value:
self.set_char(event.value[0])
elif (event.input.id or "").startswith("var_v_"):
self.design["vars"][event.input.id[6:]] = event.value
self.dirty = True
def on_checkbox_changed(self, event: Checkbox.Changed):
if event.checkbox.id == "bold":
self.canvas.bold = event.value
self.update_status()
elif event.checkbox.id == "local":
self.design["local"] = event.value
self.dirty = True
def on_select_changed(self, event: Select.Changed):
sid = event.select.id
if sid == "box_kind":
self.canvas.box_kind = event.value
elif sid == "position":
self.design["widgets_position"] = event.value
self.dirty = True
elif sid == "w_add" and event.value is not Select.NULL:
self.design["widgets"].append({"type": "builtin", "name": event.value})
event.select.clear()
self.refresh_widgets(len(self.design["widgets"]) - 1)
# --- fields tab ---------------------------------------------------------------------
def refresh_fields(self):
lv = self.query_one("#fields_list", ListView)
lv.clear()
for f in self.fields:
font = f" [{f['font']}]" if f.get("font") else ""
lv.append(ListItem(Label(Text(f"({f['x']},{f['y']}) ×{f.get('width')} {f.get('align')}{font}: "
f"{f.get('text', '')}"))))
# --- widgets tab --------------------------------------------------------------------
def refresh_widgets(self, index=None):
lv = self.query_one("#widget_list", ListView)
lv.clear()
for w in self.design["widgets"]:
mark = "☐" if w.get("enabled") is False else "☑"
mode = widgets.mode_of(w)
info = t("при вході", "at login") if mode == "live" else \
t(f"кеш, кожні {widgets.interval_of(w)} с", f"cached, every {widgets.interval_of(w)} s")
label = Text.assemble(f"{mark} ", widgets.describe(w), (f" ({info})", "dim"))
if w.get("title"):
label.append(f" «{w['title']}»", style="italic")
lv.append(ListItem(Label(label)))
if index is not None:
lv.index = index
def widget_index(self):
i = self.query_one("#widget_list", ListView).index
return i if i is not None and i < len(self.design["widgets"]) else None
@work(thread=True, exclusive=True, group="widget_run")
def run_widget(self, w):
lines = widgets.run(w, widgets.Values(self.design.get("vars")), self.design.get("vars"),
timeout=float(w.get("timeout") or 30))
out = "\n".join(lines) if lines else t("(порожньо: немає даних, помилка або потрібен root)",
"(empty: no data, an error, or it needs root)")
self.app.call_from_thread(self.query_one("#widget_out_text", Static).update, ansi_text(out))
def edit_widget(self, i):
w = self.design["widgets"][i]
def done(result):
if not result:
return
w["title"] = result["title"]
for key in ("interval", "timeout"):
if result[key].isdigit():
w[key] = int(result[key])
else:
w.pop(key, None)
if result["mode"]:
w["mode"] = result["mode"]
else:
w.pop("mode", None)
if w.get("type") == "command":
w["cmd"] = result.get("cmd", w.get("cmd", ""))
self.dirty = True
self.refresh_widgets(i)
fields = [("title", t("Заголовок над виводом (необов'язково)", "Heading above the output (optional)"),
w.get("title", "")),
("mode", t("Коли виконувати", "When to run"),
[(t("як задумано віджетом", "as the widget intends"), ""),
(t("при кожному вході (швидкі)", "at every login (quick ones)"), "live"),
(t("службою das-banner, з кешем (повільні, від root)", "by the das-banner service, cached "
"(slow ones, as root)"), "cached")], w.get("mode", "")),
("interval", t("Оновлювати кеш кожні … секунд", "Refresh the cache every … seconds"),
str(w.get("interval", ""))),
("timeout", t("Тайм-аут, секунд", "Timeout, seconds"), str(w.get("timeout", "")))]
if w.get("type") == "command":
fields.insert(0, ("cmd", t("Команда", "Command"), w.get("cmd", "")))
self.app.push_screen(AskDialog(widgets.describe(w), fields), done)
# --- variables tab -------------------------------------------------------------------
async def refresh_vars(self):
box = self.query_one("#vars_box", VerticalScroll)
await box.remove_children()
rows = []
for key, value in self.design["vars"].items():
rows.append(Horizontal(Label(Text(f"{{{key}}}"), classes="var_label"),
Input(str(value), id="var_v_" + key),
Button("✕", id="var_del_" + key), classes="row"))
await box.mount(*rows)
# --- buttons -----------------------------------------------------------------------------
async def on_button_pressed(self, event: Button.Pressed):
bid = event.button.id or ""
ws = self.design["widgets"]
i = self.widget_index()
if bid == "field_edit":
n = self.query_one("#fields_list", ListView).index
if n is not None and n < len(self.fields):
self.edit_field(n)
elif bid == "field_del":
n = self.query_one("#fields_list", ListView).index
if n is not None and n < len(self.fields):
self.snapshot()
del self.fields[n]
self.refresh_fields()
self.canvas.refresh()
elif bid == "w_add_cmd":
cmd = self.query_one("#w_cmd", Input).value.strip()
if cmd:
ws.append({"type": "command", "cmd": cmd})
self.query_one("#w_cmd", Input).value = ""
self.refresh_widgets(len(ws) - 1)
elif bid == "w_add_file":
path = self.query_one("#w_file", Input).value.strip()
if path:
ws.append({"type": "file", "path": path})
self.query_one("#w_file", Input).value = ""
self.refresh_widgets(len(ws) - 1)
elif bid.startswith("var_del_"):
self.design["vars"].pop(bid[8:], None)
await self.refresh_vars()
elif bid == "var_add":
name = re.sub(r"\W", "", self.query_one("#var_name", Input).value)
if name:
self.design["vars"][name] = self.query_one("#var_value", Input).value
self.query_one("#var_name", Input).value = self.query_one("#var_value", Input).value = ""
await self.refresh_vars()
elif i is not None:
if bid == "w_up" and i > 0:
ws[i - 1], ws[i] = ws[i], ws[i - 1]
self.refresh_widgets(i - 1)
elif bid == "w_down" and i + 1 < len(ws):
ws[i + 1], ws[i] = ws[i], ws[i + 1]
self.refresh_widgets(i + 1)
elif bid == "w_toggle":
ws[i]["enabled"] = ws[i].get("enabled") is False
self.refresh_widgets(i)
elif bid == "w_del":
del ws[i]
self.refresh_widgets(min(i, len(ws) - 1) if ws else None)
elif bid == "w_edit":
self.edit_widget(i)
elif bid == "w_run":
self.query_one("#widget_out_text", Static).update(Text(t("Виконується…", "Running…")))
self.run_widget(ws[i])
return
self.dirty = True
# --- result ------------------------------------------------------------------------------
def current_design(self):
return self.grid.to_design(dict(self.design, fields=self.fields, name=self.name_))
def draft_path(self):
return os.path.join(banner.drafts_dir(), self.name_ + ".json")
def action_save(self):
banner.save(self.current_design(), self.draft_path())
self.dirty = False
self.notify(t(f"Збережено: {self.draft_path()}", f"Saved: {self.draft_path()}"))
return True
def action_undo(self):
if self.undo_stack:
self.redo_stack.append((self.grid.copy(), copy.deepcopy(self.fields)))
self._restore(*self.undo_stack.pop())
def action_redo(self):
if self.redo_stack:
self.undo_stack.append((self.grid.copy(), copy.deepcopy(self.fields)))
self._restore(*self.redo_stack.pop())
def _restore(self, grid, fields):
self.grid.cells, self.grid.styles = grid.cells, grid.styles
self.grid.width, self.grid.height = grid.width, grid.height
self.fields[:] = fields
self.canvas.set_grid(self.grid, self.fields)
self.refresh_fields()
self.update_status()
def action_copy(self):
if self.canvas.copy_selection():
self.notify(t("Скопійовано", "Copied"))
def action_cut(self):
self.canvas.copy_selection(cut=True)
def action_paste(self):
self.canvas.paste()
def action_resize(self):
def done(result):
if result and result["w"].isdigit() and result["h"].isdigit():
self.snapshot()
self.grid.resize(min(int(result["w"]), 300), min(int(result["h"]), 100))
self.canvas.set_grid(self.grid, self.fields)
self.update_status()
self.app.push_screen(AskDialog(t("Розмір полотна", "Canvas size"), [
("w", t("Ширина, символів", "Width, characters"), str(self.grid.width)),
("h", t("Висота, рядків", "Height, lines"), str(self.grid.height))]), done)
def action_preview(self):
self.query_one("#tabs", TabbedContent).active = "tab_preview"
self.query_one("#preview", Static).update(Text(t("Збираю дані…", "Collecting data…")))
self.build_preview(self.current_design())
def on_tabbed_content_tab_activated(self, event):
if event.pane.id == "tab_preview":
self.query_one("#preview", Static).update(Text(t("Збираю дані…", "Collecting data…")))
self.build_preview(self.current_design())
elif event.pane.id == "tab_fields":
self.refresh_fields()
@work(thread=True, exclusive=True, group="preview")
def build_preview(self, design):
values = widgets.Values(design.get("vars"))
blocks = widgets.blocks(design, values, use_cache=False)
text = banner.render(design, values, blocks)
self.app.call_from_thread(self.query_one("#preview", Static).update, ansi_text(text))
def action_apply(self):
self.action_save()
if run_as_root(self.app, ["dbanner", "apply", self.draft_path()]):
self.notify(t("Банер застосовано: його видно при вході по SSH.",
"The banner is applied: it shows at SSH login."), timeout=6)
def action_back(self):
if self.canvas.anchor is not None or self.canvas.selection or self.canvas.text_origin:
self.canvas.anchor = self.canvas.selection = self.canvas.text_origin = None
self.canvas.refresh()
return
if not self.dirty:
self.dismiss(None)
return
def done(result):
if result is None:
return
if result["save"] == "yes":
self.action_save()
self.dismiss(None)
self.app.push_screen(AskDialog(t("Є незбережені зміни", "There are unsaved changes"), [
("save", t("Зберегти чернетку перед виходом?", "Save the draft before leaving?"),
[(t("так, зберегти", "yes, save"), "yes"), (t("ні, відкинути", "no, discard"), "no")], "yes")]), done)
# --- start screen ------------------------------------------------------------------
class StartScreen(Screen):
CSS = """
#list { width: 46; border-right: solid $primary; }
#start_preview { padding: 0 1; }
#start_main { height: 1fr; }
.buttons { height: auto; }
.buttons Button { margin-right: 1; }
"""
BINDINGS = [
Binding("enter", "edit", t("Редагувати", "Edit"), show=False),
Binding("e", "edit", t("Редагувати", "Edit")),
Binding("n", "new", t("Новий", "New")),
Binding("a", "apply", t("Застосувати", "Apply")),
Binding("delete", "delete", t("Видалити чернетку", "Delete draft")),
Binding("q", "app.quit", t("Вийти", "Quit"), show=False),
]
def compose(self):
yield Header()
with Horizontal(id="start_main"):
yield ListView(id="list")
yield VerticalScroll(Static(id="start_preview"))
with Horizontal(classes="buttons"):
yield Button(t("Редагувати копію", "Edit a copy"), id="edit", variant="primary")
yield Button(t("Новий", "New"), id="new")
yield Button(t("Застосувати", "Apply"), id="apply", variant="success")
yield Button(t("Видалити чернетку", "Delete draft"), id="delete", variant="error")
yield Footer()
def on_mount(self):
self.reload()
def on_screen_resume(self):
self.reload()
def reload(self):
# (kind, name, path or None, label)
self.entries = []
if os.path.exists(banner.SYSTEM_FILE):
self.entries.append(("system", "system", banner.SYSTEM_FILE,
t("● Поточний банер системи", "● The system's current banner")))
for name, path in banner.list_drafts():
self.entries.append(("draft", name, path, t("✎ Чернетка: ", "✎ Draft: ") + name))
for name, title_ in banner.list_presets():
self.entries.append(("preset", name, os.path.join(banner.preset_dir(), name + ".json"), title_))
lv = self.query_one("#list", ListView)
index = lv.index or 0
lv.clear()
for kind, name, _, label in self.entries:
lv.append(ListItem(Label(Text(label, style="bold" if kind != "preset" else ""))))
lv.index = min(index, len(self.entries) - 1) if self.entries else None
def selected(self):
i = self.query_one("#list", ListView).index
return self.entries[i] if i is not None and i < len(self.entries) else None
def on_list_view_highlighted(self, event):
entry = self.selected()
if entry:
self.query_one("#start_preview", Static).update(Text(t("Збираю дані…", "Collecting data…")))
self.show_preview(entry[2])
def on_list_view_selected(self, event):
self.action_edit()
@work(thread=True, exclusive=True, group="start_preview")
def show_preview(self, path):
try:
design = banner.load_file(path)
values = widgets.Values(design.get("vars"))
text = banner.render(design, values, widgets.blocks(design, values, use_cache=True))
except (OSError, ValueError) as e:
text = str(e)
self.app.call_from_thread(self.query_one("#start_preview", Static).update, ansi_text(text))
def on_button_pressed(self, event: Button.Pressed):
getattr(self, "action_" + event.button.id)()
def action_edit(self):
entry = self.selected()
if not entry:
return
kind, name, path, _ = entry
design = banner.load_file(path)
draft = name if kind == "draft" else unique_draft_name(design.get("name", name) if kind == "system"
else name + "-my")
self.app.push_screen(EditorScreen(design, draft))
def action_new(self):
def done(result):
if result:
design = {"name": result["name"], "canvas": {"width": 60, "height": 10}}
self.app.push_screen(EditorScreen(design, unique_draft_name(result["name"])))
self.app.push_screen(AskDialog(t("Новий банер", "New banner"),
[("name", t("Назва чернетки", "Draft name"), "my-banner")]), done)
def action_apply(self):
entry = self.selected()
if entry and entry[0] != "system":
if run_as_root(self.app, ["dbanner", "apply", entry[2]]):
self.notify(t("Банер застосовано.", "The banner is applied."))
self.reload()
def action_delete(self):
entry = self.selected()
if entry and entry[0] == "draft":
os.remove(entry[2])
self.notify(t(f"Видалено {entry[1]}", f"Deleted {entry[1]}"))
self.reload()
class BannerApp(App):
TITLE = "dbanner"
SUB_TITLE = t("вітальний банер при вході по SSH", "SSH login banner")
# Same quit key as dprompt; "q" stays as a hidden shortcut on the start screen
BINDINGS = [Binding("ctrl+q", "quit", t("Вийти", "Quit"))]
def __init__(self):
# The terminal's own 16 colors: the picture looks as it will at login
console_mode()
super().__init__(ansi_color=True)
def on_mount(self):
self.push_screen(StartScreen())
__all__ = ["BannerApp", "title"]
@@ -0,0 +1,315 @@
"""The drawing canvas of the dbanner editor: a Grid shown cell by cell, drawn with
the mouse or the keyboard."""
from rich.segment import Segment
from rich.style import Style
from textual import events
from textual.geometry import Size
from textual.message import Message
from textual.reactive import reactive
from textual.scroll_view import ScrollView
from textual.strip import Strip
from . import banner
TOOLS = ("pen", "eraser", "line", "box", "fill", "text", "bigtext", "field", "art", "select")
DRAG_TOOLS = ("line", "box", "fill", "select", "field")
def rich_cell_style(grid, key):
fg, bg, bold = grid.style(key)
return Style(color=fg, bgcolor=bg, bold=bold)
class Canvas(ScrollView, can_focus=True):
"""Shows a grid with its fields. before() is called right before every
change (for undo; a message would arrive after the change)."""
DEFAULT_CSS = """
Canvas { background: $background; }
Canvas:focus { border: none; }
"""
cursor = reactive((0, 0), always_update=True)
class Changed(Message):
pass
class Request(Message):
"""A tool needs a dialog: big text, field, picture."""
def __init__(self, tool, x, y, width=0, field_index=None):
super().__init__()
self.tool, self.x, self.y, self.width, self.field_index = tool, x, y, width, field_index
def __init__(self, grid, fields, before=None, **kw):
super().__init__(**kw)
self.grid, self.fields = grid, fields
self.before = before
self.tool = "pen"
self.char = "█"
self.fg, self.bg, self.bold = "cyan", None, False
self.box_kind = "single"
self.anchor = None # start of a drag or of a keyboard shape
self.last = None # last point of the pen
self.selection = None # (x0, y0, x1, y1)
self.clipboard = None
self.text_origin = None
# --- geometry ------------------------------------------------------------
def set_grid(self, grid, fields):
self.grid, self.fields = grid, fields
self.virtual_size = Size(grid.width + 1, grid.height + 1)
self.refresh()
def on_mount(self):
self.virtual_size = Size(self.grid.width + 1, self.grid.height + 1)
def key_now(self):
return self.grid.style_key(self.fg, self.bg, self.bold)
def _cell_at(self, event):
offset = event.get_content_offset(self)
if offset is None:
return None
x, y = offset.x + int(self.scroll_offset.x), offset.y + int(self.scroll_offset.y)
return (min(max(x, 0), self.grid.width - 1), min(max(y, 0), self.grid.height - 1))
# --- rendering --------------------------------------------------------------
def _shape_preview(self):
"""Cells the current drag would change: {(x, y): (char, key)}."""
if self.anchor is None or self.tool not in DRAG_TOOLS or self.tool in ("select", "field"):
return {}
g = banner.Grid(self.grid.width, self.grid.height, self.grid.styles)
x0, y0 = self.anchor
x1, y1 = self.cursor
key = self.key_now()
if self.tool == "line":
g.line(x0, y0, x1, y1, self.char, key)
elif self.tool == "box":
g.box(x0, y0, x1, y1, self.box_kind, key)
else:
g.fill(x0, y0, x1, y1, self.char, key)
return {(x, y): c for y, row in enumerate(g.cells) for x, c in enumerate(row) if c != [" ", " "]}
def _field_cells(self):
cells = {}
for n, f in enumerate(self.fields):
label = banner.fit(f.get("text", ""), int(f.get("width", 1)), f.get("align", "left"))
for i, ch in enumerate(label):
cells[(int(f["x"]) + i, int(f["y"]))] = (ch, f.get("style", " "), n)
return cells
def render_line(self, y):
sx, sy = self.scroll_offset
row = y + int(sy)
width = self.size.width
if row > self.grid.height:
return Strip.blank(width)
if row == self.grid.height:
# The bottom edge shows where the canvas ends
edge = "▔" * self.grid.width + "◢"
return Strip([Segment(edge[int(sx):], Style(color="grey35"))]).crop(0, width)
preview = self._shape_preview()
fields = self._field_cells()
sel = None
if self.selection:
x0, y0, x1, y1 = self.selection
sel = (min(x0, x1), min(y0, y1), max(x0, x1), max(y0, y1))
if self.tool in ("select", "field") and self.anchor is not None:
(ax, ay), (cx, cy) = self.anchor, self.cursor
if self.tool == "field":
cy = ay
sel = (min(ax, cx), min(ay, cy), max(ax, cx), max(ay, cy))
segs = []
for x in range(int(sx), self.grid.width):
ch, key = preview.get((x, row)) or self.grid.get(x, row)
style = rich_cell_style(self.grid, key)
if (x, row) in fields:
ch, fkey, _ = fields[(x, row)]
style = rich_cell_style(self.grid, fkey) + Style(underline=True, italic=True)
if sel and sel[0] <= x <= sel[2] and sel[1] <= row <= sel[3]:
style += Style(reverse=True)
if (x, row) == tuple(self.cursor) and self.has_focus:
style += Style(bgcolor="grey50" if ch == " " else None, reverse=ch != " ", blink=False)
segs.append(Segment(ch, style))
segs.append(Segment("▕", Style(color="grey35")))
return Strip(segs).crop(0, width)
def watch_cursor(self, old, new):
self.refresh()
self.post_message(self.Changed())
# --- editing helpers ------------------------------------------------------------
def _change(self):
if self.before:
self.before()
def _done(self):
self.refresh()
self.post_message(self.Changed())
def field_at(self, x, y):
for n, f in enumerate(self.fields):
if int(f["y"]) == y and int(f["x"]) <= x < int(f["x"]) + int(f.get("width", 1)):
return n
return None
def press(self, x, y):
"""The mouse button or Enter at a cell."""
tool = self.tool
if tool in ("pen", "eraser"):
self._change()
ch, key = (self.char, self.key_now()) if tool == "pen" else (" ", " ")
self.grid.put(x, y, ch, key)
self.last = (x, y)
self._done()
elif tool == "field" and self.field_at(x, y) is not None:
self.post_message(self.Request("field", x, y, field_index=self.field_at(x, y)))
elif tool in DRAG_TOOLS:
self.anchor = (x, y)
if tool == "select":
self.selection = None
elif tool == "text":
self.text_origin = (x, y)
elif tool in ("bigtext", "art"):
self.post_message(self.Request(tool, x, y))
self.cursor = (x, y)
def drag(self, x, y):
if self.tool in ("pen", "eraser") and self.last is not None:
ch, key = (self.char, self.key_now()) if self.tool == "pen" else (" ", " ")
self.grid.line(self.last[0], self.last[1], x, y, ch, key)
self.last = (x, y)
self.refresh()
self.cursor = (x, y)
def release(self, x, y):
"""Ends a drag: draws the shape."""
self.last = None
if self.anchor is None or self.tool not in DRAG_TOOLS:
if self.tool in ("pen", "eraser"):
self.post_message(self.Changed())
return
(x0, y0), self.anchor = self.anchor, None
key = self.key_now()
if self.tool == "select":
self.selection = (x0, y0, x, y)
elif self.tool == "field":
x0, x1 = sorted((x0, x))
self.post_message(self.Request("field", x0, y0, width=x1 - x0 + 1))
else:
self._change()
if self.tool == "line":
self.grid.line(x0, y0, x, y, self.char, key)
elif self.tool == "box":
self.grid.box(x0, y0, x, y, self.box_kind, key)
elif self.tool == "fill":
self.grid.fill(x0, y0, x, y, self.char, key)
self._done()
self.refresh()
# --- mouse --------------------------------------------------------------------------
def on_mouse_down(self, event: events.MouseDown):
cell = self._cell_at(event)
if cell and event.button == 1:
self.focus()
self.capture_mouse()
self.press(*cell)
def on_mouse_move(self, event: events.MouseMove):
if event.button == 1:
cell = self._cell_at(event)
if cell:
self.drag(*cell)
def on_mouse_up(self, event: events.MouseUp):
self.release_mouse()
cell = self._cell_at(event) or self.cursor
self.release(*cell)
# --- keyboard -----------------------------------------------------------------------
def on_key(self, event: events.Key):
x, y = self.cursor
moves = {"left": (-1, 0), "right": (1, 0), "up": (0, -1), "down": (0, 1)}
if event.key in moves:
dx, dy = moves[event.key]
nx = min(max(x + dx, 0), self.grid.width - 1)
ny = min(max(y + dy, 0), self.grid.height - 1)
self.cursor = (nx, ny)
if self.tool in ("pen", "eraser") and self.last is not None:
self.drag(nx, ny)
self.scroll_to_region_cell(nx, ny)
event.stop()
elif self.tool == "text" and self.text_origin is not None and event.key not in ("escape", "tab"):
self._type(event)
elif event.key in ("enter", "space"):
self._keyboard_press(x, y)
event.stop()
elif event.key in ("delete", "backspace") and self.selection:
self._change()
x0, y0, x1, y1 = self.selection
self.grid.fill(x0, y0, x1, y1, " ", " ")
self._done()
event.stop()
elif event.key == "escape":
self.anchor = self.last = self.selection = self.text_origin = None
self.refresh()
def _keyboard_press(self, x, y):
if self.tool in DRAG_TOOLS and self.anchor is not None:
self.release(x, y)
elif self.tool in ("pen", "eraser"):
if self.last is None:
self.press(x, y) # Enter starts drawing as the cursor moves
else:
self.last = None
self.post_message(self.Changed())
else:
self.press(x, y)
def _type(self, event):
x, y = self.cursor
if event.key == "enter":
ox, _ = self.text_origin
self.cursor = (ox, min(y + 1, self.grid.height - 1))
elif event.key == "backspace":
if x > 0:
self._change()
self.grid.put(x - 1, y, " ", " ")
self.cursor = (x - 1, y)
self._done()
elif event.character and event.is_printable:
self._change()
self.grid.put(x, y, event.character, self.key_now())
self.cursor = (min(x + 1, self.grid.width - 1), y)
self._done()
else:
return
event.stop()
def scroll_to_region_cell(self, x, y):
from textual.geometry import Region
self.scroll_to_region(Region(x, y, 1, 1), animate=False)
# --- clipboard ------------------------------------------------------------------------
def copy_selection(self, cut=False):
if not self.selection:
return False
x0, y0, x1, y1 = self.selection
self.clipboard = self.grid.region(x0, y0, x1, y1)
if cut:
self._change()
self.grid.fill(x0, y0, x1, y1, " ", " ")
self._done()
return True
def paste(self):
if self.clipboard is None:
return False
self._change()
x, y = self.cursor
self.grid.stamp(x, y, self.clipboard, transparent=False)
self.selection = (x, y, x + self.clipboard.width - 1, y + self.clipboard.height - 1)
self._done()
return True
@@ -0,0 +1,49 @@
"""Shared bits of the dprompt and dbanner command lines."""
import os
import sys
from .i18n import t
def die(prog, uk, en, code=1):
print(f"{prog}: {t(uk, en)}", file=sys.stderr)
sys.exit(code)
def need_root(argv):
"""Runs the command again through sudo with the same arguments."""
if os.geteuid() == 0:
return
script = os.path.abspath(sys.argv[0])
try:
os.execvp("sudo", ["sudo", sys.executable, script] + argv)
except OSError as e:
print(f"sudo: {e}", file=sys.stderr)
sys.exit(1)
def resolve(spec):
"""A file argument as an absolute path (sudo starts elsewhere); names stay names."""
if os.sep in spec or spec.endswith(".json"):
return os.path.abspath(spec)
return spec
def run_tui(prog, loader):
try:
app = loader()
except ImportError as e:
die(prog, f"немає бібліотеки Textual ({e}); встановіть python3-textual",
f"the Textual library is missing ({e}); install python3-textual")
app.run()
return 0
def write_file(path, text, mode=0o644):
os.makedirs(os.path.dirname(path), exist_ok=True)
tmp = path + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
f.write(text)
os.chmod(tmp, mode)
os.replace(tmp, path)
@@ -0,0 +1,154 @@
"""dbanner: the banner shown at SSH login. Without arguments it opens the editor."""
import json
import os
import subprocess
import sys
import time
from . import banner, widgets
from .cli import die, need_root, resolve, run_tui
from .i18n import t
PROG = "dbanner"
SERVICE = "/etc/sv/das-banner"
SERVICE_LINK = "/var/service/das-banner"
def text(design, use_cache, color):
values = widgets.Values(design.get("vars"))
blocks = widgets.blocks(design, values, use_cache=use_cache)
return banner.render(design, values, blocks, color=color)
def wants_login_banner(design):
if os.path.exists(banner.OFF_FILE) or os.path.exists(os.path.expanduser("~/.hushlogin")):
return False
if not sys.stdout.isatty():
return False
return bool(os.environ.get("SSH_TTY")) or bool(design.get("local"))
def has_cached(design):
return any(w.get("enabled") is not False and widgets.mode_of(w) == "cached"
for w in design.get("widgets", []))
def set_service(enabled):
"""Enables the das-banner service (runit) that keeps cached widgets fresh."""
if not os.path.isdir(os.path.dirname(SERVICE_LINK)) or not os.path.isdir(SERVICE):
return False
if enabled and not os.path.lexists(SERVICE_LINK):
os.symlink(SERVICE, SERVICE_LINK)
elif not enabled and os.path.lexists(SERVICE_LINK):
os.remove(SERVICE_LINK)
return True
def apply(design):
design = banner.normalize(design)
banner.save(design, banner.SYSTEM_FILE)
os.chmod(banner.SYSTEM_FILE, 0o644)
cached = has_cached(design)
service = set_service(cached)
if service and cached:
# The service fills the cache in a moment; this makes the first login see it too
subprocess.Popen([sys.executable, os.path.abspath(sys.argv[0]), "refresh"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True)
return cached and service
USAGE = t("""Використання: dbanner [команда]
(без команди) редактор банера з готовими варіантами
list готові банери, ваші чернетки й малюнки
show [НАЗВА|ФАЙЛ] показати банер (типово — системний) зі свіжими даними
--no-color без кольорів
apply НАЗВА|ФАЙЛ зробити банером при вході по SSH (/etc/das/banner.json)
off | on вимкнути або ввімкнути банер
refresh [--force] оновити віджети з кешем (робить служба das-banner)
Банер показується при вході по SSH; "local": true у файлі — і при вході в консоль.
Окремий користувач вимикає його файлом ~/.hushlogin.""",
"""Usage: dbanner [command]
(no command) banner editor with ready-made designs
list ready-made banners, your drafts and pictures
show [NAME|FILE] show a banner (default: the system one) with fresh data
--no-color without colors
apply NAME|FILE make it the SSH login banner (/etc/das/banner.json)
off | on turn the banner off or on
refresh [--force] update the cached widgets (the das-banner service does it)
The banner is shown at SSH logins; "local": true in the file shows it on the
console too. A user turns it off for themselves with ~/.hushlogin.""")
def main(argv=None):
argv = sys.argv[1:] if argv is None else argv
if not argv:
from . import banner_app
return run_tui(PROG, banner_app.BannerApp)
cmd, args = argv[0], argv[1:]
flags = {a for a in args if a.startswith("--")}
args = [a for a in args if not a.startswith("--")]
try:
if cmd in ("-h", "--help", "help"):
print(USAGE)
elif cmd == "list":
print(t("Готові:", "Ready-made:"))
for name, title in banner.list_presets():
print(f" {name:<14} {title}")
drafts = banner.list_drafts()
if drafts:
print(t("Чернетки:", "Drafts:"))
for name, path in drafts:
print(f" {name:<14} {path}")
print(t("Малюнки (вставляються в редакторі):", "Pictures (inserted in the editor):"))
for name, title, _ in banner.list_art():
print(f" {name:<14} {title}")
elif cmd == "show":
if "--login" in flags:
design = banner.system_design()
if wants_login_banner(design):
sys.stdout.write(text(design, use_cache=True, color=True))
sys.stdout.flush()
# A hung widget thread must not hold the login
os._exit(0)
design = banner.load(args[0]) if args else banner.system_design()
color = "--no-color" not in flags and sys.stdout.isatty() or "--color" in flags
sys.stdout.write(text(design, use_cache=not args, color=color))
elif cmd == "apply":
if not args:
die(PROG, "вкажіть назву або файл", "name a design or a file")
spec = resolve(args[0])
design = banner.load(spec)
need_root(["apply", spec])
cached = apply(design)
print(t("Банер записано в /etc/das/banner.json, його видно при наступному вході по SSH.",
"The banner is written to /etc/das/banner.json; it shows at the next SSH login."))
if cached:
print(t("Віджети з кешем оновлює служба das-banner (увімкнено).",
"Cached widgets are updated by the das-banner service (enabled)."))
elif cmd in ("off", "on"):
need_root([cmd])
if cmd == "off":
open(banner.OFF_FILE, "w").close()
elif os.path.exists(banner.OFF_FILE):
os.remove(banner.OFF_FILE)
print(t("Банер вимкнено." if cmd == "off" else "Банер увімкнено.",
"The banner is off." if cmd == "off" else "The banner is on."))
elif cmd == "refresh":
if "--loop" in flags:
while True:
widgets.refresh(banner.system_design())
time.sleep(60)
widgets.refresh(banner.system_design(), force="--force" in flags)
else:
die(PROG, f"невідома команда: {cmd}\n{USAGE}", f"unknown command: {cmd}\n{USAGE}", 2)
except FileNotFoundError as e:
die(PROG, f"не знайдено: {e.filename or e}", f"not found: {e.filename or e}")
except (ValueError, json.JSONDecodeError) as e:
die(PROG, f"помилка у файлі: {e}", f"bad file: {e}")
except PermissionError as e:
die(PROG, f"немає доступу: {e.filename}", f"permission denied: {e.filename}")
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,132 @@
"""dprompt: builds the bash prompt. Without arguments it opens the editor."""
import json
import os
import sys
from . import ETC_DIR, config_dir, prompt
from .cli import die, need_root, resolve, run_tui, write_file
from .i18n import t
PROG = "dprompt"
def targets(system):
"""(prompt.sh, design json) of the user or of the whole system."""
base = ETC_DIR if system else config_dir()
return os.path.join(base, "prompt.sh"), os.path.join(base, "prompt.json")
def current():
"""The design in effect for this user: their own, the system one or the stock one."""
for system in (False, True):
path = targets(system)[1]
if os.path.exists(path):
try:
return prompt.load_file(path), path
except (OSError, ValueError):
continue
return prompt.load_preset("das"), None
def apply(design, system):
sh, js = targets(system)
design = prompt.normalize(design)
write_file(js, prompt.dumps(design))
write_file(sh, prompt.script(design))
return sh
def reset(system):
for path in targets(system):
try:
os.remove(path)
except FileNotFoundError:
pass
USAGE = t("""Використання: dprompt [команда]
(без команди) редактор запрошення з переглядом
list готові запрошення
show [НАЗВА|ФАЙЛ] як виглядає запрошення (типово — поточне)
--script код для bash замість вигляду
apply НАЗВА|ФАЙЛ застосувати для себе (~/.config/das/prompt.sh)
--system для всіх користувачів (/etc/das/prompt.sh, через sudo)
reset [--system] повернути стандартне запрошення
Нове запрошення діє в нових вікнах терміналу або після exec bash.""",
"""Usage: dprompt [command]
(no command) prompt editor with a live preview
list ready-made prompts
show [NAME|FILE] what the prompt looks like (default: the current one)
--script the bash code instead
apply NAME|FILE use it yourself (~/.config/das/prompt.sh)
--system for every user (/etc/das/prompt.sh, through sudo)
reset [--system] back to the stock prompt
A new prompt works in new terminal windows or after exec bash.""")
def main(argv=None):
argv = sys.argv[1:] if argv is None else argv
if not argv:
from . import prompt_app
return run_tui(PROG, prompt_app.PromptApp)
cmd, args = argv[0], argv[1:]
system = "--system" in args
args = [a for a in args if a != "--system"]
try:
if cmd in ("-h", "--help", "help"):
print(USAGE)
elif cmd == "list":
for name, title in prompt.list_presets():
print(f"{name:<12} {title}")
elif cmd == "show":
script = "--script" in args
args = [a for a in args if a != "--script"]
design = prompt.load(args[0]) if args else current()[0]
if script:
sys.stdout.write(prompt.script(design))
else:
for state in (prompt.sample_state(), prompt.sample_state(rc=1, git="main*", venv=".venv"),
prompt.sample_state(root=True)):
print(_ansi(design, state))
print()
elif cmd == "apply":
if not args:
die(PROG, "вкажіть назву або файл", "name a preset or a file")
spec = resolve(args[0])
if system:
need_root(["apply", spec, "--system"])
path = apply(prompt.load(spec), system)
print(t(f"Запрошення записано в {path}. Відкрийте новий термінал або виконайте: exec bash",
f"The prompt is written to {path}. Open a new terminal or run: exec bash"))
elif cmd == "reset":
if system:
need_root(["reset", "--system"])
reset(system)
print(t("Повернуто стандартне запрошення (діє в новому терміналі).",
"The stock prompt is back (in new terminals)."))
else:
die(PROG, f"невідома команда: {cmd}\n{USAGE}", f"unknown command: {cmd}\n{USAGE}", 2)
except FileNotFoundError as e:
die(PROG, f"не знайдено: {e.filename or e}", f"not found: {e.filename or e}")
except (ValueError, json.JSONDecodeError) as e:
die(PROG, f"помилка у файлі: {e}", f"bad file: {e}")
except PermissionError as e:
die(PROG, f"немає доступу: {e.filename}", f"permission denied: {e.filename}")
return 0
def _ansi(design, state):
from . import colors
out = []
for text, style in prompt.preview(design, state):
if style:
words = style.split()
out.append(colors.sgr(words[-1] if words[-1] != "default" else None, None, "bold" in words))
else:
out.append(colors.RESET)
out.append(text)
return "".join(out) + colors.RESET
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,53 @@
"""Terminal colors shared by the prompt and the banner: 16 ANSI names or #rrggbb."""
import re
BASIC = ["black", "red", "green", "yellow", "blue", "magenta", "cyan", "white"]
NAMES = BASIC + ["bright_" + c for c in BASIC]
_HEX = re.compile(r"^#[0-9a-fA-F]{6}$")
def valid(color):
return color is None or color in NAMES or bool(_HEX.match(str(color)))
def _code(color, background):
if color in BASIC:
return str((40 if background else 30) + BASIC.index(color))
if color in NAMES:
return str((100 if background else 90) + BASIC.index(color[7:]))
if color and _HEX.match(color):
r, g, b = (int(color[i:i + 2], 16) for i in (1, 3, 5))
return f"{48 if background else 38};2;{r};{g};{b}"
return None
def sgr_params(fg=None, bg=None, bold=False):
"""SGR parameters ("0;1;36") that reset the style first, then set it."""
params = ["0"]
if bold:
params.append("1")
for color, background in ((fg, False), (bg, True)):
code = _code(color, background)
if code:
params.append(code)
return ";".join(params)
def sgr(fg=None, bg=None, bold=False):
return f"\033[{sgr_params(fg, bg, bold)}m"
RESET = "\033[0m"
def rich_style(fg=None, bg=None, bold=False):
"""The same style as a Rich style string ("bold bright_cyan on blue")."""
parts = []
if bold:
parts.append("bold")
parts.append(fg or "default")
if bg:
parts += ["on", bg]
return " ".join(parts)
@@ -0,0 +1,26 @@
"""Ukrainian or English messages, chosen like the shell helpers do (LC_ALL, LC_MESSAGES, LANG)."""
import os
def _ukrainian():
for var in ("LC_ALL", "LC_MESSAGES", "LANG"):
value = os.environ.get(var)
if value:
return value.startswith("uk")
return False
UK = _ukrainian()
def t(uk, en):
"""The message in the user's language."""
return uk if UK else en
def title(obj, default=""):
"""A {"uk": ..., "en": ...} title (or a plain string) in the user's language."""
if isinstance(obj, dict):
return obj.get("uk" if UK else "en") or obj.get("en") or default
return obj or default
@@ -0,0 +1,430 @@
"""Bash prompt model of dprompt: segments in a frame, turned into PS1 code or a preview.
A design is JSON:
{"name": "das", "frame": "das", "frame_fg": "red", "open": "[", "close": "]",
"sep": "─", "segments": [{"type": "status"}, {"type": "userhost"}, ...]}
Static segments become PS1 escapes (\\u, \\w, \\D{...}). Conditional ones (exit
status, git branch, venv, jobs, ssh, command output) are computed by a function
that the PROMPT_COMMAND hook of /usr/lib/das/prompt-lib.sh calls before each
prompt; PS1 only reads their variables, so branch names are never evaluated as code.
"""
import copy
import datetime
import json
import os
from . import DATA_DIR, colors
from .i18n import t, title
FRAMES = ("das", "plain")
# type: (Ukrainian name, English name, default options)
SEGMENTS = {
"status": ("Код помилки", "Exit status", {"fg": "white", "mode": "mark", "mark": "✗"}),
"userhost": ("Користувач@хост", "User@host",
{"fg": "bright_white", "at_fg": "yellow", "at_bold": True,
"host_fg": "cyan", "host_bold": True, "root_fg": "red", "root_bold": True}),
"user": ("Користувач", "User", {"fg": "bright_white", "root_fg": "red", "root_bold": True}),
"host": ("Хост", "Host", {"fg": "cyan", "bold": True, "full": False}),
"cwd": ("Каталог", "Directory", {"fg": "bright_green", "mode": "full", "depth": 3}),
"date": ("Дата", "Date", {"fg": "white", "format": "%Y-%m-%d"}),
"time": ("Час", "Time", {"fg": "white", "seconds": False}),
"git": ("Гілка git", "Git branch", {"fg": "bright_magenta"}),
"venv": ("Python venv", "Python venv", {"fg": "bright_blue"}),
"jobs": ("Фонові задачі", "Background jobs", {"fg": "yellow", "prefix": "jobs:"}),
"ssh": ("Ознака SSH", "SSH mark", {"fg": "bright_red", "bold": True, "text": "ssh"}),
"text": ("Текст", "Text", {"fg": None, "text": ""}),
"cmd": ("Вивід команди", "Command output", {"fg": "white", "cmd": ""}),
"newline": ("Новий рядок", "New line", {}),
"symbol": ("Символ $/#", "Symbol $/#", {"fg": "yellow", "char": ""}),
}
# Glyphs missing from the Linux console font
CONSOLE_GLYPHS = {"╼": ">", "✗": "x", "❯": ">", "➜": ">", "λ": ">", "⚙": "*", "●": "*"}
VOID_PS1 = "[\\u@\\h \\W]\\$ "
def segment_name(seg_type):
uk, en, _ = SEGMENTS[seg_type]
return t(uk, en)
def new_segment(seg_type):
seg = {"type": seg_type}
seg.update(copy.deepcopy(SEGMENTS[seg_type][2]))
return seg
def normalize(design):
"""Fills in the defaults, so older or hand-written files keep working."""
d = copy.deepcopy(design)
d.setdefault("name", "custom")
d.setdefault("frame", "das")
if d["frame"] not in FRAMES:
d["frame"] = "das"
d.setdefault("frame_fg", "red")
d.setdefault("open", "[" if d["frame"] == "das" else "")
d.setdefault("close", "]" if d["frame"] == "das" else "")
d.setdefault("sep", "─" if d["frame"] == "das" else " ")
segs = []
for seg in d.get("segments", []):
if seg.get("type") not in SEGMENTS:
continue
full = new_segment(seg["type"])
full.update(seg)
segs.append(full)
d["segments"] = segs
return d
# --- presets and files ------------------------------------------------------
def preset_dir():
return os.path.join(DATA_DIR, "prompt", "presets")
def list_presets():
"""(name, title) of the shipped presets, "das" first."""
out = []
try:
names = sorted(f[:-5] for f in os.listdir(preset_dir()) if f.endswith(".json"))
except OSError:
names = []
for name in sorted(names, key=lambda n: (n != "das", n)):
try:
out.append((name, title(load_preset(name).get("title"), name)))
except (OSError, ValueError):
continue
return out
def load_preset(name):
return load_file(os.path.join(preset_dir(), name + ".json"))
def load_file(path):
with open(path, encoding="utf-8") as f:
return normalize(json.load(f))
def load(spec):
"""A preset name or a path to a design file."""
if os.sep in spec or spec.endswith(".json"):
return load_file(spec)
return load_preset(spec)
def dumps(design):
return json.dumps(design, ensure_ascii=False, indent=2) + "\n"
# --- pieces -------------------------------------------------------------------
# Lit: literal text; Esc: a PS1 escape such as \u; Var: a variable the hook sets;
# Dyn: pieces shown only when a condition holds. Style is (fg, bold) or None.
class Lit:
def __init__(self, text, style=None):
self.text, self.style = text, style
class Esc:
def __init__(self, code, style=None):
self.code, self.style = code, style
class Var:
def __init__(self, name, style=None):
self.name, self.style = name, style
class Dyn:
def __init__(self, cond, pieces, extra=None):
self.cond, self.pieces, self.extra = cond, pieces, extra or {}
def _st(seg, prefix=""):
return (seg.get(prefix + "fg"), bool(seg.get(prefix + "bold")))
def _content(seg, index, root):
"""(pieces, condition or None, bracketed?) for one segment."""
kind = seg["type"]
style = _st(seg)
if kind == "status":
inner = [Var("rc", style)] if seg.get("mode") == "code" else [Lit(seg.get("mark") or "✗", style)]
return inner, "error", True
if kind == "userhost":
user = _st(seg, "root_") if root else style
return [Esc("\\u", user), Lit("@", _st(seg, "at_")), Esc("\\h", _st(seg, "host_"))], None, True
if kind == "user":
return [Esc("\\u", _st(seg, "root_") if root else style)], None, True
if kind == "host":
return [Esc("\\H" if seg.get("full") else "\\h", style)], None, True
if kind == "cwd":
return [Esc("\\W" if seg.get("mode") == "base" else "\\w", style)], None, True
if kind == "date":
return [Esc("\\D{%s}" % (seg.get("format") or "%Y-%m-%d"), style)], None, True
if kind == "time":
return [Esc("\\t" if seg.get("seconds") else "\\A", style)], None, True
if kind == "git":
return [Var("git", style)], "git", True
if kind == "venv":
return [Var("venv", style)], "venv", True
if kind == "jobs":
return [Lit(seg.get("prefix") or "", style), Var("jobs", style)], "jobs", True
if kind == "ssh":
return [Lit(seg.get("text") or "ssh", style)], "ssh", True
if kind == "text":
return [Lit(seg.get("text") or "", style)], None, bool(seg.get("bracket", False))
if kind == "cmd":
return [Var(f"cmd{index}", style)], f"cmd{index}", True
if kind == "symbol":
char = seg.get("char") or ""
return [Lit(char, style) if char else Esc("\\$", style)], None, False
raise ValueError(kind)
def pieces(design, root=False):
"""The prompt as a flat list of pieces."""
d = normalize(design)
frame = (d["frame_fg"], False)
lines = [[]]
for i, seg in enumerate(d["segments"]):
if seg["type"] == "newline":
lines.append([])
else:
lines[-1].append((i, seg))
out = []
for n, items in enumerate(lines):
if n:
out.append(Lit("\n"))
if d["frame"] == "das" and len(lines) > 1:
prefix = "┌─" if n == 0 else ("└─╼ " if n == len(lines) - 1 else "├─")
out.append(Lit(prefix, frame))
first_static = next((k for k, (i, s) in enumerate(items)
if _content(s, i, root)[1] is None), len(items))
for k, (i, seg) in enumerate(items):
content, cond, bracket = _content(seg, i, root)
is_symbol = seg["type"] == "symbol"
group = []
if bracket and d["open"]:
group.append(Lit(d["open"], frame))
group += content
if bracket and d["close"]:
group.append(Lit(d["close"], frame))
sep = Lit(" " if is_symbol else d["sep"], frame) if d["sep"] else None
if cond is not None:
# A hidden segment must take its separator with it
if k < first_static:
group = group + ([sep] if sep and k + 1 < len(items) else [])
elif sep:
group = [sep] + group
out.append(Dyn(cond, group, {"cmd": seg.get("cmd", "")}))
else:
if k > first_static and sep:
out.append(sep)
out += group
out.append(Lit(" "))
return _merge(out)
def _merge(items):
"""Joins neighbouring literals of the same style."""
out = []
for p in items:
if isinstance(p, Lit) and out and isinstance(out[-1], Lit) and out[-1].style == p.style:
out[-1] = Lit(out[-1].text + p.text, p.style)
else:
out.append(p)
return out
# --- bash code ----------------------------------------------------------------
def _ps1_text(text):
return text.replace("\\", "\\\\").replace("$", "\\044").replace("`", "\\140")
def _sq(text):
return "'" + text.replace("'", "'\\''") + "'"
def _ansi_q(text):
return text.replace("\\", "\\\\").replace("'", "\\'")
class _Bash:
def __init__(self, color, console):
self.color, self.console = color, console
def glyphs(self, text):
if self.console:
for a, b in CONSOLE_GLYPHS.items():
text = text.replace(a, b)
return text
def ps1(self, items):
out, current = [], None
for p in items:
style = getattr(p, "style", None)
if self.color and not isinstance(p, Dyn) and style is not None and style != current:
out.append("\\[\\e[%sm\\]" % colors.sgr_params(style[0], None, style[1]))
current = style
if isinstance(p, Lit):
out.append(_ps1_text(self.glyphs(p.text)))
if p.text.endswith("\n"):
# Readline redraws only the last line of the prompt, so
# each line has to set its color again
current = None
elif isinstance(p, Esc):
out.append(p.code)
elif isinstance(p, Dyn):
out.append("${__das_p_%s}" % p.cond)
current = None # the dynamic part leaves its own color behind
if self.color:
out.append("\\[\\e[0m\\]")
return "".join(out)
def value(self, items):
"""A bash word with the dynamic part: $'...' literals and "$var"s."""
out, current = [], None
for p in items:
style = getattr(p, "style", None)
if self.color and style is not None and style != current:
out.append("$'\\001\\e[%sm\\002'" % colors.sgr_params(style[0], None, style[1]))
current = style
if isinstance(p, Lit):
out.append("$'%s'" % _ansi_q(self.glyphs(p.text)))
elif isinstance(p, Var):
out.append('"$__das_%s"' % p.name)
return "".join(out) or "''"
def dynamic(self, items):
lines = []
for p in items:
if not isinstance(p, Dyn):
continue
var, val = "__das_p_" + p.cond, self.value(p.pieces)
test = {
"error": "[[ $__das_rc != 0 ]]",
"git": "__das_git_info",
"venv": "[[ -n ${VIRTUAL_ENV:-} ]] && __das_venv=${VIRTUAL_ENV##*/}",
"jobs": "__das_jobs_count",
"ssh": "[[ -n ${SSH_CONNECTION:-} ]]",
}.get(p.cond)
if test is None: # command output
lines.append(f" __das_{p.cond}=$({{ {p.extra['cmd']}; }} 2>/dev/null)")
test = f"[[ -n $__das_{p.cond} ]]"
lines.append(f" {var}=")
lines.append(f" {test} && {var}={val}")
return lines
def script(design):
"""The prompt.sh that /etc/bash/bashrc.d/das-prompt.sh sources."""
d = normalize(design)
out = [
f"# Das OS bash prompt \"{d['name']}\", made by dprompt: run dprompt to change it.",
"# shellcheck shell=bash disable=SC2016",
]
cwd = next((s for s in d["segments"] if s["type"] == "cwd"), None)
if cwd and cwd.get("mode") == "short":
out.append(f"PROMPT_DIRTRIM={int(cwd.get('depth') or 3)}")
else:
out.append("unset PROMPT_DIRTRIM")
if any(s["type"] == "venv" for s in d["segments"]):
out.append("VIRTUAL_ENV_DISABLE_PROMPT=1")
variants = [("[[ $TERM == linux ]]", True, True),
("__das_has_color", True, False),
(None, False, False)]
for n, (test, color, console) in enumerate(variants):
b = _Bash(color, console)
user, root = pieces(d, False), pieces(d, True)
head = "if" if n == 0 else ("elif" if test else "else")
out.append(f"{head} {test}; then" if test else "else")
body = b.dynamic(user)
out.append(" __das_prompt_dynamic() {")
out += [" " + line for line in body] or [" :"]
out.append(" }")
out.append(" if ((EUID == 0)); then")
out.append(" PS1=" + _sq(b.ps1(root)))
out.append(" else")
out.append(" PS1=" + _sq(b.ps1(user)))
out.append(" fi")
out.append("fi")
out.append("case $TERM in xterm* | rxvt* | alacritty | foot*) PS1='\\[\\e]0;\\u@\\h: \\w\\a\\]'$PS1 ;; esac")
out.append("__das_prompt_install")
return "\n".join(out) + "\n"
# --- preview ----------------------------------------------------------------------
def sample_state(**over):
now = datetime.datetime.now()
state = {"user": "das", "host": "dasos", "fqdn": "dasos.lan", "home": "/home/das",
"cwd": "/home/das/Projects/web", "rc": 0, "git": "", "venv": "", "jobs": 0,
"ssh": False, "root": False, "cmd": "…", "now": now}
state.update(over)
if state["root"]:
state["user"] = "root"
return state
def _esc_value(code, s):
if code == "\\u":
return s["user"]
if code == "\\h":
return s["host"]
if code == "\\H":
return s["fqdn"]
if code in ("\\w", "\\W"):
home = "/root" if s["root"] else s["home"]
cwd = s["cwd"] if not s["root"] else "/etc/sv"
short = "~" + cwd[len(home):] if cwd.startswith(home) else cwd
return short if code == "\\w" else (os.path.basename(cwd) or "/")
if code.startswith("\\D{"):
return s["now"].strftime(code[3:-1])
if code == "\\A":
return s["now"].strftime("%H:%M")
if code == "\\t":
return s["now"].strftime("%H:%M:%S")
if code == "\\$":
return "#" if s["root"] else "$"
return code
def _cond_holds(cond, s):
return {"error": s["rc"] != 0, "git": bool(s["git"]), "venv": bool(s["venv"]),
"jobs": s["jobs"] > 0, "ssh": s["ssh"]}.get(cond, bool(s["cmd"]))
def _var_value(name, s):
return {"rc": str(s["rc"]), "git": s["git"], "venv": s["venv"],
"jobs": str(s["jobs"])}.get(name, s["cmd"])
def preview(design, state):
"""[(text, rich style or "")] of the prompt as the user would see it."""
out = []
def walk(items):
for p in items:
style = colors.rich_style(p.style[0], None, p.style[1]) if getattr(p, "style", None) else ""
if isinstance(p, Lit):
out.append((p.text, style))
elif isinstance(p, Esc):
out.append((_esc_value(p.code, state), style))
elif isinstance(p, Var):
out.append((_var_value(p.name, state), style))
elif isinstance(p, Dyn) and _cond_holds(p.cond, state):
walk(p.pieces)
walk(pieces(design, state["root"]))
return out
def preview_text(design, state):
return "".join(text for text, _ in preview(design, state))
@@ -0,0 +1,294 @@
"""dprompt editor: segments on the left, their settings on the right, a live preview below."""
import asyncio
import copy
import os
import tempfile
from rich.text import Text
from textual.app import App
from textual.containers import Horizontal, Vertical, VerticalScroll
from textual.widgets import Button, Checkbox, Footer, Header, Input, Label, ListItem, ListView, Select, Static
from . import prompt
from .cli_prompt import apply as apply_design, current
from .i18n import t
from .tui_common import NONE, AskDialog, color_options, color_value, console_mode, run_as_root, to_select
# Settings of each segment type: (key, kind, Ukrainian label, English label)
COLOR_KEYS = [("fg", "color", "Колір", "Color"), ("bold", "bool", "Жирний", "Bold")]
OPTIONS = {
"status": COLOR_KEYS + [("mode", "choice:mark=знак ✗|mark=mark ✗;code=код виходу|code=exit code", "Показ", "Shows"),
("mark", "text", "Знак", "Mark")],
"userhost": [("fg", "color", "Колір користувача", "User color"), ("bold", "bool", "Жирний", "Bold"),
("at_fg", "color", "Колір @", "@ color"), ("at_bold", "bool", "@ жирне", "Bold @"),
("host_fg", "color", "Колір хоста", "Host color"), ("host_bold", "bool", "Хост жирний", "Bold host"),
("root_fg", "color", "Колір для root", "Color for root"), ("root_bold", "bool", "root жирний", "Bold root")],
"user": COLOR_KEYS + [("root_fg", "color", "Колір для root", "Color for root"),
("root_bold", "bool", "root жирний", "Bold root")],
"host": COLOR_KEYS + [("full", "bool", "Повне ім'я (\\H)", "Full name (\\H)")],
"cwd": COLOR_KEYS + [("mode", "choice:full=повний шлях|full=full path;base=лише остання тека|base=last folder only;"
"short=останні N тек|short=last N folders", "Показ", "Shows"),
("depth", "text", "N для «останні N тек»", "N for \"last N folders\"")],
"date": COLOR_KEYS + [("format", "text", "Формат (strftime)", "Format (strftime)")],
"time": COLOR_KEYS + [("seconds", "bool", "Із секундами", "With seconds")],
"git": COLOR_KEYS,
"venv": COLOR_KEYS,
"jobs": COLOR_KEYS + [("prefix", "text", "Підпис перед числом", "Label before the number")],
"ssh": COLOR_KEYS + [("text", "text", "Текст", "Text")],
"text": COLOR_KEYS + [("text", "text", "Текст", "Text"), ("bracket", "bool", "У дужках", "In brackets")],
"cmd": COLOR_KEYS + [("cmd", "text", "Команда (виконується перед кожним запрошенням)",
"Command (runs before every prompt)")],
"newline": [],
"symbol": COLOR_KEYS + [("char", "text", "Символ (порожньо — $ або # для root)", "Symbol (empty: $, or # for root)")],
}
STATES = [
(("Звичайне", "Normal"), {}),
(("Після помилки, git, venv, задачі, SSH", "After an error, git, venv, jobs, SSH"),
{"rc": 1, "git": "main*", "venv": ".venv", "jobs": 1, "ssh": True, "cmd": "42"}),
(("root", "root"), {"root": True}),
]
def _choices(kind):
out = []
for item in kind[7:].split(";"):
uk, en = item.split("|")
value, uk_label = uk.split("=")
out.append((t(uk_label, en.split("=")[1]), value))
return out
class PromptApp(App):
TITLE = "dprompt"
SUB_TITLE = t("конструктор запрошення bash", "bash prompt builder")
CSS = """
#main { height: 1fr; }
#left { width: 40; border-right: solid $primary; padding: 0 1; }
#segments { height: 1fr; }
#props { padding: 0 2; }
#props Label { margin-top: 1; color: $text-muted; }
#preview { height: auto; max-height: 14; border-top: solid $primary; padding: 0 1; }
.buttons { height: auto; }
.buttons Button { min-width: 5; margin-right: 1; }
.h { text-style: bold; color: $accent; margin-top: 1; }
"""
BINDINGS = [
("ctrl+s", "apply_me", t("Застосувати для себе", "Apply for me")),
("ctrl+g", "apply_all", t("Для всіх (sudo)", "For everyone (sudo)")),
("ctrl+e", "export", t("Зберегти файл", "Save file")),
("ctrl+q", "quit", t("Вийти", "Quit")),
]
def __init__(self, design=None):
# The terminal's own 16 colors, as the prompt will use them
console_mode()
super().__init__(ansi_color=True)
self.design, self.source = (design, None) if design else current()
self.design = prompt.normalize(self.design)
self.props_lock = asyncio.Lock()
# --- layout ------------------------------------------------------------
def compose(self):
yield Header()
with Horizontal(id="main"):
with Vertical(id="left"):
yield Label(Text(t("Готові запрошення", "Ready-made prompts")), classes="h")
yield Select([(title, name) for name, title in prompt.list_presets()],
prompt=t("вибрати…", "choose…"), id="preset")
yield Label(Text(t("Сегменти (по порядку)", "Segments (in order)")), classes="h")
yield ListView(id="segments")
yield Select([(prompt.segment_name(k), k) for k in prompt.SEGMENTS],
prompt=t("+ додати сегмент", "+ add a segment"), id="add")
with Horizontal(classes="buttons"):
yield Button("↑", id="up")
yield Button("↓", id="down")
yield Button(t("Видалити", "Remove"), id="remove", variant="error")
yield VerticalScroll(id="props")
yield Static(id="preview")
yield Footer()
def on_mount(self):
self.refresh_segments(0)
# --- segments list -------------------------------------------------------
def refresh_segments(self, index=None):
lv = self.query_one("#segments", ListView)
lv.clear()
lv.append(ListItem(Label(Text("⚙ " + t("Рамка й кольори", "Frame and colors"), style="bold"))))
for seg in self.design["segments"]:
extra = seg.get("text") or seg.get("cmd") or seg.get("char") or ""
lv.append(ListItem(Label(Text(prompt.segment_name(seg["type"]) + (f" «{extra}»" if extra else "")))))
if index is not None:
lv.index = index
self.call_after_refresh(self.show_props)
self.update_preview()
def selected(self):
"""Index of the chosen segment, or None for the frame row."""
i = self.query_one("#segments", ListView).index
return None if not i else i - 1
async def on_list_view_highlighted(self, event):
await self.show_props()
# --- properties ----------------------------------------------------------
async def show_props(self):
async with self.props_lock:
await self._show_props()
async def _show_props(self):
box = self.query_one("#props", VerticalScroll)
await box.remove_children()
i = self.selected()
widgets = []
if i is None:
d = self.design
widgets += [Label(Text(t("Вигляд рамки", "Frame"))),
Select([(t("Das: ┌─[…]─[…] / └─╼", "Das: ┌─[…]─[…] / └─╼"), "das"),
(t("Простий рядок", "Plain line"), "plain")],
value=d["frame"], allow_blank=False, id="p_frame"),
Label(Text(t("Колір рамки й дужок", "Frame and bracket color"))),
Select(color_options(), value=to_select(d["frame_fg"]), allow_blank=False, id="p_frame_fg"),
Label(Text(t("Ліва дужка", "Left bracket"))), Input(d["open"], id="p_open"),
Label(Text(t("Права дужка", "Right bracket"))), Input(d["close"], id="p_close"),
Label(Text(t("Між сегментами", "Between segments"))), Input(d["sep"], id="p_sep")]
else:
seg = self.design["segments"][i]
widgets.append(Label(Text(prompt.segment_name(seg["type"]), style="bold")))
if not OPTIONS[seg["type"]]:
widgets.append(Label(Text(t("Без налаштувань.", "Nothing to set."))))
for key, kind, uk, en in OPTIONS[seg["type"]]:
wid = "s_" + key
if kind == "bool":
widgets.append(Checkbox(t(uk, en), bool(seg.get(key)), id=wid))
continue
widgets.append(Label(Text(t(uk, en))))
if kind == "color":
widgets.append(Select(color_options(), value=to_select(seg.get(key)), allow_blank=False, id=wid))
elif kind.startswith("choice:"):
widgets.append(Select(_choices(kind), value=seg.get(key), allow_blank=False, id=wid))
else:
widgets.append(Input(str(seg.get(key) if seg.get(key) is not None else ""), id=wid))
for w in widgets:
# Events of these widgets belong to this segment, whatever is selected later
w.das_target = i
await box.mount(*widgets)
def _set(self, control, value):
if not hasattr(control, "das_target") or not control.is_attached:
return
i, wid = control.das_target, control.id
key = wid[2:]
if wid.startswith("p_"):
if key == "frame" and value != self.design["frame"]:
# Sensible brackets for the new frame
self.design.update({"das": {"open": "[", "close": "]", "sep": "─"},
"plain": {"open": "", "close": "", "sep": " "}}[value])
self.design["frame"] = value
self.call_later(self.show_props)
else:
self.design[key] = value
elif i is not None and i < len(self.design["segments"]):
seg = self.design["segments"][i]
if key == "depth":
value = int(value) if str(value).isdigit() else 3
seg[key] = value
if key in ("text", "cmd", "char"):
self.refresh_label(i)
self.update_preview()
def refresh_label(self, i):
seg = self.design["segments"][i]
item = self.query_one("#segments", ListView).children[i + 1]
extra = seg.get("text") or seg.get("cmd") or seg.get("char") or ""
item.query_one(Label).update(Text(prompt.segment_name(seg["type"]) + (f" «{extra}»" if extra else "")))
def on_select_changed(self, event: Select.Changed):
wid = event.select.id or ""
if wid == "preset":
if event.value is not Select.NULL:
self.design = prompt.load_preset(event.value)
self.refresh_segments(0)
elif wid == "add":
if event.value is not Select.NULL:
i = self.selected()
pos = len(self.design["segments"]) if i is None else i + 1
self.design["segments"].insert(pos, prompt.new_segment(event.value))
event.select.clear()
self.refresh_segments(pos + 1)
elif wid.startswith(("p_", "s_")):
value = event.value
if wid.endswith("fg"):
value = color_value(value)
self._set(event.select, value)
def on_checkbox_changed(self, event: Checkbox.Changed):
self._set(event.checkbox, event.value)
def on_input_changed(self, event: Input.Changed):
if (event.input.id or "").startswith(("p_", "s_")):
self._set(event.input, event.value)
def on_button_pressed(self, event: Button.Pressed):
i = self.selected()
segs = self.design["segments"]
if i is None:
return
if event.button.id == "remove":
del segs[i]
self.refresh_segments(min(i + 1, len(segs)))
elif event.button.id == "up" and i > 0:
segs[i - 1], segs[i] = segs[i], segs[i - 1]
self.refresh_segments(i)
elif event.button.id == "down" and i + 1 < len(segs):
segs[i + 1], segs[i] = segs[i], segs[i + 1]
self.refresh_segments(i + 2)
# --- preview ---------------------------------------------------------------
def update_preview(self):
out = Text()
for n, ((uk, en), over) in enumerate(STATES):
if n:
out.append("\n")
out.append(t(uk, en) + ":\n", style="dim italic")
for text, style in prompt.preview(self.design, prompt.sample_state(**over)):
out.append(text, style=style or None)
out.append("ls", style="default")
self.query_one("#preview", Static).update(out)
# --- actions -----------------------------------------------------------------
def action_apply_me(self):
path = apply_design(self.design, system=False)
self.notify(t(f"Записано в {path}. Відкрийте новий термінал або exec bash.",
f"Written to {path}. Open a new terminal or run exec bash."), timeout=6)
def action_apply_all(self):
fd, path = tempfile.mkstemp(prefix="dprompt-", suffix=".json")
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(prompt.dumps(self.design))
os.chmod(path, 0o644)
ok = run_as_root(self, ["dprompt", "apply", path, "--system"])
os.remove(path)
if ok:
self.notify(t("Запрошення для всіх записано в /etc/das/prompt.sh.",
"The prompt for everyone is written to /etc/das/prompt.sh."), timeout=6)
def action_export(self):
def done(result):
if not result:
return
path = os.path.expanduser(result["path"])
try:
with open(path, "w", encoding="utf-8") as f:
f.write(prompt.dumps(dict(copy.deepcopy(self.design), name=os.path.basename(path)[:-5])))
except OSError as e:
self.notify(str(e), severity="error")
return
self.notify(t(f"Збережено: {path}", f"Saved: {path}"))
self.push_screen(AskDialog(t("Зберегти запрошення у файл", "Save the prompt to a file"),
[("path", t("Файл (.json)", "File (.json)"), "~/my-prompt.json")]), done)
__all__ = ["PromptApp", "NONE"]
@@ -0,0 +1,187 @@
"""Textual pieces shared by the dprompt and dbanner editors."""
import os
import subprocess
import sys
from rich.style import Style
from rich.text import Text
from textual import events
from textual.containers import Horizontal, Vertical
from textual.message import Message
from textual.screen import ModalScreen
from textual.widget import Widget
from textual.widgets import Button, Input, Label, Select
from . import colors
from .i18n import t
from .prompt import CONSOLE_GLYPHS as PROMPT_CONSOLE_GLYPHS
NONE = "-" # Select has no None value
# The Linux console's fonts hold at most 512 glyphs (cyr-sun16, Terminus: none has
# the eighth blocks ▊▎▔▁ Textual draws buttons, inputs and lists with), so the
# missing ones come out as squares. There we draw with what the fonts do have.
CONSOLE = os.environ.get("TERM") == "linux"
CONSOLE_BORDERS = ("tall", "wide", "panel", "tab", "hkey", "vkey", "round", "heavy", "dashed", "inner", "outer")
CONSOLE_GLYPHS = str.maketrans({
**PROMPT_CONSOLE_GLYPHS,
"▁": "▄", "▂": "▄", "▃": "▄", "▅": "▄", "▆": "▄", "▇": "█", "▔": "▀", "▉": "█",
"▊": "▌", "▋": "▌", "▍": "▌", "▎": "▌", "▏": "▌", "▕": "▐",
"━": "─", "╸": "─", "╺": "─", "┃": "│", "┏": "┌", "┓": "┐", "┗": "└", "┛": "┘",
"╭": "┌", "╮": "┐", "╰": "└", "╯": "┘", "╱": "/", "╲": "\\",
"—": "-", "…": ".", "×": "x", "✕": "x", "✓": "v", "☑": "x", "○": "o", "⭘": "o", "◢": "▀",
})
def console_mode():
"""On the Linux console: swaps the borders and glyphs its fonts lack. Call
before the app starts."""
if not CONSOLE:
return
from textual import _border
from textual.drivers.linux_driver import LinuxDriver
for kind in CONSOLE_BORDERS:
_border.BORDER_CHARS[kind] = _border.BORDER_CHARS["solid"]
_border.BORDER_LOCATIONS[kind] = _border.BORDER_LOCATIONS["solid"]
_border.get_box.cache_clear()
write = LinuxDriver.write
LinuxDriver.write = lambda self, data: write(self, data.translate(CONSOLE_GLYPHS))
def color_options(allow_none=True):
names = {
"black": ("чорний", "black"), "red": ("червоний", "red"), "green": ("зелений", "green"),
"yellow": ("жовтий", "yellow"), "blue": ("синій", "blue"), "magenta": ("пурпуровий", "magenta"),
"cyan": ("бірюзовий", "cyan"), "white": ("білий", "white"),
}
opts = [(t("— типовий —", "— default —"), NONE)] if allow_none else []
for c in colors.NAMES:
base = c[7:] if c.startswith("bright_") else c
label = t(*names[base])
if c.startswith("bright_"):
label = t("яскравий ", "bright ") + label
opts.append((Text.assemble(("██ ", c), label), c))
return opts
def color_value(value):
return None if value in (NONE, Select.NULL, None) else value
def to_select(color):
return color if color in colors.NAMES else NONE
class Swatches(Widget):
"""A row of the 16 colors (and "none") to click; the chosen one is marked."""
DEFAULT_CSS = """
Swatches { height: 2; width: 36; }
"""
class Picked(Message):
def __init__(self, swatches, color):
super().__init__()
self.swatches, self.color = swatches, color
def __init__(self, color=None, allow_none=True, **kw):
super().__init__(**kw)
self.color, self.allow_none = color, allow_none
def _items(self):
return ([None] if self.allow_none else []) + list(colors.NAMES)
def render(self):
text = Text()
for i, c in enumerate(self._items()):
if i == 9:
text.append("\n")
mark = "▣" if c == self.color else "■"
if c is None:
text.append("∅ " if c != self.color else "▣ ", Style(bold=True))
else:
text.append(mark * 2 + " " if c == self.color else "██ ", Style(color=c))
return text
def on_click(self, event: events.Click):
items = self._items()
index = (event.y * 9) + event.x // 3
if 0 <= event.x // 3 < 9 and 0 <= index < len(items):
self.color = items[index]
self.refresh()
self.post_message(self.Picked(self, self.color))
class AskDialog(ModalScreen):
"""A few text fields with OK/Cancel; dismisses with {key: value} or None."""
DEFAULT_CSS = """
AskDialog { align: center middle; }
AskDialog > Vertical { width: 72; height: auto; border: round $accent; background: $surface; padding: 1 2; }
AskDialog Label { margin-top: 1; }
AskDialog Horizontal { height: auto; margin-top: 1; align-horizontal: right; }
AskDialog Button { margin-left: 2; }
"""
BINDINGS = [("escape", "cancel", t("Скасувати", "Cancel"))]
def __init__(self, heading, fields):
"""fields: [(key, label, value or a list of (label, value) choices, current)]"""
super().__init__()
self.heading, self.fields = heading, fields
def compose(self):
with Vertical():
yield Label(Text(self.heading, style="bold"))
for key, label, value, *rest in self.fields:
yield Label(Text(label))
if isinstance(value, list):
yield Select(value, value=rest[0] if rest else value[0][1], allow_blank=False, id="f_" + key)
else:
yield Input(str(value), id="f_" + key)
with Horizontal():
yield Button(t("Скасувати", "Cancel"), id="cancel")
yield Button("OK", variant="primary", id="ok")
def on_mount(self):
inputs = self.query(Input)
if inputs:
inputs.first().focus()
def _result(self):
return {key: self.query_one("#f_" + key).value for key, *_ in self.fields}
def on_button_pressed(self, event: Button.Pressed):
self.dismiss(self._result() if event.button.id == "ok" else None)
def on_input_submitted(self, event: Input.Submitted):
self.dismiss(self._result())
def action_cancel(self):
self.dismiss(None)
def run_as_root(app, args):
"""Runs one of our commands through sudo with the TUI suspended, so sudo can
ask for the password. Returns True on success."""
exe = "/usr/bin/" + args[0]
if os.path.exists(exe):
cmd = [exe] + args[1:]
else: # from the source tree
module = {"dprompt": "das_shell.cli_prompt", "dbanner": "das_shell.cli_banner"}[args[0]]
env = [f"{k}={os.environ[k]}" for k in ("PYTHONPATH", "DAS_SHELL_DATA", "DAS_SHELL_ETC", "DAS_SHELL_CACHE")
if k in os.environ]
cmd = ["env"] + env + [sys.executable, "-m", module] + args[1:]
if os.geteuid() != 0:
cmd = ["sudo"] + cmd
with app.suspend():
print("\n$ " + " ".join(cmd))
code = subprocess.run(cmd).returncode
if code:
input(t("Помилка. Enter — повернутися…", "Failed. Press Enter to go back…"))
return code == 0
def bar(*buttons):
row = Horizontal(*buttons, classes="buttons")
return row
@@ -0,0 +1,415 @@
"""Values for banner fields ({hostname}, {ip}, ...) and widgets: blocks of lines
from built-in functions, scripts, commands or files.
"live" widgets run at login with a short timeout; "cached" ones are run by the
das-banner service, and the login only reads their last output from CACHE_DIR.
"""
import concurrent.futures
import datetime
import hashlib
import json
import os
import socket
import subprocess
import time
from . import CACHE_DIR, DATA_DIR, colors
from .i18n import t
LIVE_TIMEOUT = 1.5
CACHED_TIMEOUT = 60
# --- field values -----------------------------------------------------------------
def _read(path):
try:
with open(path, encoding="utf-8", errors="replace") as f:
return f.read()
except OSError:
return ""
def _ips():
try:
out = subprocess.run(["ip", "-4", "-o", "addr", "show", "scope", "global"],
capture_output=True, text=True, timeout=1).stdout
ips = [line.split()[3].split("/")[0] for line in out.splitlines() if len(line.split()) > 3]
if ips:
return ips
except (OSError, subprocess.SubprocessError):
pass
try:
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
s.connect(("192.0.2.1", 9)) # no packet is sent
return [s.getsockname()[0]]
except OSError:
return []
def _os_name():
for line in _read("/etc/os-release").splitlines():
if line.startswith("PRETTY_NAME="):
return line.split("=", 1)[1].strip().strip('"')
return "Linux"
def _duration(seconds):
days, rest = divmod(int(seconds), 86400)
hours, rest = divmod(rest, 3600)
minutes = rest // 60
parts = []
if days:
parts.append(t(f"{days} дн", f"{days} d"))
if hours or days:
parts.append(t(f"{hours} год", f"{hours} h"))
if not days:
parts.append(t(f"{minutes} хв", f"{minutes} min"))
return " ".join(parts)
def _uptime():
try:
return _duration(float(_read("/proc/uptime").split()[0]))
except (IndexError, ValueError):
return "?"
def _meminfo():
info = {}
for line in _read("/proc/meminfo").splitlines():
key, _, value = line.partition(":")
try:
info[key] = int(value.split()[0]) * 1024
except (IndexError, ValueError):
pass
return info
def human(n):
for unit in ("B", "K", "M", "G", "T", "P"):
if abs(n) < 1024 or unit == "P":
return f"{n:.0f}{unit}" if unit in ("B", "K") or n >= 100 else f"{n:.1f}{unit}"
n /= 1024
return str(n)
def _mem():
m = _meminfo()
total = m.get("MemTotal", 0)
used = total - m.get("MemAvailable", 0)
return f"{human(used)}/{human(total)}"
def _cpu():
for line in _read("/proc/cpuinfo").splitlines():
if line.startswith("model name"):
return " ".join(line.split(":", 1)[1].split())
return os.uname().machine
def _users():
try:
out = subprocess.run(["who"], capture_output=True, text=True, timeout=1).stdout
return str(len({line.split()[0] for line in out.splitlines() if line.strip()}))
except (OSError, subprocess.SubprocessError):
return "?"
PROVIDERS = {
"hostname": lambda: socket.gethostname().split(".")[0],
"fqdn": socket.getfqdn,
"ip": lambda: (_ips() or ["?"])[0],
"ips": lambda: " ".join(_ips()),
"os": _os_name,
"kernel": lambda: os.uname().release,
"arch": lambda: os.uname().machine,
"uptime": _uptime,
"load": lambda: " ".join(_read("/proc/loadavg").split()[:3]),
"mem": _mem,
"cpu": _cpu,
"cores": lambda: str(os.cpu_count() or 1),
"date": lambda: datetime.datetime.now().strftime("%Y-%m-%d %H:%M"),
"users": _users,
"user": lambda: os.environ.get("USER") or os.environ.get("LOGNAME") or "?",
}
VAR_HELP = {
"hostname": ("ім'я комп'ютера", "host name"),
"fqdn": ("повне ім'я в мережі", "fully qualified name"),
"ip": ("перша IPv4-адреса", "first IPv4 address"),
"ips": ("усі IPv4-адреси", "all IPv4 addresses"),
"os": ("назва системи", "system name"),
"kernel": ("версія ядра", "kernel version"),
"arch": ("архітектура", "architecture"),
"uptime": ("час роботи", "uptime"),
"load": ("навантаження", "load average"),
"mem": ("пам'ять зайнято/всього", "memory used/total"),
"cpu": ("процесор", "processor"),
"cores": ("кількість ядер", "number of cores"),
"date": ("дата й час", "date and time"),
"users": ("користувачів у системі", "users logged in"),
"user": ("хто входить", "who logs in"),
}
class Values(dict):
"""Field values, computed only when a field asks for them."""
def __init__(self, user_vars=None):
super().__init__()
self.user_vars = dict(user_vars or {})
def get(self, key, default=None):
if key in self.user_vars:
return self.user_vars[key]
if key not in self and key in PROVIDERS:
try:
self[key] = PROVIDERS[key]()
except Exception: # a broken value must not break the login
self[key] = "?"
return super().get(key, default)
# --- built-in widgets -------------------------------------------------------------
def _bar(percent, width=10):
full = round(percent / 100 * width)
color = "green" if percent < 70 else ("yellow" if percent < 90 else "red")
return (colors.sgr(color, None, True) + "█" * full + colors.sgr("bright_black") + "░" * (width - full)
+ colors.RESET)
PSEUDO_FS = {"proc", "sysfs", "devtmpfs", "devpts", "tmpfs", "cgroup", "cgroup2", "securityfs",
"pstore", "efivarfs", "bpf", "debugfs", "tracefs", "configfs", "fusectl", "mqueue",
"hugetlbfs", "autofs", "binfmt_misc", "overlay", "squashfs", "nsfs", "ramfs", "rpc_pipefs",
"fuse.portal", "fuse.gvfsd-fuse", "iso9660", "zram"}
def _mounts():
seen, out = set(), []
for line in _read("/proc/mounts").splitlines():
parts = line.split()
if len(parts) < 3 or parts[2] in PSEUDO_FS or not parts[0].startswith("/"):
continue
mount = parts[1].replace("\\040", " ")
if parts[0] in seen or mount.startswith(("/snap", "/var/lib/docker", "/.snapshots", "/run")):
continue
seen.add(parts[0])
out.append(mount)
return out
def w_disks(args, values):
lines = [colors.sgr("white", None, True) + t("Диски:", "Disks:") + colors.RESET]
rows = []
for mount in args.get("mounts") or _mounts():
try:
st = os.statvfs(mount)
except OSError:
continue
total = st.f_blocks * st.f_frsize
if not total:
continue
used = total - st.f_bfree * st.f_frsize
rows.append((mount, total, used))
width = max([len(r[0]) for r in rows] + [1])
for mount, total, used in rows:
pct = round(used * 100 / total)
lines.append(f" {mount.ljust(width)} {human(total):>6} {_bar(pct)} {pct:3d}%")
return lines
def w_memory(args, values):
m = _meminfo()
lines = []
for label, total, free in ((t("Пам'ять", "Memory"), m.get("MemTotal", 0), m.get("MemAvailable", 0)),
("Swap", m.get("SwapTotal", 0), m.get("SwapFree", 0))):
if not total:
continue
pct = round((total - free) * 100 / total)
lines.append(f"{colors.sgr('white', None, True)}{label + ':':<9}{colors.RESET} "
f"{human(total - free):>6}/{human(total):<6} {_bar(pct)} {pct:3d}%")
return lines
def w_uptime(args, values):
return [f"{colors.sgr('white', None, True)}{t('Працює:', 'Uptime:')}{colors.RESET} "
f"{colors.sgr('red', None, True)}{values.get('uptime')}{colors.RESET}"]
def w_load(args, values):
return [f"{colors.sgr('white', None, True)}{t('Навантаження:', 'Load:')}{colors.RESET} "
f"{values.get('load')} ({t('ядер', 'cores')}: {values.get('cores')})"]
def w_network(args, values):
lines = [colors.sgr("white", None, True) + t("Мережа:", "Network:") + colors.RESET]
try:
out = subprocess.run(["ip", "-o", "addr", "show", "scope", "global"],
capture_output=True, text=True, timeout=1).stdout
except (OSError, subprocess.SubprocessError):
return []
for line in out.splitlines():
p = line.split()
if len(p) > 3:
lines.append(f" {p[1]:<10} {colors.sgr('cyan', None, True)}{p[3]}{colors.RESET}")
return lines if len(lines) > 1 else []
BUILTINS = {
"uptime": (w_uptime, ("Час роботи", "Uptime")),
"load": (w_load, ("Навантаження", "Load average")),
"memory": (w_memory, ("Пам'ять і swap", "Memory and swap")),
"disks": (w_disks, ("Диски", "Disks")),
"network": (w_network, ("Мережа", "Network")),
}
def widget_dir():
return os.path.join(DATA_DIR, "banner", "widgets")
def script_meta(path):
"""# dbanner-<key>: value lines of a widget script."""
meta = {}
for line in _read(path).splitlines()[:20]:
if line.startswith("# dbanner-"):
key, _, value = line[10:].partition(":")
meta[key.strip()] = value.strip()
return meta
def available():
"""(name, title, mode) of every built-in widget and widget script."""
out = [(name, t(*names), "live") for name, (_, names) in BUILTINS.items()]
try:
scripts = sorted(f for f in os.listdir(widget_dir()) if f.endswith(".sh") and f != "lib.sh")
except OSError:
scripts = []
for f in scripts:
meta = script_meta(os.path.join(widget_dir(), f))
out.append((f[:-3], t(meta.get("title-uk", f[:-3]), meta.get("title-en", f[:-3])),
meta.get("mode", "live")))
return out
def describe(w):
"""A short line about a widget for lists."""
kind = w.get("type", "builtin")
if kind == "builtin":
names = {n: title_ for n, title_, _ in available()}
return names.get(w.get("name"), w.get("name", "?"))
if kind == "command":
return "$ " + w.get("cmd", "")
return t("файл ", "file ") + w.get("path", "")
def mode_of(w):
if w.get("mode"):
return w["mode"]
if w.get("type", "builtin") == "builtin" and w.get("name") not in BUILTINS:
return script_meta(os.path.join(widget_dir(), w.get("name", "") + ".sh")).get("mode", "live")
return "live"
def interval_of(w):
if w.get("interval"):
return int(w["interval"])
if w.get("type", "builtin") == "builtin":
meta = script_meta(os.path.join(widget_dir(), w.get("name", "") + ".sh"))
if meta.get("interval", "").isdigit():
return int(meta["interval"])
return 600
def cache_path(w):
key = hashlib.sha1(json.dumps(w, sort_keys=True).encode()).hexdigest()[:16]
return os.path.join(CACHE_DIR, key + ".txt")
def _env(design_vars):
env = dict(os.environ)
for key, value in (design_vars or {}).items():
env["DBANNER_" + key.upper()] = str(value)
env["DBANNER_LANG"] = "uk" if t("uk", "en") == "uk" else "en"
return env
def run(w, values, design_vars=None, timeout=None):
"""Runs a widget now and returns its lines (empty when it fails)."""
kind = w.get("type", "builtin")
try:
if kind == "file":
text = _read(os.path.expanduser(w.get("path", "")))
lines = text.rstrip("\n").split("\n") if text.strip() else []
elif kind == "builtin" and w.get("name") in BUILTINS:
lines = BUILTINS[w["name"]][0](w.get("args") or {}, values)
else:
if kind == "builtin":
cmd = [os.path.join(widget_dir(), w.get("name", "") + ".sh")]
else:
cmd = ["sh", "-c", w.get("cmd", "")]
limit = timeout or float(w.get("timeout") or (CACHED_TIMEOUT if mode_of(w) == "cached" else LIVE_TIMEOUT))
proc = subprocess.run(cmd, capture_output=True, text=True, errors="replace",
timeout=limit, env=_env(design_vars), stdin=subprocess.DEVNULL)
out = proc.stdout.rstrip("\n")
lines = out.split("\n") if out.strip() else []
except Exception: # a broken widget must not break the login
return []
if lines and w.get("title"):
lines = [colors.sgr("white", None, True) + w["title"] + colors.RESET] + lines
return lines
def read_cache(w):
text = _read(cache_path(w))
return text.rstrip("\n").split("\n") if text.strip() else []
def blocks(design, values, use_cache=True):
"""Lines of every widget of a design, in order; live ones run in parallel."""
widgets = design.get("widgets", [])
result = [[] for _ in widgets]
jobs = {}
# No "with": leaving it would wait for a widget that hangs
pool = concurrent.futures.ThreadPoolExecutor(max_workers=8)
for i, w in enumerate(widgets):
if w.get("enabled") is False:
continue
if use_cache and mode_of(w) == "cached":
result[i] = read_cache(w)
else:
jobs[pool.submit(run, w, values, design.get("vars"))] = i
try:
for fut in concurrent.futures.as_completed(jobs, timeout=LIVE_TIMEOUT + 1 if use_cache else None):
result[jobs[fut]] = fut.result()
except concurrent.futures.TimeoutError:
pass
pool.shutdown(wait=False, cancel_futures=True)
return result
def refresh(design, force=False):
"""Runs the cached widgets whose output is older than their interval."""
values = None
os.makedirs(CACHE_DIR, mode=0o755, exist_ok=True)
for w in design.get("widgets", []):
if w.get("enabled") is False or mode_of(w) != "cached":
continue
path = cache_path(w)
try:
age = time.time() - os.stat(path).st_mtime
except OSError:
age = None
if not force and age is not None and age < interval_of(w):
continue
if values is None:
values = Values(design.get("vars"))
lines = run(w, values, design.get("vars"))
tmp = path + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
f.write("\n".join(lines) + ("\n" if lines else ""))
os.chmod(tmp, 0o644)
os.replace(tmp, path)
@@ -0,0 +1,74 @@
{
"name": "das-logo-small",
"title": {
"uk": "Логотип Das OS, малий",
"en": "Das OS logo, small"
},
"styles": {
"a": {
"fg": "bright_yellow",
"bg": null,
"bold": false
},
"b": {
"fg": "bright_blue",
"bg": null,
"bold": false
},
"c": {
"fg": "bright_yellow",
"bg": "blue",
"bold": false
},
"d": {
"fg": "yellow",
"bg": "bright_blue",
"bold": false
},
"e": {
"fg": "bright_yellow",
"bg": "yellow",
"bold": false
},
"f": {
"fg": "bright_yellow",
"bg": "bright_blue",
"bold": false
},
"g": {
"fg": "bright_blue",
"bg": "bright_yellow",
"bold": false
},
"h": {
"fg": "blue",
"bg": "bright_yellow",
"bold": false
},
"i": {
"fg": "bright_blue",
"bg": "yellow",
"bold": false
}
},
"lines": [
"",
" ▄▄ ██ ▄▄",
" ▀█▀█▀▀█▀█▀",
" ▄▄▀▀▀▀▀▀▀▀▄▄",
" ▀▀▀▀▀▀▀▀▀▀▀▀",
" █▀█▀▀█▀█",
" ▀▀ ██ ▀▀",
""
],
"style_map": [
"",
" aa bb aa",
" aacaddacaa",
" bbeffggffebb",
" bbdggffggdbb",
" ahaiiaha",
" aa bb aa",
""
]
}
@@ -0,0 +1,82 @@
{
"name": "das-logo",
"title": {
"uk": "Логотип Das OS",
"en": "Das OS logo"
},
"styles": {
"a": {
"fg": "bright_blue",
"bg": null,
"bold": false
},
"b": {
"fg": "bright_yellow",
"bg": null,
"bold": false
},
"c": {
"fg": "bright_blue",
"bg": "yellow",
"bold": false
},
"d": {
"fg": "bright_yellow",
"bg": "blue",
"bold": false
},
"e": {
"fg": "yellow",
"bg": null,
"bold": false
},
"f": {
"fg": "blue",
"bg": "bright_yellow",
"bold": false
},
"g": {
"fg": "bright_yellow",
"bg": "bright_blue",
"bold": false
},
"h": {
"fg": "bright_blue",
"bg": "bright_yellow",
"bold": false
},
"i": {
"fg": "yellow",
"bg": "bright_blue",
"bold": false
}
},
"lines": [
"",
" ██",
" ▄▄ ▄▀▀▄ ▄▄",
" ▀█▀▀██████▀▀█▀",
" █▀▀██████▀▀█",
" ▄▄▄▀▀▀▀▀▀▀▀▀▀▀▀▀▀▄▄▄",
" ▀▀█▀▀▀▀▀▀▀▀▀▀▀▀▀▀█▀▀",
" █▀▀██████▀▀█",
" ▄█▀▀██████▀▀█▄",
" ▀▀ ▀▀▀▀ ▀▀",
" ██",
""
],
"style_map": [
"",
" aa",
" bb acca bb",
" bbddbaeeabddbb",
" bffbbaabbffb",
" aaacccggghhgggcccaaa",
" aaaiiihhhgghhhiiiaaa",
" bddbbaabbddb",
" bbffbaeeabffbb",
" bb aiia bb",
" aa",
""
]
}
@@ -0,0 +1,63 @@
{
"name": "trident-shield",
"title": {
"uk": "Малий герб України (тризуб на щиті)",
"en": "Lesser coat of arms of Ukraine (tryzub on a shield)"
},
"styles": {
"a": {
"fg": "blue",
"bg": null,
"bold": false
},
"b": {
"fg": "bright_yellow",
"bg": null,
"bold": false
},
"c": {
"fg": "blue",
"bg": "bright_yellow",
"bold": false
},
"d": {
"fg": "bright_yellow",
"bg": "blue",
"bold": false
}
},
"lines": [
"██████████████████████",
"██████████████████████",
"████▀████████████▀████",
"████▀█▀████████▀█▀████",
"██████████████████████",
"███████▀██████▀███████",
"██████████████████████",
"█████▀█▀█▀██▀█▀█▀█████",
"██████▀█▀████▀█▀██████",
"██████▀███▀▀███▀██████",
"████▀▀▀██▀██▀██▀▀▀████",
"███▀▀▀▀▀█▀██▀█▀▀▀▀▀███",
" ▀██████▀████▀██████▀",
" ▀▀▀████▀▀████▀▀▀",
" ▀▀██▀▀"
],
"style_map": [
"aaaaaaaaaaaaaaaaaaaaaa",
"aaaaaaaaaabbaaaaaaaaaa",
"aaabcaaaaabbaaaaacbaaa",
"aaabdbcaaabbaaacbdbaaa",
"aaabaabaaabbaaabaabaaa",
"aaabaabcaabbaacbaabaaa",
"aaabaabbaabbaabbaabaaa",
"aaabacbdacbbcadbcabaaa",
"aaabbbcacbaabcacbbbaaa",
"aaabaadbbbccbbbdaabaaa",
"aaabcccbbcbbcbbcccbaaa",
"aaadddddbdbbdbdddddaaa",
" aaaaaaadbbbbdaaaaaaa",
" aaaaaaaddaaaaaaa",
" aaaaaa"
]
}
@@ -0,0 +1,40 @@
{
"name": "trident-small",
"title": {
"uk": "Тризуб, малий",
"en": "Tryzub, small"
},
"styles": {
"a": {
"fg": "bright_yellow",
"bg": null,
"bold": false
}
},
"lines": [
"",
" ▄ ██ ▄",
" █▀▄ ██ ▄▀█",
" █ █ ██ █ █",
" █ ▄ ██ ▄ █",
" █▄▄▀ ██ ▀▄▄█",
" █▀█▄█ █▄█▀█",
" █▄ ██████ ▄█",
" ▀▀▀████▀▀▀",
" ▀██▀",
""
],
"style_map": [
"",
" a aa a",
" aaa aa aaa",
" a a aa a a",
" a a aa a a",
" aaaa aa aaaa",
" aaaaa aaaaa",
" aa aaaaaa aa",
" aaaaaaaaaa",
" aaaa",
""
]
}
@@ -0,0 +1,46 @@
{
"name": "trident",
"title": {
"uk": "Тризуб",
"en": "Tryzub"
},
"styles": {
"a": {
"fg": "bright_yellow",
"bg": null,
"bold": false
}
},
"lines": [
"",
" ██",
" █▄ ██ ▄█",
" █▀█ ██ █▀█",
" █ ██ ██ ██ █",
" █ █ ██ █ █",
" █ █ ██ █ █",
" █▄█ █▀▀█ █▄█",
" █ ▀▄██ ██▄▀ █",
" █ █ ██ █ █",
" ▀▀▀▀█▀██▀█▀▀▀▀",
" ▀████▀",
" ▀▀",
""
],
"style_map": [
"",
" aa",
" aa aa aa",
" aaa aa aaa",
" a aa aa aa a",
" a a aa a a",
" a a aa a a",
" aaa aaaa aaa",
" a aaaa aaaa a",
" a a aa a a",
" aaaaaaaaaaaaaa",
" aaaaaa",
" aa",
""
]
}
@@ -0,0 +1,248 @@
{
"name": "blocks",
"title": {
"uk": "Логотип Das OS і відомості про систему",
"en": "Das OS logo and system facts"
},
"local": false,
"vars": {
"url": ""
},
"fields": [
{
"x": 26,
"y": 0,
"width": 50,
"align": "left",
"text": "{user}@{hostname}",
"style": "j"
},
{
"x": 26,
"y": 1,
"width": 50,
"align": "left",
"text": "──────────────────────────────",
"style": "i"
},
{
"x": 26,
"y": 2,
"width": 9,
"align": "left",
"text": "[[ОС|OS]]",
"style": "k"
},
{
"x": 35,
"y": 2,
"width": 41,
"align": "left",
"text": "{os}",
"style": "l"
},
{
"x": 26,
"y": 3,
"width": 9,
"align": "left",
"text": "[[Ядро|Kernel]]",
"style": "k"
},
{
"x": 35,
"y": 3,
"width": 41,
"align": "left",
"text": "{kernel}",
"style": "l"
},
{
"x": 26,
"y": 4,
"width": 9,
"align": "left",
"text": "[[Працює|Uptime]]",
"style": "k"
},
{
"x": 35,
"y": 4,
"width": 41,
"align": "left",
"text": "{uptime}",
"style": "l"
},
{
"x": 26,
"y": 5,
"width": 9,
"align": "left",
"text": "CPU",
"style": "k"
},
{
"x": 35,
"y": 5,
"width": 41,
"align": "left",
"text": "{cpu}",
"style": "l"
},
{
"x": 26,
"y": 6,
"width": 9,
"align": "left",
"text": "[[Пам'ять|Memory]]",
"style": "k"
},
{
"x": 35,
"y": 6,
"width": 41,
"align": "left",
"text": "{mem}",
"style": "l"
},
{
"x": 26,
"y": 7,
"width": 9,
"align": "left",
"text": "IP",
"style": "k"
},
{
"x": 35,
"y": 7,
"width": 41,
"align": "left",
"text": "{ips}",
"style": "l"
},
{
"x": 26,
"y": 8,
"width": 9,
"align": "left",
"text": "[[Вхід|Users]]",
"style": "k"
},
{
"x": 35,
"y": 8,
"width": 41,
"align": "left",
"text": "{users}",
"style": "l"
},
{
"x": 26,
"y": 10,
"width": 50,
"align": "left",
"text": "{url}",
"style": "g"
}
],
"widgets_position": "below",
"widgets": [
{
"type": "builtin",
"name": "disks"
}
],
"styles": {
"a": {
"fg": "bright_yellow",
"bg": null,
"bold": false
},
"b": {
"fg": "blue",
"bg": "bright_yellow",
"bold": false
},
"c": {
"fg": "bright_blue",
"bg": "bright_yellow",
"bold": false
},
"d": {
"fg": "bright_blue",
"bg": "yellow",
"bold": false
},
"e": {
"fg": "bright_yellow",
"bg": "bright_blue",
"bold": false
},
"f": {
"fg": "yellow",
"bg": "bright_blue",
"bold": false
},
"g": {
"fg": "yellow",
"bg": null,
"bold": false
},
"h": {
"fg": "bright_yellow",
"bg": "blue",
"bold": false
},
"i": {
"fg": "bright_blue",
"bg": null,
"bold": false
},
"j": {
"fg": "bright_cyan",
"bg": null,
"bold": true
},
"k": {
"fg": "bright_blue",
"bg": null,
"bold": true
},
"l": {
"fg": "white",
"bg": null,
"bold": false
}
},
"lines": [
"",
" ██",
" ▄▄ ▄▀▀▄ ▄▄",
" ▀█▀▀██████▀▀█▀",
" █▀▀██████▀▀█",
" ▄▄▄▀▀▀▀▀▀▀▀▀▀▀▀▀▀▄▄▄",
" ▀▀█▀▀▀▀▀▀▀▀▀▀▀▀▀▀█▀▀",
" █▀▀██████▀▀█",
" ▄█▀▀██████▀▀█▄",
" ▀▀ ▀▀▀▀ ▀▀",
" ██"
],
"style_map": [
"",
" ii",
" aa iddi aa",
" aahhaiggiahhaa",
" abbaaiiaabba",
" iiidddeeecceeedddiii",
" iiifffccceecccfffiii",
" ahhaaiiaahha",
" aabbaiggiabbaa",
" aa iffi aa",
" ii"
],
"canvas": {
"width": 76,
"height": 12
}
}
@@ -0,0 +1,105 @@
{
"name": "das-classic",
"title": {
"uk": "Das класичний: рамка з #, назва, IP, посилання",
"en": "Das classic: # frame, name, IP, link"
},
"local": false,
"vars": {
"title": "Das OS",
"url": "https://das.org.ua/"
},
"fields": [
{
"x": 2,
"y": 1,
"width": 35,
"align": "center",
"text": "-=:dAs:=- {title}",
"style": "c"
},
{
"x": 18,
"y": 2,
"width": 19,
"align": "left",
"text": "{ip}",
"style": "d"
},
{
"x": 13,
"y": 3,
"width": 24,
"align": "left",
"text": "{url}",
"style": "d"
}
],
"widgets_position": "below",
"widgets": [
{
"type": "builtin",
"name": "uptime"
},
{
"type": "builtin",
"name": "disks"
},
{
"type": "builtin",
"name": "memory"
},
{
"type": "builtin",
"name": "docker"
},
{
"type": "builtin",
"name": "services"
},
{
"type": "builtin",
"name": "updates"
}
],
"styles": {
"a": {
"fg": "blue",
"bg": null,
"bold": false
},
"b": {
"fg": "red",
"bg": null,
"bold": true
},
"c": {
"fg": "white",
"bg": null,
"bold": true
},
"d": {
"fg": "cyan",
"bg": null,
"bold": true
}
},
"lines": [
"#######################################",
"# #",
"# Server IP: #",
"# Link: #",
"#######################################"
],
"style_map": [
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"a a",
"a bbbbbbbbbb a",
"a bbbbb a",
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
],
"canvas": {
"width": 39,
"height": 5
}
}
@@ -0,0 +1,103 @@
{
"name": "dashboard",
"title": {
"uk": "Панель: усі віджети під заголовком",
"en": "Dashboard: every widget under a header"
},
"local": false,
"vars": {
"url": "",
"city": "Kyiv"
},
"fields": [
{
"x": 2,
"y": 1,
"width": 66,
"align": "center",
"text": "{hostname} · {ip} · {url}",
"style": "b"
}
],
"widgets_position": "below",
"widgets": [
{
"type": "builtin",
"name": "uptime"
},
{
"type": "builtin",
"name": "load"
},
{
"type": "builtin",
"name": "memory"
},
{
"type": "builtin",
"name": "disks"
},
{
"type": "builtin",
"name": "network"
},
{
"type": "builtin",
"name": "docker"
},
{
"type": "builtin",
"name": "updates"
},
{
"type": "builtin",
"name": "weather"
},
{
"type": "builtin",
"name": "smart"
},
{
"type": "builtin",
"name": "services"
},
{
"type": "builtin",
"name": "fail2ban"
},
{
"type": "builtin",
"name": "snapshots"
},
{
"type": "builtin",
"name": "lastlog"
}
],
"styles": {
"a": {
"fg": "bright_blue",
"bg": null,
"bold": false
},
"b": {
"fg": "bright_white",
"bg": null,
"bold": true
}
},
"lines": [
"╭────────────────────────────────────────────────────────────────────╮",
"│ │",
"╰────────────────────────────────────────────────────────────────────╯"
],
"style_map": [
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"a a",
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
],
"canvas": {
"width": 70,
"height": 3
}
}
@@ -0,0 +1,130 @@
{
"name": "double",
"title": {
"uk": "Подвійна рамка з великим ім'ям сервера",
"en": "Double frame with the server name in big letters"
},
"local": false,
"vars": {
"title": "Das OS Server",
"url": ""
},
"fields": [
{
"x": 2,
"y": 1,
"width": 60,
"align": "center",
"text": "{hostname}",
"style": "c",
"font": "small"
},
{
"x": 2,
"y": 5,
"width": 60,
"align": "center",
"text": "{title}",
"style": "d"
},
{
"x": 2,
"y": 6,
"width": 60,
"align": "center",
"text": "{url}",
"style": "e"
},
{
"x": 2,
"y": 8,
"width": 60,
"align": "center",
"text": "IP {ip} · {os} · {kernel}",
"style": "f"
}
],
"widgets_position": "below",
"widgets": [
{
"type": "builtin",
"name": "uptime"
},
{
"type": "builtin",
"name": "load"
},
{
"type": "builtin",
"name": "memory"
},
{
"type": "builtin",
"name": "disks"
},
{
"type": "builtin",
"name": "services"
}
],
"styles": {
"a": {
"fg": "cyan",
"bg": null,
"bold": true
},
"b": {
"fg": "bright_black",
"bg": null,
"bold": false
},
"c": {
"fg": "bright_white",
"bg": null,
"bold": true
},
"d": {
"fg": "yellow",
"bg": null,
"bold": true
},
"e": {
"fg": "cyan",
"bg": null,
"bold": false
},
"f": {
"fg": "white",
"bg": null,
"bold": false
}
},
"lines": [
"╔══════════════════════════════════════════════════════════════╗",
"║ ║",
"║ ║",
"║ ║",
"║ ║",
"║ ║",
"║ ║",
"║──────────────────────────────────────────────────────────────║",
"║ ║",
"╚══════════════════════════════════════════════════════════════╝"
],
"style_map": [
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"a a",
"a a",
"a a",
"a a",
"a a",
"a a",
"abbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbba",
"a a",
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
],
"canvas": {
"width": 64,
"height": 10
}
}
@@ -0,0 +1,34 @@
{
"name": "minimal",
"title": {
"uk": "Мінімальний: один рядок",
"en": "Minimal: one line"
},
"local": false,
"vars": {},
"fields": [
{
"x": 0,
"y": 0,
"width": 70,
"align": "left",
"text": "{hostname} · {ip} · [[працює|up]] {uptime} · {load}",
"style": "a"
}
],
"widgets_position": "below",
"widgets": [],
"styles": {
"a": {
"fg": "bright_cyan",
"bg": null,
"bold": true
}
},
"lines": [],
"style_map": [],
"canvas": {
"width": 70,
"height": 1
}
}
@@ -0,0 +1,107 @@
{
"name": "tryzub",
"title": {
"uk": "Малий тризуб з іменем і IP",
"en": "Small tryzub with name and IP"
},
"local": false,
"vars": {
"url": ""
},
"fields": [
{
"x": 17,
"y": 2,
"width": 43,
"align": "left",
"text": "{hostname}",
"style": "b"
},
{
"x": 17,
"y": 3,
"width": 43,
"align": "left",
"text": "{ip}",
"style": "c"
},
{
"x": 17,
"y": 5,
"width": 43,
"align": "left",
"text": "[[Працює|Uptime]]: {uptime}",
"style": "d"
},
{
"x": 17,
"y": 6,
"width": 43,
"align": "left",
"text": "{url}",
"style": "e"
}
],
"widgets_position": "below",
"widgets": [
{
"type": "builtin",
"name": "disks"
}
],
"styles": {
"a": {
"fg": "bright_yellow",
"bg": null,
"bold": false
},
"b": {
"fg": "bright_yellow",
"bg": null,
"bold": true
},
"c": {
"fg": "bright_blue",
"bg": null,
"bold": true
},
"d": {
"fg": "white",
"bg": null,
"bold": false
},
"e": {
"fg": "bright_blue",
"bg": null,
"bold": false
}
},
"lines": [
"",
" ▄ ██ ▄",
" █▀▄ ██ ▄▀█",
" █ █ ██ █ █",
" █ ▄ ██ ▄ █",
" █▄▄▀ ██ ▀▄▄█",
" █▀█▄█ █▄█▀█",
" █▄ ██████ ▄█",
" ▀▀▀████▀▀▀",
" ▀██▀"
],
"style_map": [
"",
" a aa a",
" aaa aa aaa",
" a a aa a a",
" a a aa a a",
" aaaa aa aaaa",
" aaaaa aaaaa",
" aa aaaaaa aa",
" aaaaaaaaaa",
" aaaa"
],
"canvas": {
"width": 60,
"height": 11
}
}
@@ -0,0 +1,191 @@
{
"name": "ukraine",
"title": {
"uk": "Тризуб: малий герб України і дані сервера",
"en": "Tryzub: Ukrainian coat of arms and server facts"
},
"local": false,
"vars": {
"url": ""
},
"fields": [
{
"x": 26,
"y": 1,
"width": 54,
"align": "left",
"text": "{hostname}",
"style": "e",
"font": "small"
},
{
"x": 26,
"y": 6,
"width": 54,
"align": "left",
"text": "[[Слава Україні!|Glory to Ukraine!]]",
"style": "f"
},
{
"x": 26,
"y": 8,
"width": 9,
"align": "left",
"text": "IP",
"style": "f"
},
{
"x": 35,
"y": 8,
"width": 45,
"align": "left",
"text": "{ip}",
"style": "a"
},
{
"x": 26,
"y": 9,
"width": 9,
"align": "left",
"text": "[[Сайт|Site]]",
"style": "f"
},
{
"x": 35,
"y": 9,
"width": 45,
"align": "left",
"text": "{url}",
"style": "a"
},
{
"x": 26,
"y": 10,
"width": 9,
"align": "left",
"text": "[[Працює|Uptime]]",
"style": "f"
},
{
"x": 35,
"y": 10,
"width": 45,
"align": "left",
"text": "{uptime}",
"style": "a"
},
{
"x": 26,
"y": 11,
"width": 9,
"align": "left",
"text": "[[Пам'ять|Memory]]",
"style": "f"
},
{
"x": 35,
"y": 11,
"width": 45,
"align": "left",
"text": "{mem}",
"style": "a"
},
{
"x": 26,
"y": 12,
"width": 9,
"align": "left",
"text": "[[ОС|OS]]",
"style": "f"
},
{
"x": 35,
"y": 12,
"width": 45,
"align": "left",
"text": "{os}",
"style": "a"
}
],
"widgets_position": "below",
"widgets": [
{
"type": "builtin",
"name": "disks"
},
{
"type": "builtin",
"name": "services"
}
],
"styles": {
"a": {
"fg": "bright_yellow",
"bg": null,
"bold": false
},
"b": {
"fg": "blue",
"bg": "bright_yellow",
"bold": false
},
"c": {
"fg": "bright_yellow",
"bg": "blue",
"bold": false
},
"d": {
"fg": "blue",
"bg": null,
"bold": false
},
"e": {
"fg": "bright_yellow",
"bg": null,
"bold": true
},
"f": {
"fg": "bright_blue",
"bg": null,
"bold": true
}
},
"lines": [
"██████████████████████",
"██████████████████████",
"████▀████████████▀████",
"████▀█▀████████▀█▀████",
"██████████████████████",
"███████▀██████▀███████",
"██████████████████████",
"█████▀█▀█▀██▀█▀█▀█████",
"██████▀█▀████▀█▀██████",
"██████▀███▀▀███▀██████",
"████▀▀▀██▀██▀██▀▀▀████",
"███▀▀▀▀▀█▀██▀█▀▀▀▀▀███",
" ▀██████▀████▀██████▀",
" ▀▀▀████▀▀████▀▀▀",
" ▀▀██▀▀"
],
"style_map": [
"dddddddddddddddddddddd",
"ddddddddddaadddddddddd",
"dddabdddddaadddddbaddd",
"dddacabdddaadddbacaddd",
"dddaddadddaadddaddaddd",
"dddaddabddaaddbaddaddd",
"dddaddaaddaaddaaddaddd",
"dddadbacdbaabdcabdaddd",
"dddaaabdbaddabdbaaaddd",
"dddaddcaaabbaaacddaddd",
"dddabbbaabaabaabbbaddd",
"dddcccccacaacacccccddd",
" dddddddcaaaacddddddd",
" dddddddccddddddd",
" dddddd"
],
"canvas": {
"width": 80,
"height": 15
}
}
+15
View File
@@ -0,0 +1,15 @@
#!/bin/bash
# dbanner-title-uk: Контейнери Docker
# dbanner-title-en: Docker containers
# dbanner-mode: live
# Running and stopped containers; nothing when Docker is not there or not reachable.
. "${0%/*}/lib.sh"
command -v docker >/dev/null || exit 0
mapfile -t states < <(timeout 1 docker ps -a --format '{{.State}}' 2>/dev/null) || exit 0
((${#states[@]})) || exit 0
running=0
for s in "${states[@]}"; do [[ $s == running ]] && ((running++)); done
stopped=$((${#states[@]} - running))
color=$Green
((stopped)) && color=$Yellow
echo "$Bold$(say "Docker:" "Docker:")$Off $color$running$Off $(say "працює" "running"), $stopped $(say "зупинено" "stopped")"
+13
View File
@@ -0,0 +1,13 @@
#!/bin/bash
# dbanner-title-uk: Заблоковані fail2ban адреси
# dbanner-title-en: Addresses banned by fail2ban
# dbanner-mode: cached
# dbanner-interval: 300
# How many addresses fail2ban keeps banned now and in total; needs root.
. "${0%/*}/lib.sh"
command -v fail2ban-client >/dev/null || exit 0
((EUID == 0)) || exit 0
out=$(timeout 5 fail2ban-client status sshd 2>/dev/null) || exit 0
now=$(awk -F: '/Currently banned/ {gsub(/ /, "", $2); print $2}' <<<"$out")
total=$(awk -F: '/Total banned/ {gsub(/ /, "", $2); print $2}' <<<"$out")
echo "$Bold$(say "fail2ban (sshd):" "fail2ban (sshd):")$Off $Yellow${now:-0}$Off $(say "заблоковано зараз, усього" "banned now, total") ${total:-0}"
+13
View File
@@ -0,0 +1,13 @@
#!/bin/bash
# dbanner-title-uk: Попередній вхід
# dbanner-title-en: Previous login
# dbanner-mode: live
# The login before this one of the user who logs in (from last).
. "${0%/*}/lib.sh"
command -v last >/dev/null || exit 0
user=${USER:-$(id -un)}
line=$(timeout 1 last -n 2 -w --time-format iso "$user" 2>/dev/null | sed -n 2p)
[[ -n $line && $line != wtmp* ]] || exit 0
read -r _ tty from when _ <<<"$line"
[[ $from == :* || $from =~ ^tty ]] && from=$tty
echo "$Bold$(say "Попередній вхід:" "Previous login:")$Off ${when%+*} $(say "з" "from") $Cyan$from$Off"
@@ -0,0 +1,14 @@
# Shared by the dbanner widget scripts: colors and the user's language.
# shellcheck shell=bash
# shellcheck disable=SC2034
Off=$'\e[0m' Bold=$'\e[1;37m' Red=$'\e[1;31m' Green=$'\e[1;32m' Yellow=$'\e[1;33m' Cyan=$'\e[1;36m' Gray=$'\e[0;90m'
# say UKRAINIAN ENGLISH
say() {
if [[ ${DBANNER_LANG:-} == uk ]]; then echo "$1"; else echo "$2"; fi
}
# level VALUE WARN CRIT: green below WARN, yellow below CRIT, red otherwise
level() {
if (($1 < $2)); then echo -n "$Green"; elif (($1 < $3)); then echo -n "$Yellow"; else echo -n "$Red"; fi
}
+17
View File
@@ -0,0 +1,17 @@
#!/bin/bash
# dbanner-title-uk: Служби, що не працюють
# dbanner-title-en: Services that are down
# dbanner-mode: live
# Enabled runit services that are not running; nothing when all of them are up.
. "${0%/*}/lib.sh"
dir=/var/service
[[ -d $dir ]] || exit 0
down=()
for s in "$dir"/*/; do
s=${s%/}
[[ -f $s/down ]] && continue
state=$(timeout 1 sv status "$s" 2>/dev/null) || state=down
[[ $state == run:* ]] || down+=("${s##*/}")
done
((${#down[@]})) || exit 0
echo "$Bold$(say "Не працюють:" "Down:")$Off $Red${down[*]}$Off $(say "(dsv status)" "(dsv status)")"
+27
View File
@@ -0,0 +1,27 @@
#!/bin/bash
# dbanner-title-uk: Стан дисків (SMART)
# dbanner-title-en: Disk health (SMART)
# dbanner-mode: cached
# dbanner-interval: 21600
# Health, temperature and power-on hours of every disk; needs root and smartmontools.
. "${0%/*}/lib.sh"
command -v smartctl >/dev/null || exit 0
((EUID == 0)) || exit 0
first=1
for dev in /sys/block/*; do
name=${dev##*/}
[[ $name == @(loop|ram|zram|sr|dm-|md)* ]] && continue
out=$(smartctl -H -A -i "/dev/$name" 2>/dev/null) || [[ -n $out ]] || continue
model=$(sed -n 's/^\(Device Model\|Model Number\|Product\): *//p' <<<"$out" | head -1)
health=$(sed -n 's/.*\(overall-health\|Health Status\).*: *//p' <<<"$out" | head -1)
temp=$(awk '/Temperature_Celsius|Airflow_Temperature/ {print $10; exit} /^Temperature:/ {print $2; exit}' <<<"$out")
hours=$(awk '/Power_On_Hours/ {print $10; exit} /^Power On Hours:/ {gsub(",", "", $4); print $4; exit}' <<<"$out")
[[ -n $health ]] || continue
if ((first)); then echo "$Bold$(say "Стан дисків:" "Disk health:")$Off"; first=0; fi
hcolor=$Green
[[ $health == PASSED || $health == OK ]] || hcolor=$Red
line=" /dev/$name ${model:+($model) }$hcolor$health$Off"
[[ $temp =~ ^[0-9]+$ ]] && line+=", $(level "$temp" 55 70)$temp °C$Off"
[[ $hours =~ ^[0-9]+$ ]] && line+=", $Cyan$hours $(say "год" "h")$Off"
echo "$line"
done
+13
View File
@@ -0,0 +1,13 @@
#!/bin/bash
# dbanner-title-uk: Знімки Btrfs (snapper)
# dbanner-title-en: Btrfs snapshots (snapper)
# dbanner-mode: cached
# dbanner-interval: 3600
# Number of snapper snapshots of / and the date of the last one; needs root.
. "${0%/*}/lib.sh"
command -v snapper >/dev/null || exit 0
((EUID == 0)) || exit 0
mapfile -t rows < <(timeout 10 snapper -c root --csvout list --columns number,date 2>/dev/null | tail -n +3)
((${#rows[@]})) || exit 0
last=${rows[-1]#*,}
echo "$Bold$(say "Знімки:" "Snapshots:")$Off ${#rows[@]}, $(say "останній" "last") $Cyan${last%:*}$Off"
+15
View File
@@ -0,0 +1,15 @@
#!/bin/bash
# dbanner-title-uk: Доступні оновлення
# dbanner-title-en: Available updates
# dbanner-mode: cached
# dbanner-interval: 21600
# Package updates (xbps-install -Mun downloads the repository index, so it runs
# from the das-banner service, not at login).
. "${0%/*}/lib.sh"
command -v xbps-install >/dev/null || exit 0
count=$(xbps-install -Mun 2>/dev/null | wc -l) || exit 0
if ((count)); then
echo "$Bold$(say "Оновлення:" "Updates:")$Off $Yellow$count$Off $(say "пакетів — das-update" "packages — das-update")"
else
echo "$Bold$(say "Оновлення:" "Updates:")$Off $Green$(say "система оновлена" "up to date")$Off"
fi
+13
View File
@@ -0,0 +1,13 @@
#!/bin/bash
# dbanner-title-uk: Погода (wttr.in)
# dbanner-title-en: Weather (wttr.in)
# dbanner-mode: cached
# dbanner-interval: 1800
# Weather from wttr.in for the "city" variable of the banner (DBANNER_CITY).
. "${0%/*}/lib.sh"
command -v curl >/dev/null || exit 0
city=${DBANNER_CITY:-}
lang=${DBANNER_LANG:-en}
line=$(curl -fsS -m 15 -H "Accept-Language: $lang" "https://wttr.in/${city// /+}?format=%l:+%c+%t+(%f),+%w,+%h&lang=$lang" 2>/dev/null) || exit 0
[[ $line == *"Unknown location"* || -z $line ]] && exit 0
echo "$Bold$(say "Погода:" "Weather:")$Off $Cyan$line$Off"
@@ -0,0 +1,16 @@
{
"name": "classic",
"title": {"uk": "Класичне Void: [user@host dir]$", "en": "Classic Void: [user@host dir]$"},
"frame": "plain",
"open": "",
"close": "",
"sep": "",
"segments": [
{"type": "text", "text": "["},
{"type": "userhost", "fg": null, "at_fg": null, "at_bold": false, "host_fg": null, "host_bold": false, "root_fg": null, "root_bold": false},
{"type": "text", "text": " "},
{"type": "cwd", "mode": "base", "fg": null},
{"type": "text", "text": "]"},
{"type": "symbol", "fg": null}
]
}
@@ -0,0 +1,17 @@
{
"name": "das-git",
"title": {"uk": "Das для розробника: git, venv, фонові задачі", "en": "Das for developers: git, venv, background jobs"},
"frame": "das",
"frame_fg": "red",
"segments": [
{"type": "status"},
{"type": "userhost"},
{"type": "time"},
{"type": "cwd"},
{"type": "git"},
{"type": "venv"},
{"type": "jobs"},
{"type": "newline"},
{"type": "symbol"}
]
}
@@ -0,0 +1,15 @@
{
"name": "das",
"title": {"uk": "Das: дві лінії з датою й часом (стандарт)", "en": "Das: two lines with date and time (default)"},
"frame": "das",
"frame_fg": "red",
"segments": [
{"type": "status"},
{"type": "userhost"},
{"type": "date"},
{"type": "time"},
{"type": "cwd"},
{"type": "newline"},
{"type": "symbol"}
]
}
@@ -0,0 +1,14 @@
{
"name": "minimal",
"title": {"uk": "Мінімальне: каталог, git і ❯", "en": "Minimal: directory, git and ❯"},
"frame": "plain",
"open": "",
"close": "",
"sep": " ",
"segments": [
{"type": "cwd", "mode": "short", "fg": "blue", "bold": true},
{"type": "git", "fg": "magenta"},
{"type": "status", "fg": "red", "bold": true},
{"type": "symbol", "char": "❯", "fg": "green", "bold": true}
]
}
@@ -0,0 +1,15 @@
{
"name": "server",
"title": {"uk": "Сервер: ім'я хоста й ознака SSH помітні", "en": "Server: host name and SSH mark stand out"},
"frame": "das",
"frame_fg": "blue",
"segments": [
{"type": "status", "mode": "code", "fg": "bright_red", "bold": true},
{"type": "ssh"},
{"type": "userhost", "host_fg": "bright_yellow"},
{"type": "time"},
{"type": "cwd"},
{"type": "newline"},
{"type": "symbol", "fg": "bright_yellow"}
]
}
+85
View File
@@ -0,0 +1,85 @@
# Das OS aliases, loaded by /etc/bash/bashrc.d/das-aliases.sh in interactive
# shells. Your own ~/.bash_aliases is read after this file and wins.
# shellcheck shell=bash
# --- ls ---------------------------------------------------------------------
alias ls='ls --color=auto'
alias sl='ls'
alias l='ls -CF'
alias la='ls -A'
alias ll='ls -alF'
alias lsl='ls -lhFA --color=always | less -R'
alias lsz='ls --human-readable --size -1 -S --classify'
alias lst='ls -t -1'
alias lsd='ls -d */'
alias grep='grep --color=auto'
alias egrep='grep -E --color=auto'
alias fgrep='grep -F --color=auto'
alias diff='diff --color=auto'
alias ip='ip -color=auto'
# --- directories --------------------------------------------------------------
alias ..='cd ..'
alias cd..='cd ..'
alias ...='cd ../..'
alias ....='cd ../../..'
alias mkdir='mkdir -p'
alias path='echo "${PATH//:/$'\''\n'\''}"'
# --- files and disks ----------------------------------------------------------
alias cnt='find . -type f | wc -l'
alias fhere='find . -name'
alias df='df -h'
alias dft='df -Th --total -x tmpfs -x devtmpfs -x efivarfs'
alias dftl='df -Tha --total | less'
alias mounts='mount | column -t'
alias mnt="findmnt -rno SOURCE,TARGET,FSTYPE | grep '^/dev/' | sort | column -t"
alias snaps='sudo snapper -c root list'
# --- memory and processes -----------------------------------------------------
alias free='free -ht'
alias meminfo='command free -mlt'
alias cpuinfo='lscpu'
alias j='jobs -l'
alias psg='ps aux | grep -v grep | grep -i -e VSZ -e'
alias psmem='ps aux --sort=-%mem'
alias psmem10='ps aux --sort=-%mem | head -n 11'
alias pscpu='ps aux --sort=-%cpu'
alias pscpu10='ps aux --sort=-%cpu | head -n 11'
alias gpumeminfo='grep -i --color memory /var/log/Xorg.0.log ~/.local/share/xorg/Xorg.0.log 2>/dev/null'
# --- time ----------------------------------------------------------------------
alias now='date +"%T"'
alias nowtime='now'
alias nowdate='date +"%d-%m-%Y"'
# --- network -------------------------------------------------------------------
alias ping5='ping -c 5'
alias fastping='ping -c 100 -i 0.2'
alias oports='sudo ss -tulpn'
alias gport='sudo ss -tulpn | grep'
alias header='curl -I'
alias headerc='curl -I --compressed'
alias wget='wget -c'
alias nftlist='sudo nft list ruleset'
alias firewall='dfw status'
# --- history --------------------------------------------------------------
alias hgrep='history | grep'
alias histop10='histat 10'
# --- packages and updates (xbps) -------------------------------------------------
alias upd='das-update --cli'
alias updy='das-update --cli -y'
alias pkgclean='sudo xbps-remove -Oo'
# --- Docker and git ----------------------------------------------------------------
alias dps='docker ps --format "table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}"'
alias dlog='docker logs -f --tail 100'
alias dcu='docker compose up -d'
alias dcd='docker compose down'
alias dcl='docker compose logs -f --tail 100'
alias dcp='docker compose pull'
alias lzd='lazydocker'
alias lzg='lazygit'
+374
View File
@@ -0,0 +1,374 @@
# Das OS shell functions, loaded by /etc/bash/bashrc.d/das-aliases.sh in
# interactive shells. Your own ~/.bash_functions is read after this file.
# The first line of each function is its description for lsfunc:
# "# Українською | In English".
# shellcheck shell=bash
__das_uk() {
[[ ${LC_ALL:-${LC_MESSAGES:-${LANG:-}}} == uk* ]]
}
# __das_say UKRAINIAN ENGLISH
__das_say() {
if __das_uk; then echo "$1"; else echo "$2"; fi
}
# __das_usage UKRAINIAN ENGLISH: a usage line on stderr, returns 2
__das_usage() {
__das_say "використання: $1" "usage: $2" >&2
return 2
}
# __das_need COMMAND PACKAGE: is COMMAND there? If not, says which package has it
__das_need() {
command -v "$1" >/dev/null && return 0
__das_say "немає команди $1, встановіть: xi $2" "$1 is missing, install it: xi $2" >&2
return 127
}
lsfunc() {
# Список функцій з описами | List the functions with their descriptions
local name file line descr restore
restore=$(shopt -p extdebug)
shopt -s extdebug
while read -r name; do
[[ $name == _* ]] && continue
read -r _ line file < <(declare -F "$name")
descr=""
if [[ -r $file ]]; then
descr=$(sed -n "$((line + 1)),$((line + 2))p" "$file" | grep -m1 -E '^[[:space:]]*#' |
sed -E 's/^[[:space:]]*#[[:space:]]*//')
fi
if [[ $descr == *" | "* ]]; then
if __das_uk; then descr=${descr%% | *}; else descr=${descr#* | }; fi
fi
printf '%-12s %s\n' "$name" "$descr"
done < <(compgen -A function | sort)
eval "$restore"
}
# --- archives --------------------------------------------------------------------
extract() {
# Розпакувати архіви (tar.*, zip, rar, 7z, gz, xz, zst, xbps…) | Extract archives (tar.*, zip, rar, 7z, gz, xz, zst, xbps…)
(($#)) || { __das_usage "extract АРХІВ..." "extract ARCHIVE..."; return; }
local f rc=0
for f in "$@"; do
if [[ ! -f $f ]]; then
__das_say "'$f' — не файл" "'$f' is not a file" >&2
rc=1
continue
fi
case ${f,,} in
*.tar | *.tar.* | *.tgz | *.tbz | *.tbz2 | *.txz | *.tzst | *.xbps) tar -xvf "$f" ;;
*.zip | *.jar) __das_need unzip unzip && unzip "$f" ;;
*.rar) __das_need unrar unrar && unrar x "$f" ;;
*.7z) __das_need 7z 7zip && 7z x "$f" ;;
*.gz) gunzip -k "$f" ;;
*.bz2) bunzip2 -k "$f" ;;
*.xz) unxz -k "$f" ;;
*.zst) __das_need unzstd zstd && unzstd "$f" ;;
*.lz4) __das_need lz4 lz4 && lz4 -d "$f" ;;
*.z) uncompress "$f" ;;
*)
__das_say "'$f': невідомий тип архіву" "'$f': unknown archive type" >&2
false
;;
esac || rc=1
done
return $rc
}
compress() {
# Запакувати файли в архів; тип за назвою | Pack files into an archive, the type comes from the name
(($# >= 2)) || { __das_usage "compress АРХІВ.tar.gz ФАЙЛ..." "compress ARCHIVE.tar.gz FILE..."; return; }
local archive=$1
shift
case ${archive,,} in
*.tar.gz | *.tgz) tar -czvf "$archive" -- "$@" ;;
*.tar.bz2 | *.tbz2) tar -cjvf "$archive" -- "$@" ;;
*.tar.xz | *.txz) tar -cJvf "$archive" -- "$@" ;;
*.tar.zst | *.tzst) __das_need zstd zstd && tar --zstd -cvf "$archive" -- "$@" ;;
*.tar) tar -cvf "$archive" -- "$@" ;;
*.zip) __das_need zip zip && zip -r "$archive" -- "$@" ;;
*.7z) __das_need 7z 7zip && 7z a "$archive" -- "$@" ;;
*)
__das_say "невідомий тип архіву: $archive (tar.gz, tar.xz, tar.zst, zip, 7z…)" \
"unknown archive type: $archive (tar.gz, tar.xz, tar.zst, zip, 7z…)" >&2
return 1
;;
esac
}
bu() {
# Резервна копія файлу з датою в назві | Back up a file with the date in its name
(($#)) || { __das_usage "bu ФАЙЛ..." "bu FILE..."; return; }
local f stamp
stamp=$(date +%Y%m%d%H%M%S)
for f in "$@"; do
cp -a -- "$f" "$f-$stamp.backup" && echo "$f-$stamp.backup"
done
}
# --- directories and files -----------------------------------------------------------
mkcd() {
# Створити каталог і перейти в нього | Make a directory and go into it
(($# == 1)) || { __das_usage "mkcd КАТАЛОГ" "mkcd DIR"; return; }
mkdir -p -- "$1" && cd -- "$1" || return
}
cl() {
# Перейти в каталог і показати його вміст | Change to a directory and list it
cd -- "${1:-$HOME}" && ls
}
up() {
# Піднятися на N каталогів вгору | Go N directories up
local n=${1:-1} dir=""
[[ $n =~ ^[0-9]+$ ]] || { __das_usage "up [N]" "up [N]"; return; }
while ((n-- > 0)); do dir+="../"; done
cd -- "${dir:-.}" || return
}
f() {
# Знайти файли за назвою (шаблон, без регістру) | Find files by name (a pattern, any case)
(($#)) || { __das_usage "f ШАБЛОН [КАТАЛОГ]" "f PATTERN [DIR]"; return; }
local pattern=$1
[[ $pattern == *[*?[]* ]] || pattern="*$pattern*"
find "${2:-.}" -iname "$pattern" 2>/dev/null
}
dirsize() {
# Розміри всього в каталозі, за зростанням | Sizes of everything in a directory, smallest first
local dir=${1:-.}
du -shx -- "$dir"/* "$dir"/.[!.]* 2>/dev/null | sort -h
}
whatsize() {
# Розміри підкаталогів (типово /) на тій самій ФС, за спаданням | Subdirectory sizes (/ by default) on the same filesystem, largest first
du -hx --max-depth=1 -- "${1:-/}" 2>/dev/null | sort -hr
}
bigfiles() {
# N найбільших файлів у каталозі | The N largest files in a directory
find "${1:-.}" -xdev -type f -printf '%s\t%p\n' 2>/dev/null | sort -nr | head -n "${2:-10}" |
numfmt --to=iec --field=1
}
ednt() {
# Редагувати файл, не змінюючи часу зміни | Edit a file keeping its modification time
(($# == 1)) || { __das_usage "ednt ФАЙЛ" "ednt FILE"; return; }
local mtime
mtime=$(stat -c %y -- "$1") || return
"${VISUAL:-${EDITOR:-vi}}" "$1"
touch -d "$mtime" -- "$1"
}
devid() {
# Пристрої: lsblk, blkid і lsusb разом | Devices: lsblk, blkid and lsusb at once
echo '--- lsblk ---'
lsblk -f -e 7
echo '--- blkid ---'
sudo blkid | grep -v '^/dev/loop'
echo '--- lsusb ---'
lsusb
}
# --- processes and history -------------------------------------------------------------
psa() {
# Процеси, в рядку яких є текст | Processes whose line contains the text
(($#)) || { __das_usage "psa ТЕКСТ" "psa TEXT"; return; }
ps aux | awk -v p="$1" 'NR == 1 || (tolower($0) ~ tolower(p) && !/awk -v p=/)'
}
pst() {
# Те саме деревом процесів | The same as a process tree
(($#)) || { __das_usage "pst ТЕКСТ" "pst TEXT"; return; }
# shellcheck disable=SC2009 # pgrep has no tree
ps auxf | grep -i -B 10 -A 5 -- "$1" | grep -v -e 'grep -i -B' -e 'ps auxf'
}
histat() {
# N найчастіших команд з історії | The N most used commands from history
history | awk -v n="${1:-10}" '
{ sub(/^ *[0-9]+\*? +/, ""); split($0, w, " "); c = w[1]; if (c != "") { cnt[c]++; total++ } }
END { for (c in cnt) printf "%d\t%.1f%%\t%s\n", cnt[c], cnt[c] * 100 / total, c }' |
sort -nr | head -n "${1:-10}" | nl
}
EC() {
# Код завершення попередньої команди | The previous command's exit code
local code=$?
printf '\e[1;33mcode %d\e[m\n' "$code"
return "$code"
}
please() {
# Повторити попередню команду через sudo | Run the previous command again with sudo
local cmd
cmd=$(fc -ln -1)
cmd=${cmd#"${cmd%%[![:space:]]*}"}
echo "sudo $cmd" >&2
sudo bash -c "$cmd"
}
# --- packages (xbps) --------------------------------------------------------------------
pkgshow() {
# Встановлені пакети, з фільтром за назвою | Installed packages, filtered by name
xbps-query -l | awk '{print $2}' | grep -i -- "${1:-.}" | sort
}
pkgfind() {
# Шукати пакети в репозиторіях | Search the repositories for packages
(($#)) || { __das_usage "pkgfind ТЕКСТ" "pkgfind TEXT"; return; }
xbps-query -Rs "$1"
}
pkgowner() {
# Якому пакету належить файл чи команда | Which package owns a file or a command
(($#)) || { __das_usage "pkgowner ФАЙЛ|КОМАНДА" "pkgowner FILE|COMMAND"; return; }
local path=$1
[[ $path == */* ]] || path=$(type -P -- "$1") || path=$1
xbps-query -o "$(readlink -f -- "$path")"
}
# --- network -----------------------------------------------------------------------------
myip() {
# Зовнішня IP-адреса | The public IP address
curl -fsS --max-time 5 https://ifconfig.me/ip && echo
}
netinfo() {
# Адреси, шлюз, DNS і зовнішня IP | Addresses, gateway, DNS and the public IP
echo "--- $(__das_say "Адреси" "Addresses") ---"
command ip -br address | grep -v '^lo '
echo "--- $(__das_say "Шлюз" "Gateway") ---"
command ip route show default
echo "--- DNS ---"
awk '/^nameserver/ {print $2}' /etc/resolv.conf
echo "--- $(__das_say "Зовнішня IP" "Public IP") ---"
myip
}
listen() {
# Слухати порт TCP і показувати, що прийшло | Listen on a TCP port and print what arrives
(($# == 1)) || { __das_usage "listen ПОРТ" "listen PORT"; return; }
__das_need nc openbsd-netcat || return
nc -lvn "$1"
}
rmhost() {
# Прибрати хост або рядок N з ~/.ssh/known_hosts | Remove a host or line N from ~/.ssh/known_hosts
(($# == 1)) || { __das_usage "rmhost ХОСТ|НОМЕР_РЯДКА" "rmhost HOST|LINE_NUMBER"; return; }
if [[ $1 =~ ^[0-9]+$ ]]; then
sed -i.bak "${1}d" ~/.ssh/known_hosts && echo "$HOME/.ssh/known_hosts.bak"
else
ssh-keygen -R "$1"
fi
}
serve() {
# Роздати поточний каталог по HTTP (типово порт 8000) | Serve the current directory over HTTP (port 8000 by default)
__das_need python3 python3 || return
python3 -m http.server "${1:-8000}"
}
weather() {
# Погода з wttr.in (місто з аргументу або за IP) | Weather from wttr.in (the city given, or by IP)
local lang=en
__das_uk && lang=uk
curl -fsS --max-time 10 "https://wttr.in/${1// /+}?lang=$lang"
}
cheat() {
# Шпаргалка до команди з cheat.sh | A command's cheat sheet from cheat.sh
(($#)) || { __das_usage "cheat КОМАНДА" "cheat COMMAND"; return; }
curl -fsS --max-time 10 "https://cheat.sh/$1"
}
# --- small tools -------------------------------------------------------------------------
calc() {
# Порахувати вираз | Calculate an expression
(($#)) || { __das_usage "calc ВИРАЗ" "calc EXPRESSION"; return; }
if command -v bc >/dev/null; then
echo "scale=3; $*" | bc -l
else
awk "BEGIN { print $* }"
fi
}
epoch() {
# Поточний час Unix або перетворити його на дату | The current Unix time, or turn one into a date
if (($#)); then
date -d "@$1" -R
else
date +%s
fi
}
genpass() {
# Випадковий пароль (типово 20 символів) | A random password (20 characters by default)
local n=${1:-20}
[[ $n =~ ^[0-9]+$ ]] || { __das_usage "genpass [ДОВЖИНА]" "genpass [LENGTH]"; return; }
LC_ALL=C tr -dc 'A-Za-z0-9_@%+=.,-' </dev/urandom | head -c "$n"
echo
}
csrc() {
# Скомпілювати й запустити програму на C | Compile and run a C source file
(($#)) || { __das_usage "csrc ФАЙЛ.c [АРГУМЕНТИ]" "csrc FILE.c [ARGS]"; return; }
[[ -r $1 ]] || { __das_say "немає файлу $1" "no such file: $1" >&2; return 1; }
__das_need gcc gcc || return
local src=$1 bin rc
shift
bin=$(mktemp) || return
gcc "$src" -o "$bin" && "$bin" "$@"
rc=$?
rm -f -- "$bin"
return $rc
}
note() {
# Нотатки в ~/.notes: без аргументів показати, -c очистити | Notes in ~/.notes: no arguments shows them, -c clears
local file=$HOME/.notes
if ! (($#)); then
[[ -f $file ]] && cat -- "$file"
elif [[ $1 == -c ]]; then
: >"$file"
else
printf '%s\n' "$*" >>"$file"
fi
}
todo() {
# Справи в ~/.todo: -l з номерами, -r N виконано, -c очистити | To-dos in ~/.todo: -l numbered, -r N done, -c clears
local file=$HOME/.todo
case ${1:-} in
"") [[ -f $file ]] && cat -- "$file" ;;
-l) [[ -f $file ]] && nl -b a -- "$file" ;;
-c) : >"$file" ;;
-r)
local n=${2:-}
if [[ -z $n ]]; then
nl -b a -- "$file"
read -r -p "$(__das_say "Номер, що прибрати: " "Number to remove: ")" n
fi
[[ $n =~ ^[0-9]+$ ]] || return 1
sed -i "${n}d" -- "$file"
;;
*) printf '%s\n' "$*" >>"$file" ;;
esac
}
man() {
# man з кольоровими заголовками | man with coloured headings
LESS_TERMCAP_mb=$'\e[1;31m' LESS_TERMCAP_md=$'\e[1;31m' LESS_TERMCAP_me=$'\e[0m' \
LESS_TERMCAP_so=$'\e[1;44;33m' LESS_TERMCAP_se=$'\e[0m' \
LESS_TERMCAP_us=$'\e[1;32m' LESS_TERMCAP_ue=$'\e[0m' \
GROFF_NO_SGR=1 command man "$@"
}
+21
View File
@@ -0,0 +1,21 @@
"""Points das_shell at the source tree and a scratch /etc before it is imported."""
import os
import sys
import tempfile
HERE = os.path.dirname(os.path.abspath(__file__))
FILES = os.path.dirname(HERE)
SCRATCH = tempfile.mkdtemp(prefix="das-shell-test-")
os.environ.update({
"DAS_SHELL_DATA": os.path.join(FILES, "share"),
"DAS_SHELL_LIB": FILES,
"DAS_SHELL_ETC": os.path.join(SCRATCH, "etc"),
"DAS_SHELL_CACHE": os.path.join(SCRATCH, "cache"),
"XDG_CONFIG_HOME": os.path.join(SCRATCH, "config"),
"LANG": "en_US.UTF-8",
})
os.environ.pop("LC_ALL", None)
os.environ.pop("LC_MESSAGES", None)
sys.path.insert(0, os.path.join(FILES, "python"))
@@ -0,0 +1,108 @@
import os
import time
import unittest
from tests.common import SCRATCH
from das_shell import banner, widgets # noqa: E402
class GridTest(unittest.TestCase):
def test_round_trip(self):
g = banner.Grid(10, 3)
g.box(0, 0, 9, 2, "double", g.style_key("cyan", None, True))
g.text(2, 1, "Дас", g.style_key("yellow"))
d = g.to_design({"name": "x"})
g2 = banner.Grid.from_design(d)
self.assertEqual(banner.grid_lines(g2, color=False), ["╔════════╗", "║ Дас ║", "╚════════╝"])
self.assertEqual(len(d["styles"]), 2)
def test_stamp_merges_styles(self):
a = banner.Grid(4, 1)
a.text(0, 0, "ab", a.style_key("red"))
b = banner.Grid(6, 1)
b.text(0, 0, "x", b.style_key("blue"))
b.stamp(2, 0, a)
self.assertEqual(banner.grid_lines(b, color=False), ["x ab"])
self.assertEqual(b.style(b.get(2, 0)[1])[0], "red")
def test_region_and_resize(self):
g = banner.Grid(5, 5)
g.fill(1, 1, 3, 3, "#", " ")
r = g.region(1, 1, 2, 2)
self.assertEqual((r.width, r.height), (2, 2))
g.resize(2, 2)
self.assertEqual(banner.grid_lines(g, color=False), ["", " #"])
class RenderTest(unittest.TestCase):
def test_fields_keep_the_frame(self):
d = banner.load("das-classic")
values = widgets.Values({"title": "T", "url": "https://example.org/"})
values["ip"] = "10.0.0.123"
lines = banner.render(dict(d, widgets=[]), values, [], color=False).splitlines()
self.assertEqual(len({len(line) for line in lines}), 1, lines)
self.assertIn("10.0.0.123", lines[2])
def test_long_value_is_cut(self):
self.assertEqual(banner.fit("abcdef", 4, "left"), "abc…")
self.assertEqual(banner.fit("ab", 4, "center"), " ab ")
def test_languages_and_empty_values(self):
values = widgets.Values({"url": ""})
self.assertEqual(banner.expand("[[Сайт|Site]]: {url}", values), "Site: ")
f = {"x": 0, "y": 0, "width": 20, "text": "{x} · {url}"}
self.assertEqual(banner.field_lines(f, widgets.Values({"x": "host", "url": ""}))[0].rstrip(), "host")
self.assertEqual(banner.expand("{unknown}", values), "{unknown}")
def test_widgets_right(self):
d = {"lines": ["AB", "CD"], "widgets_position": "right"}
out = banner.render(d, widgets.Values(), [["w1", "w2", "w3"]], color=False)
self.assertEqual(out.splitlines(), ["AB w1", "CD w2", " w3"])
def test_every_preset_renders(self):
for name, _ in banner.list_presets():
d = banner.load(name)
out = banner.render(d, widgets.Values(d["vars"]), [], color=True)
self.assertTrue(out.strip(), name)
def test_art_pictures(self):
names = {n for n, _, _ in banner.list_art()}
self.assertTrue({"das-logo", "das-logo-small", "trident", "trident-small", "trident-shield"} <= names)
class WidgetTest(unittest.TestCase):
def test_slow_widget_does_not_hold_the_login(self):
d = {"widgets": [{"type": "command", "cmd": "sleep 10"}, {"type": "command", "cmd": "echo hi"}]}
start = time.monotonic()
blocks = widgets.blocks(d, widgets.Values())
self.assertLess(time.monotonic() - start, 4)
self.assertEqual(blocks, [[], ["hi"]])
def test_cache(self):
w = {"type": "command", "cmd": "echo cached", "mode": "cached", "interval": 1000}
d = {"widgets": [w]}
self.assertEqual(widgets.blocks(d, widgets.Values()), [[]])
widgets.refresh(d)
self.assertEqual(widgets.blocks(d, widgets.Values()), [["cached"]])
def test_file_widget(self):
path = os.path.join(SCRATCH, "out.txt")
with open(path, "w") as f:
f.write("a\nb\n")
self.assertEqual(widgets.run({"type": "file", "path": path}, widgets.Values()), ["a", "b"])
def test_builtins(self):
values = widgets.Values()
for name in widgets.BUILTINS:
widgets.run({"type": "builtin", "name": name}, values)
self.assertTrue(values.get("hostname"))
self.assertNotEqual(values.get("uptime"), "?")
def test_vars_reach_scripts(self):
d = {"vars": {"city": "Kyiv"}, "widgets": [{"type": "command", "cmd": "echo $DBANNER_CITY"}]}
self.assertEqual(widgets.blocks(d, widgets.Values())[0], ["Kyiv"])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,91 @@
import datetime
import os
import shutil
import subprocess
import unittest
from tests.common import FILES, SCRATCH
from das_shell import prompt # noqa: E402
NOW = datetime.datetime(2026, 10, 21, 8, 12)
def text(design, **state):
return prompt.preview_text(design, prompt.sample_state(now=NOW, **state))
class PresetTest(unittest.TestCase):
def test_presets_load(self):
names = [n for n, _ in prompt.list_presets()]
self.assertEqual(names[0], "das")
self.assertTrue({"das", "das-git", "classic", "minimal", "server"} <= set(names))
def test_das_matches_the_authors_prompt(self):
d = prompt.load_preset("das")
self.assertEqual(text(d), "┌─[das@dasos]─[2026-10-21]─[08:12]─[~/Projects/web]\n└─╼ $ ")
self.assertEqual(text(d, rc=1), "┌─[✗]─[das@dasos]─[2026-10-21]─[08:12]─[~/Projects/web]\n└─╼ $ ")
self.assertEqual(text(d, root=True), "┌─[root@dasos]─[2026-10-21]─[08:12]─[/etc/sv]\n└─╼ # ")
def test_classic_is_void(self):
self.assertEqual(text(prompt.load_preset("classic")), "[das@dasos web]$ ")
def test_hidden_parts_take_their_separator(self):
d = prompt.load_preset("das-git")
self.assertNotIn("──", text(d))
self.assertIn("─[main*]─[.venv]─[jobs:2]", text(d, git="main*", venv=".venv", jobs=2))
def test_each_line_sets_its_color(self):
# Readline redraws only the last line, so it must not rely on the first
ps1 = prompt.script(prompt.load_preset("das")).split("PS1='", 1)[1].split("'", 1)[0]
self.assertTrue(ps1.split("\n")[1].startswith("\\[\\e[0;31m\\]└"), ps1)
def test_unknown_segments_are_dropped(self):
d = prompt.normalize({"segments": [{"type": "nope"}, {"type": "cwd"}]})
self.assertEqual([s["type"] for s in d["segments"]], ["cwd"])
@unittest.skipUnless(shutil.which("bash"), "needs bash")
class BashTest(unittest.TestCase):
"""The generated script in a real bash, after Void's skel .bashrc."""
def run_prompt(self, preset, before="true", term="xterm-256color"):
path = os.path.join(SCRATCH, preset + ".sh")
with open(path, "w", encoding="utf-8") as f:
f.write(prompt.script(prompt.load_preset(preset)))
code = (f". {FILES}/prompt-lib.sh; . {path}; PS1='[\\u@\\h \\W]\\$ '; "
f"{before}; __das_prompt_hook; printf '%s' \"${{PS1@P}}\"")
out = subprocess.run(["bash", "--norc", "--noprofile", "-c", code], capture_output=True, text=True,
env=dict(os.environ, TERM=term, HOME="/home/das"), cwd="/").stdout
for seq in ("\x01", "\x02"):
out = out.replace(seq, "")
import re
return re.sub(r"\x1b\[[0-9;]*m|\x1b\][^\a]*\a", "", out)
def test_script_is_valid(self):
for name, _ in prompt.list_presets():
path = os.path.join(SCRATCH, name + ".sh")
with open(path, "w", encoding="utf-8") as f:
f.write(prompt.script(prompt.load_preset(name)))
subprocess.run(["bash", "-n", path], check=True)
def test_skel_ps1_is_replaced(self):
out = self.run_prompt("das")
self.assertTrue(out.startswith("┌─["), out)
self.assertNotIn("[✗]", out)
def test_error_mark(self):
self.assertIn("┌─[✗]─[", self.run_prompt("das", "false"))
def test_console_glyphs(self):
out = self.run_prompt("das", "false", term="linux")
self.assertIn("[x]", out)
self.assertIn("└─> ", out)
def test_plain_terminal(self):
out = self.run_prompt("das", term="dumb")
self.assertTrue(out.startswith("┌─["), out)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,54 @@
import os
import subprocess
import tempfile
import unittest
from tests.common import FILES, SCRATCH
ALIASES = os.path.join(FILES, "shell", "aliases.sh")
FUNCTIONS = os.path.join(FILES, "shell", "functions.sh")
def bash(script, cwd=None):
"""Runs script in bash with the Das OS aliases and functions loaded."""
full = f"shopt -s expand_aliases\n. {ALIASES}\n. {FUNCTIONS}\n{script}"
return subprocess.run(["bash", "--norc", "-c", full], cwd=cwd, capture_output=True, text=True,
env={**os.environ, "HOME": SCRATCH})
class ShellTest(unittest.TestCase):
def test_syntax(self):
for path in (ALIASES, FUNCTIONS, os.path.join(FILES, "bashrc.d", "das-aliases.sh")):
self.assertEqual(subprocess.run(["bash", "-n", path]).returncode, 0, path)
def test_every_function_has_a_description(self):
out = bash("lsfunc").stdout.splitlines()
self.assertIn("extract", [line.split()[0] for line in out])
for line in out:
name, _, descr = line.partition(" ")
self.assertTrue(descr.strip(), f"{name} has no description")
self.assertNotIn(" | ", descr, name)
def test_descriptions_follow_the_language(self):
out = bash("LANG=uk_UA.UTF-8 lsfunc | grep '^mkcd '").stdout
self.assertIn("Створити каталог", out)
def test_compress_and_extract(self):
with tempfile.TemporaryDirectory(dir=SCRATCH) as d:
r = bash("mkdir -p src/sub && echo hi >src/sub/x.txt && compress a.tar.gz src >/dev/null &&"
" mkdir out && cd out && extract ../a.tar.gz >/dev/null && cat src/sub/x.txt", cwd=d)
self.assertEqual(r.stdout, "hi\n", r.stderr)
r = bash("extract nope.zip", cwd=d)
self.assertEqual(r.returncode, 1)
def test_up_and_usage(self):
with tempfile.TemporaryDirectory(dir=SCRATCH) as d:
r = bash("mkcd a/b/c && up 2 && pwd", cwd=d)
self.assertEqual(r.stdout.strip(), os.path.join(os.path.realpath(d), "a"))
r = bash("up x")
self.assertEqual(r.returncode, 2)
self.assertIn("usage: up", r.stderr)
if __name__ == "__main__":
unittest.main()
+61
View File
@@ -0,0 +1,61 @@
# Template file for 'das-shell'
# In-tree package: sources are in files/ (FILESDIR).
pkgname=das-shell
version=0.4.0
revision=1
create_wrksrc=yes
hostmakedepends="python3"
checkdepends="bash"
depends="bash python3 python3-textual figlet"
short_desc="Das OS terminal: bash prompt, aliases, dprompt and dbanner (SSH banner)"
maintainer="Das <anry.das@gmail.com>"
license="MIT"
homepage="https://git.das.org.ua/das/Das-OS"
conf_files="/etc/bash/bashrc.d/das-prompt.sh /etc/bash/bashrc.d/das-aliases.sh
/etc/profile.d/das-banner.sh"
make_dirs="/var/cache/das/banner 0755 root root"
pycompile_dirs="usr/lib/das/python"
python_version=3
do_build() {
# The stock prompt is the "das" preset turned into bash by the code itself
PYTHONPATH="${FILESDIR}/python" DAS_SHELL_DATA="${FILESDIR}/share" \
python3 -m das_shell.cli_prompt show das --script >default-prompt.sh
}
do_check() {
cd "${FILESDIR}" && PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s tests -t .
}
do_install() {
local f
vinstall "${FILESDIR}/bashrc.d/das-prompt.sh" 644 etc/bash/bashrc.d
vinstall "${FILESDIR}/bashrc.d/das-aliases.sh" 644 etc/bash/bashrc.d
vinstall "${FILESDIR}/shell/aliases.sh" 644 usr/share/das/shell
vinstall "${FILESDIR}/shell/functions.sh" 644 usr/share/das/shell
vinstall "${FILESDIR}/profile.d/das-banner.sh" 644 etc/profile.d
vinstall "${FILESDIR}/prompt-lib.sh" 644 usr/lib/das
vinstall default-prompt.sh 644 usr/share/das/prompt default.sh
for f in "${FILESDIR}"/python/das_shell/*.py; do
vinstall "${f}" 644 usr/lib/das/python/das_shell
done
for f in "${FILESDIR}"/share/prompt/presets/*.json; do
vinstall "${f}" 644 usr/share/das/prompt/presets
done
for f in "${FILESDIR}"/share/banner/presets/*.json; do
vinstall "${f}" 644 usr/share/das/banner/presets
done
for f in "${FILESDIR}"/share/banner/art/*.json; do
vinstall "${f}" 644 usr/share/das/banner/art
done
for f in "${FILESDIR}"/share/banner/widgets/*.sh; do
vinstall "${f}" 755 usr/share/das/banner/widgets
done
chmod 644 "${DESTDIR}/usr/share/das/banner/widgets/lib.sh"
vsv das-banner
for f in dprompt dbanner; do
vbin "${FILESDIR}/bin/${f}"
vcompletion "${FILESDIR}/${f}.bash-completion" bash ${f}
done
vlicense "${FILESDIR}/LICENSE"
}
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="128" height="128" viewBox="-115.25 0 230.5 230.5" fill="#ffd500" stroke="#2f6fde" stroke-width="7" stroke-linejoin="round" paint-order="stroke">
<!-- Trident, yellow with a blue outline so it reads on light and dark panels. -->
<path d="M5.985561 78.82382a104.079383 104.079383 0 0 0 14.053598 56.017033 55 55 0 0 1-13.218774 70.637179A20 20 0 0 0 0 212.5a20 20 0 0 0-6.820384-7.021968 55 55 0 0 1-13.218774-70.637179A104.079383 104.079383 0 0 0-5.98556 78.82382l-1.599642-45.260519A30.103986 30.103986 0 0 1 0 12.5a30.103986 30.103986 0 0 1 7.585202 21.063301zM5 193.624749a45 45 0 0 0 6.395675-53.75496A114.079383 114.079383 0 0 1 0 112.734179a114.079383 114.079383 0 0 1-11.395675 27.13561A45 45 0 0 0-5 193.624749V162.5H5z"/>
<path id="a" d="M27.779818 75.17546A62.64982 62.64982 0 0 1 60 27.5v145H0l-5-10a22.5 22.5 0 0 1 17.560976-21.95122l14.634147-3.292683a10 10 0 1 0-4.427443-19.503751zm5.998315 34.353887a20 20 0 0 1-4.387889 37.482848l-14.634146 3.292683A12.5 12.5 0 0 0 5 162.5h45V48.265462a52.64982 52.64982 0 0 0-12.283879 28.037802zM42 122.5h10v10H42z"/>
<use xlink:href="#a" transform="scale(-1 1)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -13,6 +13,21 @@
border-top: 1px solid alpha(@panel_fg_color, 0.12); border-top: 1px solid alpha(@panel_fg_color, 0.12);
} }
/* Symbolic panel icons (tray, volume, layout) come out pale grey on the light panel. */
.xfce4-panel,
.xfce4-panel button,
.xfce4-panel image,
.xfce4-panel button image,
.xfce4-panel label {
color: #1f2430;
opacity: 1;
-gtk-icon-shadow: none;
}
.xfce4-panel button:hover {
background-color: alpha(#1f2430, 0.1);
}
/* adw-gtk3 draws white desktop labels with a shadow, made for dark wallpapers. */ /* adw-gtk3 draws white desktop labels with a shadow, made for dark wallpapers. */
XfdesktopIconView.view { XfdesktopIconView.view {
color: #1f2430; color: #1f2430;
@@ -7,6 +7,6 @@ Encoding=UTF-8
[X-GNOME-Metatheme] [X-GNOME-Metatheme]
GtkTheme=DasOS GtkTheme=DasOS
MetacityTheme=DasOS MetacityTheme=DasOS
IconTheme=Papirus IconTheme=Papirus-Light
CursorTheme=Adwaita CursorTheme=Adwaita
ButtonLayout=menu:minimize,maximize,close ButtonLayout=menu:minimize,maximize,close
+2 -1
View File
@@ -1,7 +1,7 @@
# Template file for 'das-theme' # Template file for 'das-theme'
# In-tree package: sources are in files/ (FILESDIR). # In-tree package: sources are in files/ (FILESDIR).
pkgname=das-theme pkgname=das-theme
version=0.1.0 version=0.3.0
revision=1 revision=1
create_wrksrc=yes create_wrksrc=yes
hostmakedepends="go" hostmakedepends="go"
@@ -35,5 +35,6 @@ do_install() {
vinstall backgrounds/das-dark.svg 644 usr/share/backgrounds/das vinstall backgrounds/das-dark.svg 644 usr/share/backgrounds/das
vinstall branding/das-logo.svg 644 usr/share/icons/hicolor/scalable/apps vinstall branding/das-logo.svg 644 usr/share/icons/hicolor/scalable/apps
vinstall branding/das-logo.svg 644 usr/share/pixmaps vinstall branding/das-logo.svg 644 usr/share/pixmaps
vinstall branding/das-trident.svg 644 usr/share/icons/hicolor/scalable/apps
vlicense LICENSE vlicense LICENSE
} }
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Das
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+8
View File
@@ -0,0 +1,8 @@
#!/bin/sh
# deployer launcher: the config lives in ~/.config/deployer/deploy.toml (copied from the example on the first run).
cfg=${XDG_CONFIG_HOME:-$HOME/.config}/deployer/deploy.toml
if [ ! -f "$cfg" ]; then
mkdir -p "${cfg%/*}" && cp /usr/share/das/deployer/deploy.toml.example "$cfg" || exit 1
echo "deploy: created $cfg, edit [server] there" >&2
fi
PYTHONPATH=/usr/lib/das/deployer exec python3 -c 'import sys; from deployer.cli import main; sys.exit(main())' --config "$cfg" "$@"
@@ -0,0 +1,2 @@
"""Deploy local projects to the home server over ssh + rsync."""
__version__ = "0.3.1"
@@ -0,0 +1,171 @@
"""Command-line mode: the same pipeline as the TUI, for scripts and quick deploys."""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
from . import __version__, config, detect, pipeline, remote, snapshots
from .kinds import Options, Plan
C = {"new": "\033[32m+", "update": "\033[33m~", "delete": "\033[31m-"}
RESET = "\033[0m"
def color(on: bool):
if not on:
C.update(new="+", update="~", delete="-")
globals()["RESET"] = ""
def print_plan(plan: Plan) -> None:
a = plan.app
print(f"\n{a.name} [{a.kind}] {plan.rsync_src} -> {plan.server.target}:{plan.dest}"
f" {('rev ' + plan.git_rev) if plan.git_rev else ''}")
for c in plan.changes:
print(f" {C[c.action]} {c.path}{RESET}")
if not plan.changes:
print(" (no file changes)")
for n in plan.notes:
print(f" ! {n}")
def confirm(question: str, yes: bool) -> bool:
if yes:
return True
if not sys.stdin.isatty():
print("not a terminal: pass --yes to confirm", file=sys.stderr)
return False
return input(f"{question} [y/N] ").strip().lower() in ("y", "yes", "т", "так")
def options(args) -> Options:
return Options(build=not args.no_build, install_deps=args.deps, restart=not args.no_restart,
copy_configs=not args.no_configs, delete=args.delete)
def cmd_list(cfg, args):
st = {} if args.offline else pipeline.status_all(cfg)
for a in cfg.apps:
state, last, code = st.get(a.name, ("", "", ""))
print(f"{a.name:22} {a.kind:15} {state:14} {code:8} {cfg.dest_path(a):48} {last}")
def cmd_deploy(cfg, args):
app = cfg.app(args.app)
plan = pipeline.preview(cfg, app, options(args), log=print)
print_plan(plan)
if args.dry_run:
return 0
if not plan.changes and not plan.first_install and not args.force:
print("nothing to sync: use --force to run install/restart anyway")
return 0
if not confirm("deploy?", args.yes):
return 1
res = pipeline.execute(plan, log=print)
if not res.ok and res.snapshot and confirm(f"roll back to {res.snapshot}?", False):
res = pipeline.rollback(cfg, app, res.snapshot, log=print)
return 0 if res.ok else 2
def cmd_rollback(cfg, args):
app = cfg.app(args.app)
snaps = snapshots.listing(cfg.server, app.name)
if args.list or not snaps:
print("\n".join(snaps) or "no snapshots")
return 0
ts = args.ts or snaps[0]
if not confirm(f"restore {app.name} from {ts}?", args.yes):
return 1
res = pipeline.rollback(cfg, app, ts, log=print)
print(res.message)
return 0 if res.ok else 2
def cmd_logs(cfg, args):
return remote.interactive(cfg.server, pipeline.logs_command(cfg, cfg.app(args.app)))
def cmd_import(cfg, args):
found = detect.import_server(cfg, log=print)
if not found:
print("nothing new on the server")
return 0
ready = [i.app for i in found if i.matched and i.app.kind.startswith("python")]
drafts = [i.app for i in found if i.app not in ready]
print(f"\n{len(ready)} ready, {len(drafts)} as commented drafts")
if confirm(f"append to {cfg.path}?", args.yes):
config.append_apps(cfg.path, ready, drafts)
return 0
def cmd_add(cfg, args):
app = detect.new_app(Path(args.path), args.name, args.kind)
print(config.app_to_toml(app))
if confirm(f"append to {cfg.path}?", args.yes):
config.append_apps(cfg.path, [app])
return 0
def main(argv=None) -> int:
p = argparse.ArgumentParser(prog="deploy", description="Deploy local projects to the server. "
"No command: start the TUI.")
p.add_argument("--config", default=None, help="deploy.toml (default: next to deploy.py)")
p.add_argument("--no-color", action="store_true")
p.add_argument("-V", "--version", action="version", version=__version__)
sub = p.add_subparsers(dest="cmd")
s = sub.add_parser("list", help="apps and their state on the server")
s.add_argument("--offline", action="store_true", help="do not ask the server")
for name, hlp in (("deploy", "preview and deploy an app"), ("preview", "show what a deploy would change")):
s = sub.add_parser(name, help=hlp)
s.add_argument("app")
s.add_argument("-y", "--yes", action="store_true", help="do not ask for confirmation")
s.add_argument("-n", "--dry-run", action="store_true", default=name == "preview")
s.add_argument("--force", action="store_true", help="install/restart even without file changes")
s.add_argument("--no-build", action="store_true")
s.add_argument("--no-restart", action="store_true", help="no restart, no post command, no health check")
s.add_argument("--no-configs", action="store_true", help="do not create missing config links")
s.add_argument("--deps", action="store_true", help="pip install even if requirements.txt is unchanged")
s.add_argument("--delete", action=argparse.BooleanOptionalAction, default=None,
help="delete server files that are absent locally (default: app setting)")
s = sub.add_parser("rollback", help="restore the latest (or given) snapshot")
s.add_argument("app")
s.add_argument("ts", nargs="?")
s.add_argument("--list", action="store_true")
s.add_argument("-y", "--yes", action="store_true")
s = sub.add_parser("logs", help="follow the app log")
s.add_argument("app")
s = sub.add_parser("import", help="add apps that already run on the server to deploy.toml")
s.add_argument("-y", "--yes", action="store_true")
s = sub.add_parser("add", help="add a local project to deploy.toml")
s.add_argument("path")
s.add_argument("--name")
s.add_argument("--kind", choices=config.KINDS)
s.add_argument("-y", "--yes", action="store_true")
sub.add_parser("check", help="validate deploy.toml")
sub.add_parser("tui", help="start the TUI (default)")
args = p.parse_args(argv)
color(sys.stdout.isatty() and not args.no_color)
cfg_path = Path(args.config) if args.config else config.default_path()
try:
cfg = config.load(cfg_path)
except (OSError, config.ConfigError) as e:
print(f"config: {e}", file=sys.stderr)
return 1
if args.cmd in (None, "tui"):
from .tui import run
return run(cfg)
if args.cmd == "check":
print(f"{cfg_path}: {len(cfg.apps)} apps, OK")
return 0
handlers = {"list": cmd_list, "deploy": cmd_deploy, "preview": cmd_deploy, "rollback": cmd_rollback,
"logs": cmd_logs, "import": cmd_import, "add": cmd_add}
try:
return handlers[args.cmd](cfg, args) or 0
except (config.ConfigError, pipeline.DeployError, remote.RemoteError) as e:
print(f"error: {e}", file=sys.stderr)
return 1
except KeyboardInterrupt:
return 130
@@ -0,0 +1,221 @@
"""deploy.toml: server settings and the registry of deployable apps."""
from __future__ import annotations
import os
import re
import tomllib
from dataclasses import dataclass, field, fields
from pathlib import Path
KINDS = ("python-service", "python-update", "docker", "files", "artifact")
NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$")
UNIT_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.@-]{0,99}$")
class ConfigError(ValueError):
pass
@dataclass
class Server:
host: str = "localhost"
user: str = "user"
port: int = 22
app_root: str = "/home/user/app"
configs_root: str = "/home/user/app/configs"
state_dir: str = "/home/user/app_deploy"
keep: int = 5
local_roots: list[str] = field(default_factory=lambda: ["~/Dev/Projects"])
# Development-only files: not synced for python/docker/files apps (an app can sync them via its own rules)
dev_exclude: list[str] = field(default_factory=lambda: [
"/tests", "/test", "/CLAUDE.md", "/.claude", "/README*.md", "/requirements-dev.txt", "/.gitignore",
"/pytest.ini", "/.coverage", "/htmlcov"])
@property
def target(self) -> str:
return f"{self.user}@{self.host}"
@property
def snapshots(self) -> str:
return f"{self.state_dir}/snapshots"
@property
def home(self) -> str:
return "/root" if self.user == "root" else f"/home/{self.user}"
@dataclass
class App:
name: str
kind: str
src: str
dest: str
# python-*
unit: str = ""
entry: str = "main.py"
user: str = "user"
description: str = ""
env_file: str = ""
syslog_id: str = ""
# items kept in configs_root and linked into the app dir (python, docker)
configs: list[str] = field(default_factory=list)
# sync options
exclude: list[str] = field(default_factory=list)
use_gitignore: bool = True
delete: bool = False
# build / artifact
build: str = ""
build_env: dict[str, str] = field(default_factory=dict)
artifact: str = ""
# files / artifact
mode: str = ""
post: str = ""
# health
health_container: str = ""
health_url: str = ""
health_timeout: int = 120
@property
def src_path(self) -> Path:
return Path(os.path.expanduser(self.src))
@property
def is_python(self) -> bool:
return self.kind.startswith("python")
@dataclass
class Config:
path: Path
server: Server
apps: list[App]
def app(self, name: str) -> App:
for a in self.apps:
if a.name == name:
return a
raise ConfigError(f"unknown app: {name}")
def dest_path(self, app: App) -> str:
"""Absolute remote path; relative dests are under app_root, '~' is the remote home."""
d = app.dest
if d.startswith("~"):
d = self.server.home + d[1:]
elif not d.startswith("/"):
d = f"{self.server.app_root}/{d}"
return d.rstrip("/") or "/"
def _check_types(obj, data: dict, where: str):
known = {f.name: f for f in fields(obj)}
for k, v in data.items():
if k not in known:
raise ConfigError(f"{where}: unknown key '{k}'")
default = getattr(obj, k)
if isinstance(default, bool):
ok = isinstance(v, bool)
elif isinstance(default, int):
ok = isinstance(v, int) and not isinstance(v, bool)
elif isinstance(default, list):
ok = isinstance(v, list) and all(isinstance(x, str) for x in v)
elif isinstance(default, dict):
ok = isinstance(v, dict) and all(isinstance(x, str) for x in v.values())
else:
ok = isinstance(v, str)
if not ok:
raise ConfigError(f"{where}: '{k}' has wrong type")
setattr(obj, k, v)
def validate_app(app: App) -> None:
w = f"app '{app.name}'"
if not NAME_RE.match(app.name):
raise ConfigError(f"{w}: bad name")
if app.kind not in KINDS:
raise ConfigError(f"{w}: kind must be one of {', '.join(KINDS)}")
if not app.src or not app.dest:
raise ConfigError(f"{w}: src and dest are required")
if ".." in Path(app.dest).parts or app.dest.strip("/~") == "":
raise ConfigError(f"{w}: unsafe dest '{app.dest}'")
if app.is_python:
if not UNIT_RE.match(app.unit):
raise ConfigError(f"{w}: python apps need a valid 'unit'")
if not app.entry.strip():
raise ConfigError(f"{w}: empty entry")
if app.kind == "artifact" and not app.artifact:
raise ConfigError(f"{w}: artifact apps need an 'artifact' glob")
for c in app.configs:
if "/" in c.strip("/") or c in ("", ".", ".."):
raise ConfigError(f"{w}: configs items must be top-level names, got '{c}'")
if app.mode and not re.fullmatch(r"[0-7]{3,4}", app.mode):
raise ConfigError(f"{w}: mode must be octal like 755")
def load(path: str | Path) -> Config:
path = Path(path)
with open(path, "rb") as f:
try:
data = tomllib.load(f)
except tomllib.TOMLDecodeError as e:
raise ConfigError(f"{path}: {e}") from None
server = Server()
_check_types(server, data.get("server", {}), "[server]")
apps, seen = [], set()
for i, raw in enumerate(data.get("app", [])):
raw = dict(raw)
try:
app = App(name=raw.pop("name"), kind=raw.pop("kind"), src=raw.pop("src"), dest=raw.pop("dest"))
except KeyError as e:
raise ConfigError(f"app #{i + 1}: missing {e}") from None
_check_types(app, raw, f"app '{app.name}'")
validate_app(app)
if app.name in seen:
raise ConfigError(f"duplicate app '{app.name}'")
seen.add(app.name)
apps.append(app)
return Config(path, server, apps)
def _toml_value(v) -> str:
if isinstance(v, bool):
return "true" if v else "false"
if isinstance(v, int):
return str(v)
if isinstance(v, list):
return "[" + ", ".join(_toml_value(x) for x in v) + "]"
if isinstance(v, dict):
return "{ " + ", ".join(f"{k} = {_toml_value(x)}" for k, x in v.items()) + " }"
s = str(v).replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
return f'"{s}"'
def app_to_toml(app: App) -> str:
"""[[app]] block with the non-default keys only."""
blank = App(name="", kind="", src="", dest="")
lines = ["[[app]]"]
for f in fields(app):
v = getattr(app, f.name)
if f.name in ("name", "kind", "src", "dest") or v != getattr(blank, f.name):
lines.append(f"{f.name} = {_toml_value(v)}")
return "\n".join(lines) + "\n"
def append_apps(path: Path, apps: list[App], drafts: list[App] = ()) -> None:
"""Append blocks to the file: hand-written comments and order are kept.
drafts are written commented out, for review."""
text = path.read_text() if path.exists() else ""
for a in apps:
validate_app(a)
text = text.rstrip("\n") + "\n\n" + app_to_toml(a)
for a in drafts:
block = "".join(f"# {line}\n" for line in app_to_toml(a).splitlines())
text = text.rstrip("\n") + "\n\n# TODO: check src / build / dest, then uncomment\n" + block
path.write_text(text)
def default_path() -> Path:
"""deploy.toml next to the sources (a checkout), else ~/.config/deployer/deploy.toml (an installed package)."""
local = Path(__file__).resolve().parent.parent / "deploy.toml"
if local.exists():
return local
return Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config") / "deployer" / "deploy.toml"
@@ -0,0 +1,176 @@
"""Guess how a local project is deployed, and import the apps that already run on the server."""
from __future__ import annotations
import os
import re
from dataclasses import dataclass
from pathlib import Path
from . import remote
from .config import App, Config
ENTRIES = ("main.py", "app.py", "app/main.py", "run.py", "server.py")
CONFIG_NAMES = ("configs", "config", ".env", "secrets.yaml", "secrets.yml")
CONFIG_RE = re.compile(r"(config|secret)[^/]*\.(ya?ml|json|toml|ini)$", re.I)
COMPOSE = ("docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml")
def norm(name: str) -> str:
return re.sub(r"[^a-z0-9]", "", name.lower())
def safe_name(name: str) -> str:
return re.sub(r"[^A-Za-z0-9_.-]", "-", name).strip("-.") or "app"
def guess_kind(path: Path) -> str:
if path.is_file():
return "artifact" if path.suffix in (".jar", ".war", ".zip") else "files"
names = {p.name for p in path.iterdir()}
if names & {"pom.xml", "build.gradle", "build.gradle.kts"}:
return "artifact"
if names & {"requirements.txt", "pyproject.toml", "main.py", "app.py"}:
return "python-service"
if names & set(COMPOSE):
return "docker"
return "files"
def guess_entry(path: Path) -> str:
return next((e for e in ENTRIES if (path / e).is_file()), "main.py")
def guess_configs(path: Path) -> list[str]:
found = [n for n in CONFIG_NAMES if (path / n).exists()]
found += sorted(p.name for p in path.iterdir()
if p.is_file() and CONFIG_RE.search(p.name) and p.name not in found and p.suffix != ".py")
return found
def java_build(path: Path) -> tuple[str, str]:
"""(build command, artifact glob) for Maven / Gradle projects."""
if (path / "pom.xml").is_file():
pom = (path / "pom.xml").read_text(errors="replace")
ext = "war" if re.search(r"<packaging>\s*war\s*</packaging>", pom) else "jar"
mvn = "./mvnw" if (path / "mvnw").is_file() else "mvn"
return f"{mvn} -B -q clean package -DskipTests", f"target/*.{ext}"
gradle = "./gradlew" if (path / "gradlew").is_file() else "gradle"
return f"{gradle} -q build -x test", "build/libs/*[!n].jar" # skip *-plain.jar
def new_app(path: Path, name: str | None = None, kind: str | None = None) -> App:
"""Defaults for a project that is not on the server yet."""
path = path.expanduser().resolve()
name = safe_name(name or path.stem)
kind = kind or guess_kind(path)
src = str(path).replace(str(Path.home()), "~", 1)
app = App(name=name, kind=kind, src=src, dest=name)
if kind.startswith("python"):
app.dest = f"python/{name}"
app.unit = name
app.entry = guess_entry(path)
app.description = name
app.configs = guess_configs(path)
elif kind == "docker":
app.dest = f"docker/{name}"
app.configs = []
elif kind == "artifact":
if path.is_dir():
app.build, app.artifact = java_build(path)
else:
app.src, app.artifact = str(Path(src).parent), path.name
app.dest = f"docker/java_services/data/app/{name}/"
else:
app.dest = f"other/{name}/" if path.is_file() else f"other/{name}"
return app
_IMPORT = r'''
for f in "$ROOT"/python/*/*.service; do
[ -f "$f" ] || continue
d=$(dirname "$f"); g() { sed -nE "s/^\s*$1\s*=\s*(.*\S)\s*$/\1/p" "$f" | head -1; }
entry=$(sed -nE 's/^ENTRY="(.*)"$/\1/p' "$d/start.sh" 2>/dev/null)
links=$(find "$d" -mindepth 1 -maxdepth 1 -type l -lname "$CFG/*" -printf '%f,')
echo "P|$(basename "$d")|$(basename "$f" .service)|$(g Description)|$(g User)|$(g SyslogIdentifier)|$(g EnvironmentFile)|$entry|$links"
done
for d in "$ROOT"/docker/*/; do
ls "$d" | grep -qE '^(docker-)?compose\.ya?ml$' && echo "D|$(basename "$d")"
done
for j in "$ROOT"/docker/java_services/data/app/*/*.jar; do
[ -f "$j" ] && echo "J|$(basename "$(dirname "$j")")|$(basename "$j")"
done
true
'''
@dataclass
class Imported:
app: App
matched: bool # a local source was found
def local_projects(cfg: Config) -> list[Path]:
found = []
for root in cfg.server.local_roots:
base = Path(os.path.expanduser(root))
if not base.is_dir():
continue
for p in base.glob("*/*"):
if p.is_dir() and not p.name.startswith("."):
found.append(p)
found += [q for q in p.iterdir() if q.is_dir() and not q.name.startswith(".")
and ((q / "pom.xml").exists() or (q / "build.gradle").exists())]
return found
def match_local(projects: list[Path], names: list[str], unit: str = "") -> Path | None:
keys = [norm(n) for n in names if n]
for p in projects: # exact name
if norm(p.name) in keys:
return p
if unit: # the project ships a unit file with the same name
for p in projects:
if (p / f"{unit}.service").is_file():
return p
return None
def tilde(p: Path) -> str:
return str(p).replace(str(Path.home()), "~", 1)
def import_server(cfg: Config, log=print) -> list[Imported]:
out = remote.run(cfg.server, _IMPORT, log=None, ROOT=cfg.server.app_root, CFG=cfg.server.configs_root)
projects = local_projects(cfg)
known = {a.name for a in cfg.apps} | {cfg.dest_path(a) for a in cfg.apps}
res = []
for line in out.splitlines():
f = line.split("|")
if f[0] == "P" and len(f) == 9:
_, d, unit, desc, user, sid, envf, entry, links = f
app = App(name=d, kind="python-update", src="", dest=f"python/{d}", unit=unit, entry=entry or "main.py",
user=user or "user", description=desc, syslog_id="" if sid == unit else sid, env_file=envf,
configs=[x for x in links.split(",") if x])
local = match_local(projects, [d, sid], unit)
elif f[0] == "D" and len(f) == 2:
app = App(name=f[1], kind="docker", src="", dest=f"docker/{f[1]}")
local = match_local(projects, [f[1]])
if not local:
continue # image-only projects: nothing to deploy from here
elif f[0] == "J" and len(f) == 3:
dname, jar = f[1], f[2]
stem = re.sub(r"[-_]?\d[\w.]*$", "", Path(jar).stem)
app = App(name=f"{dname}-jar", kind="artifact", src="", dest=f"docker/java_services/data/app/{dname}/{jar}",
post="docker restart JavaApplications", health_container="JavaApplications")
local = match_local(projects, [stem, dname])
if local:
app.build, app.artifact = java_build(local)
app.artifact = app.artifact.replace("*.", f"{stem}*.", 1)
else:
continue
if app.name in known or cfg.dest_path(app) in known:
continue
app.src = tilde(local) if local else ""
res.append(Imported(app, local is not None))
log(f"{'+' if local else '?'} {app.kind:15} {app.name:20} {app.src or '(no local source found)'}")
return res
@@ -0,0 +1,6 @@
from .base import KINDS_BY_NAME, Kind, Options, Plan, link_configs # noqa: F401
from . import docker, files, python # noqa: F401 (register the kinds)
def get(name: str) -> Kind:
return KINDS_BY_NAME[name]
@@ -0,0 +1,159 @@
"""Shared pieces of the install kinds."""
from __future__ import annotations
import shlex
from dataclasses import dataclass, field
from pathlib import Path
from .. import remote
from ..config import App, Config
@dataclass
class Options:
build: bool = True
install_deps: bool = False # force pip install even if requirements.txt did not change
restart: bool = True
copy_configs: bool = True # upload missing config items and link them into the app dir
delete: bool | None = None # None = app setting
@dataclass
class Plan:
cfg: Config
app: App
opts: Options
dest: str
first_install: bool
rsync_src: str
filters: list[str]
delete: bool
extra: list[str] = field(default_factory=list) # more rsync options
# top-level entries of dest on the server: name -> f (file) | d (dir) | l (link)
remote_entries: dict[str, str] = field(default_factory=dict)
changes: list[remote.Change] = field(default_factory=list)
notes: list[str] = field(default_factory=list)
artifact_file: Path | None = None
git_rev: str = ""
@property
def server(self):
return self.cfg.server
class Kind:
name = ""
# Server-side items that are never synced, relative to the app dir
protected: tuple[str, ...] = ()
dest_is_file = False
def config_items(self, plan: Plan) -> list[tuple[str, str]]:
"""(name in the app dir, absolute target under configs_root) of config links."""
return []
def local_config(self, plan: Plan, name: str) -> Path:
return plan.app.src_path / name
def install(self, plan: Plan, log) -> None:
pass
def health(self, plan: Plan, log) -> bool:
return health_extra(plan, log)
def after_restore(self, plan: Plan, log) -> None:
self.install(plan, log)
def status_script(self) -> str:
"""Bash fragment printing a one-word state; gets $DEST, $UNIT, $C."""
return ('if [ -n "$C" ]; then docker inspect -f "{{.State.Status}}" "$C" 2>/dev/null || echo missing; '
'elif [ -e "$DEST" ]; then echo present; else echo missing; fi')
def logs_command(self, cfg: Config, app: App, dest: str) -> str:
if app.health_container:
return f"docker logs -f --tail 100 {shlex.quote(app.health_container)}"
return f"tail -n 50 -f {shlex.quote(cfg.server.state_dir + '/deploy.log')}"
_WAIT_CONTAINER = r'''
start=$SECONDS; end=$((SECONDS + TIMEOUT))
while :; do
st=$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$C" 2>/dev/null || echo missing)
case $st in
# "running" must hold for a few seconds: a crash loop shows up as restarting/exited
healthy) echo "$C: $st"; exit 0 ;;
running) [ $SECONDS -ge $((start + 6)) ] && { echo "$C: $st"; exit 0; } ;;
unhealthy|exited|dead|missing) echo "$C: $st"; docker logs --tail 50 "$C" 2>&1; exit 1 ;;
esac
[ $SECONDS -lt $end ] || { echo "$C: still $st after ${TIMEOUT}s"; docker logs --tail 50 "$C" 2>&1; exit 1; }
sleep 2
done
'''
_CHECK_URL = r'''
end=$((SECONDS + TIMEOUT))
while :; do
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 "$URL")
case $code in 2*|3*) echo "$URL: HTTP $code"; exit 0 ;; esac
[ $SECONDS -lt $end ] || { echo "$URL: HTTP ${code:-none} after ${TIMEOUT}s"; exit 1; }
sleep 2
done
'''
def health_extra(plan: Plan, log) -> bool:
"""Optional checks for any kind: a container and/or an URL (requested from the server itself)."""
app = plan.app
try:
if app.health_container:
remote.run(plan.server, _WAIT_CONTAINER, log, C=app.health_container, TIMEOUT=app.health_timeout)
if app.health_url:
remote.run(plan.server, _CHECK_URL, log, URL=app.health_url, TIMEOUT=app.health_timeout)
except remote.RemoteError:
return False
return True
_LINK = r'''
for i in "${!NAMES[@]}"; do
n=${NAMES[$i]}; t=${TARGETS[$i]}
[ -e "$t" ] || { echo "WARN: $t is missing, $n is not linked"; continue; }
ln -s "$t" "$DEST/$n" && echo "linked $n -> $t"
done
find "$CFGROOT" -mindepth 1 -type d -exec chmod 700 {} + ; find "$CFGROOT" -type f -exec chmod 600 {} +
'''
def link_configs(plan: Plan, log) -> None:
"""Config items missing in the app dir: upload the local copy into the configs dir (never overwriting)
and link it into the app dir."""
kind = KINDS_BY_NAME[plan.app.kind]
names, targets = [], []
for name, target in kind.config_items(plan):
state = plan.remote_entries.get(name)
if state == "l":
continue
if state:
log(f"WARN: {name} is a real {'dir' if state == 'd' else 'file'} on the server, left as is")
continue
if not names:
log("== configs")
local = kind.local_config(plan, name)
if local.is_dir():
remote.rsync(plan.server, f"{local}/", f"{target}/", filters=["- .git"], extra=["--ignore-existing"],
log=log)
elif local.is_file():
remote.rsync(plan.server, str(local), target, extra=["--ignore-existing"], log=log)
# No local copy: link anyway if the target already exists on the server
names.append(name)
targets.append(target)
if names:
remote.run(plan.server, _LINK, log, NAMES=names, TARGETS=targets, DEST=plan.dest,
CFGROOT=plan.cfg.server.configs_root)
KINDS_BY_NAME: dict[str, Kind] = {}
def register(cls: type[Kind]) -> type[Kind]:
KINDS_BY_NAME[cls.name] = cls()
return cls
@@ -0,0 +1,68 @@
"""docker: compose project in ~/app/docker/<app>, .env linked from configs/docker/<app>.env."""
from __future__ import annotations
import shlex
from pathlib import Path, PurePosixPath
from .. import remote
from ..config import App, Config
from .base import Kind, Plan, health_extra, register
_UP = r'''
cd "$DEST" || exit 1
if [ ! -e Makefile ] && [ ! -L Makefile ] && [ -f "$APPROOT/docker/Makefile" ] && [ "$(dirname "$DEST")" = "$APPROOT/docker" ]; then
ln -s "$APPROOT/docker/Makefile" Makefile && echo "linked Makefile"
fi
docker compose config -q || exit 1
if [ "$RESTART" = 1 ]; then docker compose up -d --build --remove-orphans; else docker compose build; fi
'''
_HEALTH = r'''
cd "$DEST" || exit 1
start=$SECONDS; end=$((SECONDS + TIMEOUT))
while :; do
ps=$(docker compose ps -a --format '{{.Name}} {{.State}} {{.Health}}')
bad=$(echo "$ps" | awk '$2=="exited" || $2=="dead" || $3=="unhealthy"')
if [ -n "$bad" ]; then echo "$ps"; docker compose logs --tail 30; exit 1; fi
wait=$(echo "$ps" | awk 'NF && ($2!="running" || ($3!="" && $3!="healthy"))')
if [ -z "$wait" ] && [ $SECONDS -ge $((start + 6)) ]; then echo "$ps"; exit 0; fi
[ $SECONDS -lt $end ] || { echo "$ps"; echo "not ready after ${TIMEOUT}s"; exit 1; }
sleep 3
done
'''
@register
class Docker(Kind):
name = "docker"
protected = (".env", "data", "Makefile")
def config_items(self, plan: Plan) -> list[tuple[str, str]]:
app = PurePosixPath(plan.dest).name
root = f"{plan.cfg.server.configs_root}/docker"
return [(".env", f"{root}/{app}.env")] + [(n, f"{root}/{app}/{n}") for n in plan.app.configs]
def local_config(self, plan: Plan, name: str) -> Path:
p = plan.app.src_path / name
if name == ".env" and not p.exists():
return plan.app.src_path / ".env.example"
return p
def install(self, plan: Plan, log) -> None:
remote.run(plan.server, _UP, log, DEST=plan.dest, APPROOT=plan.cfg.server.app_root,
RESTART=int(plan.opts.restart))
def health(self, plan: Plan, log) -> bool:
try:
remote.run(plan.server, _HEALTH, log, DEST=plan.dest, TIMEOUT=plan.app.health_timeout)
except remote.RemoteError:
return False
return health_extra(plan, log)
def status_script(self) -> str:
return ('cd "$DEST" 2>/dev/null || { echo missing; exit; }; '
't=$(docker compose ps -a -q 2>/dev/null | wc -l); r=$(docker compose ps -q --status running 2>/dev/null | wc -l); '
'[ "$t" -gt 0 ] && echo "running $r/$t" || echo stopped')
def logs_command(self, cfg: Config, app: App, dest: str) -> str:
return f"cd {shlex.quote(dest)} && docker compose logs -f --tail=100"
@@ -0,0 +1,42 @@
"""files: a dir or a file copied to any place; artifact: a built file (jar, war, ...) under a fixed name.
Both run the optional `post` command on the server after the copy (skipped when restart is off)."""
from __future__ import annotations
from .. import remote
from .base import Kind, Plan, register
# mode is applied by rsync (--chmod), so unchanged files keep it too
_POST = r'''
if [ -n "$POST" ]; then
if [ "$IS_FILE" = 1 ]; then cd "$(dirname "$DEST")"; else cd "$DEST"; fi || exit 1
echo "+ $POST"; bash -c "$POST" || exit 1
fi
'''
def run_post(plan: Plan, log, is_file: bool) -> None:
if plan.app.post:
remote.run(plan.server, _POST, log, DEST=plan.dest, POST=plan.app.post, IS_FILE=int(is_file))
@register
class Files(Kind):
name = "files"
def install(self, plan: Plan, log) -> None:
if plan.opts.restart:
run_post(plan, log, is_file=plan.app.src_path.is_file())
else:
log("restart is off: post command skipped")
@register
class Artifact(Kind):
name = "artifact"
dest_is_file = True
def install(self, plan: Plan, log) -> None:
if plan.opts.restart:
run_post(plan, log, is_file=True)
else:
log("restart is off: post command skipped")
@@ -0,0 +1,103 @@
"""python-service: full install via ~/app/python/make_py_service.sh; python-update: code + changed deps."""
from __future__ import annotations
import shlex
from pathlib import PurePosixPath
from .. import remote
from ..config import App, Config
from .base import Kind, Plan, health_extra, register
_DEPS = r'''
cd "$DEST" || exit 1
[ -x .venv/bin/python ] || { echo "no .venv in $DEST: deploy it as python-service first"; exit 1; }
if [ -f requirements.txt ]; then
new=$(sha256sum requirements.txt | cut -d' ' -f1); old=$(cat .deploy_req.sha 2>/dev/null)
if [ "$FORCE" = 1 ] || [ "$new" != "$old" ]; then
echo "installing requirements"
.venv/bin/pip install --disable-pip-version-check -q -r requirements.txt || exit 1
echo "$new" > .deploy_req.sha
else
echo "requirements.txt unchanged"
fi
fi
'''
_SERVICE = r'''
cd "$DEST" || exit 1
args=(--entry "$ENTRY" --user "$RUSER" --syslog-id "$SID")
[ -n "$ENVF" ] && args+=(--env-file "$ENVF")
"$MAKE" "$DEST" "$UNIT" "$DESC" "${args[@]}" || exit 1
[ -f requirements.txt ] && sha256sum requirements.txt | cut -d' ' -f1 > .deploy_req.sha
'''
_RESTART = r'''sudo systemctl restart "$UNIT" && echo "restarted $UNIT"'''
_HEALTH = r'''
sleep 3; a=$(systemctl show -p NRestarts --value "$UNIT")
sleep 8; b=$(systemctl show -p NRestarts --value "$UNIT"); st=$(systemctl is-active "$UNIT")
if [ "$st" = active ] && [ "$a" = "$b" ]; then echo "$UNIT: active, NRestarts=$b"; exit 0; fi
echo "$UNIT: $st, NRestarts $a -> $b"
journalctl -u "$UNIT" -n 40 --no-pager
exit 1
'''
class PythonBase(Kind):
protected = (".env", "start.sh", "*.service", "Makefile", ".deploy_req.sha")
def config_items(self, plan: Plan) -> list[tuple[str, str]]:
"""A single dir item becomes configs/python/<App> itself, otherwise each item goes inside it
(.env as app.env): the same layout as the existing apps."""
app = PurePosixPath(plan.dest).name
root = f"{plan.cfg.server.configs_root}/python/{app}"
items = plan.app.configs
if len(items) == 1 and (plan.app.src_path / items[0]).is_dir():
return [(items[0], root)]
return [(n, f"{root}/{'app.env' if n == '.env' else n}") for n in items]
def restart(self, plan: Plan, log) -> None:
if plan.opts.restart:
remote.run(plan.server, _RESTART, log, UNIT=plan.app.unit)
def health(self, plan: Plan, log) -> bool:
try:
remote.run(plan.server, _HEALTH, log, UNIT=plan.app.unit)
except remote.RemoteError:
return False
return health_extra(plan, log)
def deps(self, plan: Plan, log) -> None:
remote.run(plan.server, _DEPS, log, DEST=plan.dest, FORCE=int(plan.opts.install_deps))
def after_restore(self, plan: Plan, log) -> None:
self.deps(plan, log)
self.restart(plan, log)
def status_script(self) -> str:
return 'systemctl is-active "$UNIT" 2>/dev/null || true'
def logs_command(self, cfg: Config, app: App, dest: str) -> str:
return f"journalctl -u {shlex.quote(app.unit)} -n 100 -f"
@register
class PythonService(PythonBase):
name = "python-service"
def install(self, plan: Plan, log) -> None:
a = plan.app
remote.run(plan.server, _SERVICE, log, DEST=plan.dest, UNIT=a.unit, DESC=a.description or a.name,
ENTRY=a.entry, RUSER=a.user, SID=a.syslog_id or a.unit, ENVF=a.env_file,
MAKE=f"{plan.cfg.server.app_root}/python/make_py_service.sh")
# make_py_service.sh installs requirements.txt itself
self.restart(plan, log)
@register
class PythonUpdate(PythonBase):
name = "python-update"
def install(self, plan: Plan, log) -> None:
self.deps(plan, log)
self.restart(plan, log)
@@ -0,0 +1,280 @@
"""Deploy steps: preview() builds a Plan (nothing changes on the server), execute() applies it."""
from __future__ import annotations
import os
import glob
import shlex
import subprocess
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from . import kinds, remote, snapshots
from .config import App, Config
from .kinds import Options, Plan
def _history_path() -> Path:
"""history.log next to the sources (a checkout), else ~/.local/state/deployer/ (an installed package)."""
local = Path(__file__).resolve().parent.parent
if os.access(local, os.W_OK):
return local / "history.log"
return Path(os.environ.get("XDG_STATE_HOME") or Path.home() / ".local/state") / "deployer" / "history.log"
HISTORY = _history_path()
class DeployError(RuntimeError):
pass
@dataclass
class Result:
ok: bool
snapshot: str | None
message: str
_PROBE = r'''
if [ -d "$DEST" ]; then
echo "EXISTS d"
find "$DEST" -mindepth 1 -maxdepth 1 -printf 'E %y %f\n'
find "$DEST" -mindepth 2 -maxdepth 2 -type l -printf 'L %P\n'
elif [ -e "$DEST" ]; then echo "EXISTS f"
else echo "EXISTS -"
fi
'''
def git_rev(src: Path) -> str:
try:
rev = subprocess.run(["git", "-C", str(src), "rev-parse", "--short", "HEAD"], capture_output=True,
text=True, timeout=10).stdout.strip()
if not rev:
return ""
dirty = subprocess.run(["git", "-C", str(src), "status", "--porcelain"], capture_output=True, text=True,
timeout=10).stdout.strip()
return rev + ("-dirty" if dirty else "")
except (OSError, subprocess.SubprocessError):
return ""
def find_artifact(app: App) -> Path:
files = [Path(p) for p in glob.glob(str(app.src_path / app.artifact)) if Path(p).is_file()]
if not files:
raise DeployError(f"no file matches {app.artifact} in {app.src_path}")
return max(files, key=lambda p: p.stat().st_mtime)
def probe(cfg: Config, dest: str) -> tuple[str, dict[str, str], list[str]]:
"""(d|f|-, top-level entries, symlinks two levels deep)."""
out = remote.run(cfg.server, _PROBE, DEST=dest)
exists, entries, links = "-", {}, []
for line in out.splitlines():
tag, _, rest = line.partition(" ")
if tag == "EXISTS":
exists = rest
elif tag == "E":
t, _, name = rest.partition(" ")
entries[name] = "l" if t == "l" else ("d" if t == "d" else "f")
elif tag == "L":
links.append(rest)
return exists, entries, links
def preview(cfg: Config, app: App, opts: Options, log=print) -> Plan:
kind = kinds.get(app.kind)
src = app.src_path
if not src.exists():
raise DeployError(f"local source not found: {src}")
if opts.build and app.build:
log(f"== build: {app.build}")
remote.run_local(app.build, src if src.is_dir() else src.parent, log, app.build_env)
dest = cfg.dest_path(app)
artifact = None
if app.kind == "artifact":
artifact = find_artifact(app)
if app.dest.endswith("/"):
dest = f"{dest}/{artifact.name}"
rsync_src = str(artifact)
elif src.is_file():
if app.dest.endswith("/"):
dest = f"{dest}/{src.name}"
rsync_src = str(src)
else:
rsync_src = f"{src}/"
is_file = rsync_src != f"{src}/"
log(f"== probe {cfg.server.target}:{dest}")
exists, entries, links = probe(cfg, dest)
if exists == ("d" if is_file else "f"):
raise DeployError(f"{dest} is a {'directory' if exists == 'd' else 'file'} on the server")
first = exists == "-"
if first and app.kind == "python-update":
raise DeployError(f"{dest} does not exist: deploy it with kind python-service first")
protected = set() if is_file else {*kind.protected, *app.configs, *links,
*(n for n, t in entries.items() if t == "l")}
filters = [] if is_file else remote.build_filters(protected=protected,
exclude=[*cfg.server.dev_exclude, *app.exclude],
use_gitignore=app.use_gitignore)
delete = (opts.delete if opts.delete is not None else app.delete) and not is_file
extra = [f"--chmod=F{app.mode}"] if app.mode else []
plan = Plan(cfg=cfg, app=app, opts=opts, dest=dest, first_install=first, rsync_src=rsync_src,
filters=filters, delete=delete, extra=extra, remote_entries=entries, artifact_file=artifact,
git_rev=git_rev(src if src.is_dir() else src.parent))
rsync_dest = dest if is_file else f"{dest}/"
plan.changes = remote.rsync(cfg.server, rsync_src, rsync_dest, filters=filters, delete=delete,
extra=extra, dry_run=True)
if first:
plan.notes.append(f"first install: {dest} will be created")
if artifact:
plan.notes.append(f"artifact: {artifact.relative_to(src)} ({artifact.stat().st_size // 1024} KiB, "
f"{datetime.fromtimestamp(artifact.stat().st_mtime):%Y-%m-%d %H:%M})")
if not is_file and opts.copy_configs:
for name, target in kind.config_items(plan):
state = entries.get(name)
if state is None:
local = kind.local_config(plan, name)
plan.notes.append(f"config {name}: link -> {target}"
+ (f" (upload {local.name} if missing)" if local.exists() else " (no local copy)"))
elif state != "l":
plan.notes.append(f"config {name}: real {'dir' if state == 'd' else 'file'} on the server, kept")
deleted = sum(c.action == "delete" for c in plan.changes)
if deleted:
plan.notes.append(f"{deleted} file(s) on the server will be DELETED")
if plan.git_rev.endswith("-dirty"):
plan.notes.append("working tree has uncommitted changes")
return plan
def _record(plan: Plan, action: str, result: str, snap: str | None, log) -> None:
ts = datetime.now().isoformat(timespec="seconds")
line = f"{ts} {plan.app.name} {action} {plan.app.kind} {result} rev={plan.git_rev or '-'} snap={snap or '-'}"
try:
remote.run(plan.server, 'mkdir -p "$STATE" && printf "%s\\n" "$LINE" >> "$STATE/deploy.log"',
STATE=plan.server.state_dir, LINE=line)
except remote.RemoteError as e:
log(f"WARN: deploy.log not written: {e}")
HISTORY.parent.mkdir(parents=True, exist_ok=True)
with open(HISTORY, "a") as f:
f.write(line + "\n")
def execute(plan: Plan, log=print) -> Result:
kind = kinds.get(plan.app.kind)
is_file = plan.rsync_src != f"{plan.app.src_path}/"
snap = None
try:
if not plan.first_install:
added = [c.path for c in plan.changes if c.action == "new" and not c.path.endswith("/")]
snap = snapshots.create(plan.server, plan.app.name, plan.dest, [] if is_file else added, log)
log(f"== snapshot {snap}")
log("== sync")
rsync_dest = plan.dest if is_file else f"{plan.dest}/"
remote.rsync(plan.server, plan.rsync_src, rsync_dest, filters=plan.filters, delete=plan.delete,
extra=plan.extra, log=log)
if not is_file and plan.opts.copy_configs:
kinds.link_configs(plan, log)
log("== install")
kind.install(plan, log)
ok = True
if plan.opts.restart:
log("== health")
ok = kind.health(plan, log)
except (remote.RemoteError, DeployError) as e:
log(f"ERROR: {e}")
_record(plan, "deploy", "FAILED", snap, log)
return Result(False, snap, str(e))
_record(plan, "deploy", "ok" if ok else "UNHEALTHY", snap, log)
msg = "deployed" if ok else "deployed, but the health check failed"
log(f"== {msg}" + (f" (rollback: snapshot {snap})" if snap and not ok else ""))
return Result(ok, snap, msg)
def base_plan(cfg: Config, app: App, opts: Options | None = None) -> Plan:
"""Plan without a preview: for rollback, status and logs."""
dest = cfg.dest_path(app)
if app.kind == "artifact" and app.dest.endswith("/"):
dest = f"{dest}/{find_artifact(app).name}"
elif app.kind == "files" and app.src_path.is_file() and app.dest.endswith("/"):
dest = f"{dest}/{app.src_path.name}"
return Plan(cfg=cfg, app=app, opts=opts or Options(), dest=dest, first_install=False, rsync_src="",
filters=[], delete=False, git_rev=git_rev(app.src_path if app.src_path.is_dir() else app.src_path.parent))
def rollback(cfg: Config, app: App, ts: str | None = None, log=print) -> Result:
kind = kinds.get(app.kind)
plan = base_plan(cfg, app)
snaps = snapshots.listing(cfg.server, app.name)
if not snaps:
return Result(False, None, "no snapshots")
ts = ts or snaps[0]
if ts not in snaps:
return Result(False, None, f"no snapshot {ts}")
is_file = kind.dest_is_file or app.src_path.is_file()
try:
log(f"== restore {ts}")
snapshots.restore(cfg.server, app.name, plan.dest, ts, is_file, log)
kind.after_restore(plan, log)
ok = kind.health(plan, log) if plan.opts.restart else True
except (remote.RemoteError, DeployError) as e:
log(f"ERROR: {e}")
_record(plan, "rollback", "FAILED", ts, log)
return Result(False, ts, str(e))
_record(plan, "rollback", "ok" if ok else "UNHEALTHY", ts, log)
return Result(ok, ts, f"rolled back to {ts}" + ("" if ok else ", health check failed"))
def code_state(local: str, deployed: str) -> str:
"""Local git rev vs the deployed one: same | changed | dirty (same commit, uncommitted changes) | ? (unknown)."""
if not local or not deployed or deployed == "-":
return "?"
base = local.removesuffix("-dirty")
if base != deployed.removesuffix("-dirty"):
return "changed"
return "same" if local == deployed == base else "dirty"
def deployed_rev(fields: list[str]) -> str:
"""Rev from a deploy.log line; "" when the server code is not known to match it (failed deploy, rollback)."""
if len(fields) >= 6 and fields[2] == "deploy" and fields[4] in ("ok", "UNHEALTHY") \
and fields[5].startswith("rev="):
return fields[5][4:]
return ""
def status_all(cfg: Config) -> dict[str, tuple[str, str, str]]:
"""name -> (state, last deploy line, code state vs local) in one ssh call."""
parts = []
for a in cfg.apps:
k = kinds.get(a.kind)
dest = cfg.dest_path(a)
parts.append(f"DEST={shlex.quote(dest)} UNIT={shlex.quote(a.unit)} C={shlex.quote(a.health_container)}\n"
f"printf 'S %s ' {shlex.quote(a.name)}; ( {k.status_script()} ) | head -1")
parts.append(f'[ -f {shlex.quote(cfg.server.state_dir)}/deploy.log ] && '
f'tail -n 1000 {shlex.quote(cfg.server.state_dir)}/deploy.log | sed "s/^/D /"')
out = remote.run(cfg.server, "\n".join(parts) + "\ntrue\n", check=False)
states, last, deployed = {}, {}, {}
for line in out.splitlines():
if line.startswith("S "):
_, name, *rest = line.split(" ", 2)
states[name] = rest[0].strip() if rest else "?"
elif line.startswith("D "):
f = line[2:].split()
if len(f) >= 5:
last[f[1]] = f"{f[0][:16].replace('T', ' ')} {f[2]} {f[4]}"
deployed[f[1]] = deployed_rev(f)
return {a.name: (states.get(a.name, "?"), last.get(a.name, ""),
code_state(git_rev(a.src_path if a.src_path.is_dir() else a.src_path.parent),
deployed.get(a.name, "")))
for a in cfg.apps}
def logs_command(cfg: Config, app: App) -> str:
return kinds.get(app.kind).logs_command(cfg, app, cfg.dest_path(app))
@@ -0,0 +1,139 @@
"""ssh / rsync wrappers. One multiplexed ssh connection is reused by all calls of a run."""
from __future__ import annotations
import os
import shlex
import subprocess
from dataclasses import dataclass
from typing import Callable, Iterable
from .config import Server
Log = Callable[[str], None]
# Never synced (and so never deleted on the server)
ALWAYS_EXCLUDE = [".git", ".idea", ".vscode", ".venv", "venv", "__pycache__", ".pytest_cache",
".ruff_cache", ".mypy_cache", "*.pyc", ".DS_Store", "*.iml"]
class RemoteError(RuntimeError):
pass
def ssh_opts(server: Server) -> list[str]:
return ["-o", "BatchMode=yes", "-o", "ConnectTimeout=10", "-o", "ControlMaster=auto",
"-o", f"ControlPath={os.path.expanduser('~/.ssh')}/cm-%r@%h:%p", "-o", "ControlPersist=120",
"-p", str(server.port)]
def _stream(cmd: list[str], log: Log | None, stdin: str | None = None, cwd=None, env=None) -> tuple[int, str]:
proc = subprocess.Popen(cmd, stdin=subprocess.PIPE if stdin is not None else subprocess.DEVNULL,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, cwd=cwd, env=env,
errors="replace")
if stdin is not None:
try:
proc.stdin.write(stdin)
proc.stdin.close()
except BrokenPipeError:
pass # ssh already exited; its message is in the output below
out = []
for line in proc.stdout:
line = line.rstrip("\n")
out.append(line)
if log:
log(line)
return proc.wait(), "\n".join(out)
def shell_vars(**values) -> str:
"""Bash assignments for passing values into a remote script."""
lines = []
for k, v in values.items():
if isinstance(v, (list, tuple)):
lines.append(f"{k}=({' '.join(shlex.quote(str(x)) for x in v)})")
else:
lines.append(f"{k}={shlex.quote(str(v))}")
return "\n".join(lines) + "\n"
def run(server: Server, script: str, log: Log | None = None, check: bool = True, **values) -> str:
"""Run a bash script on the server; values become shell variables in front of it."""
body = "set -uo pipefail\n" + shell_vars(**values) + script
rc, out = _stream(["ssh", *ssh_opts(server), server.target, "bash", "-s"], log, stdin=body)
if rc == 255 or (check and rc != 0):
raise RemoteError(f"remote command failed (exit {rc})" + (f": {out.splitlines()[-1]}" if out else ""))
return out
def run_local(cmd: str, cwd, log: Log | None, env: dict[str, str] | None = None) -> None:
full_env = {**os.environ, **{k: os.path.expandvars(os.path.expanduser(v)) for k, v in (env or {}).items()}}
rc, _ = _stream(["bash", "-c", cmd], log, cwd=cwd, env=full_env)
if rc != 0:
raise RemoteError(f"local command failed (exit {rc}): {cmd}")
def interactive(server: Server, command: str) -> int:
"""ssh with a terminal (logs -f); the caller suspends the TUI."""
return subprocess.call(["ssh", "-t", *ssh_opts(server), server.target, command])
@dataclass
class Change:
action: str # new | update | delete
path: str
def rsync_cmd(server: Server, src: str, dest: str, *, filters: Iterable[str] = (), delete: bool = False,
dry_run: bool = False, extra: Iterable[str] = ()) -> list[str]:
# -c: compare by content, so a touched but unchanged file is not reported (or sent) as changed
cmd = ["rsync", "-a", "-c", "-z", "--mkpath", "-i", "--out-format=%i %n%L",
"-e", shlex.join(["ssh", *ssh_opts(server)])]
if delete:
cmd.append("--delete")
if dry_run:
cmd.append("-n")
cmd += list(extra)
cmd += [f"--filter={f}" for f in filters]
return cmd + [src, f"{server.target}:{dest}"]
def build_filters(*, protected: Iterable[str], exclude: Iterable[str], use_gitignore: bool) -> list[str]:
"""rsync filter rules. Anchored excludes keep server-side items (config links, .venv, data) untouched;
excluded paths are also protected from --delete."""
rules = []
for p in sorted(set(protected)):
rules.append(f"- /{p.strip('/')}")
for p in [*ALWAYS_EXCLUDE, *exclude]:
rules.append(f"- {p}")
if use_gitignore:
rules.append(":- .gitignore")
return rules
def parse_itemize(output: str) -> list[Change]:
changes = []
for line in output.splitlines():
if line.startswith("*deleting"):
changes.append(Change("delete", line.split(None, 1)[1]))
continue
if len(line) < 13 or line[11] != " ":
continue
flags, path = line[:11], line[12:]
if path in ("./", "."):
continue
y, x = flags[0], flags[1]
if y not in "<>ch":
continue # "." = attributes only
if x == "d" and y == "c" and "+" not in flags:
continue
changes.append(Change("new" if flags[2:].strip("+") == "" else "update", path))
return changes
def rsync(server: Server, src: str, dest: str, *, filters: Iterable[str] = (), delete: bool = False,
dry_run: bool = False, extra: Iterable[str] = (), log: Log | None = None) -> list[Change]:
cmd = rsync_cmd(server, src, dest, filters=filters, delete=delete, dry_run=dry_run, extra=extra)
rc, out = _stream(cmd, None if dry_run else log)
if rc != 0:
raise RemoteError(f"rsync failed (exit {rc})" + (f": {out.splitlines()[-1]}" if out else ""))
return parse_itemize(out)
@@ -0,0 +1,70 @@
"""Remote snapshots: <state_dir>/snapshots/<app>/<ts>.tgz (+ <ts>.added: files the deploy created)."""
from __future__ import annotations
import re
from . import remote
from .config import Server
TS_RE = re.compile(r"^\d{8}-\d{6}$")
# Heavy or runtime-owned items: not part of the code, never snapshotted or restored
# .deploy_req.sha stays out so that a rollback sees changed requirements and reinstalls them
SKIP_DIRS = [".venv", "data", "logs", "node_modules", ".deploy_req.sha"]
_CREATE = r'''
d="$SNAP/$APP"; mkdir -p "$d"; chmod 700 "$STATE" "$STATE/snapshots" "$d"
ts=$(date +%Y%m%d-%H%M%S)
if [ -f "$DEST" ]; then
tar czf "$d/$ts.tgz" -C "$(dirname "$DEST")" "$(basename "$DEST")"; rc=$?
elif [ -d "$DEST" ]; then
ex=(); for s in "${SKIP[@]}"; do ex+=(--exclude="./$s"); done
tar czf "$d/$ts.tgz" --ignore-failed-read "${ex[@]}" -C "$DEST" .; rc=$?
else
echo "nothing to snapshot"; exit 0
fi
# tar: 1 = some files changed while reading, still a usable archive
[ $rc -le 1 ] || { rm -f "$d/$ts.tgz"; echo "snapshot failed"; exit 1; }
chmod 600 "$d/$ts.tgz"
printf '%s\n' "${ADDED[@]}" | sed '/^$/d' > "$d/$ts.added"
ls -1t "$d"/*.tgz | tail -n +$((KEEP + 1)) | while read -r f; do rm -f "$f" "${f%.tgz}.added"; done
echo "SNAPSHOT $ts"
'''
_LIST = r'''ls -1t "$SNAP/$APP" 2>/dev/null | sed -n 's/\.tgz$//p' '''
_RESTORE = r'''
f="$SNAP/$APP/$TS.tgz"; [ -f "$f" ] || { echo "no snapshot $TS"; exit 1; }
if [ "$IS_FILE" = 1 ]; then
mkdir -p "$(dirname "$DEST")"; tar xzf "$f" -C "$(dirname "$DEST")"
else
mkdir -p "$DEST"
# Files that this deploy created did not exist before it
if [ -f "$SNAP/$APP/$TS.added" ]; then
while IFS= read -r p; do
case "$p" in /*|*..*) continue ;; esac
[ -f "$DEST/$p" ] || [ -L "$DEST/$p" ] && rm -f "$DEST/$p" && echo "removed $p"
done < "$SNAP/$APP/$TS.added"
fi
tar xzf "$f" -C "$DEST" && echo "restored $TS into $DEST"
fi
'''
def create(server: Server, app: str, dest: str, added: list[str], log=None) -> str | None:
out = remote.run(server, _CREATE, log, SNAP=server.snapshots, STATE=server.state_dir, APP=app, DEST=dest,
KEEP=server.keep, SKIP=SKIP_DIRS, ADDED=added)
for line in out.splitlines():
if line.startswith("SNAPSHOT "):
return line.split()[1]
return None
def listing(server: Server, app: str) -> list[str]:
out = remote.run(server, _LIST, check=False, SNAP=server.snapshots, APP=app)
return [x for x in out.split() if TS_RE.match(x)]
def restore(server: Server, app: str, dest: str, ts: str, is_file: bool, log=None) -> None:
if not TS_RE.match(ts):
raise ValueError(f"bad snapshot id: {ts}")
remote.run(server, _RESTORE, log, SNAP=server.snapshots, APP=app, DEST=dest, TS=ts, IS_FILE=int(is_file))
@@ -0,0 +1,528 @@
"""Textual UI: app list, per-run options, preview → confirm → deploy, rollback, logs, add/import."""
from __future__ import annotations
import os
import shlex
import subprocess
from pathlib import Path
from rich.markup import escape
from rich.text import Text
from textual import on, work
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, Vertical, VerticalScroll
from textual.coordinate import Coordinate
from textual.screen import ModalScreen
from textual.theme import Theme
from textual.widgets import Button, Checkbox, DataTable, Footer, Input, Label, OptionList, RichLog, Select, Static
from . import __version__, config, detect, pipeline, remote, snapshots
from .config import KINDS, Config
from .kinds import Options, Plan
# Classic CGA palette of Norton / Midnight Commander (same as ServiceManagerTUI)
BLACK, BLUE, CYAN, GRAY, WHITE, YELLOW = "#000000", "#0000AA", "#00AAAA", "#AAAAAA", "#FFFFFF", "#FFFF55"
MIDNIGHT = Theme(
name="midnight", primary=CYAN, secondary=BLUE, accent=YELLOW, warning=YELLOW, error="#FF5555",
success="#55FF55", foreground=GRAY, background=BLUE, surface=BLUE, panel=CYAN, dark=True,
variables={
"border": GRAY, "border-blurred": GRAY,
"block-cursor-foreground": BLACK, "block-cursor-background": CYAN, "block-cursor-text-style": "none",
"block-cursor-blurred-foreground": BLACK, "block-cursor-blurred-background": CYAN,
"block-cursor-blurred-text-style": "none", "block-hover-background": BLUE,
"footer-background": BLACK, "footer-foreground": WHITE, "footer-key-foreground": WHITE,
"footer-key-background": BLACK, "footer-description-foreground": BLACK,
"footer-description-background": CYAN, "footer-item-background": BLACK,
"input-cursor-background": BLACK, "input-cursor-foreground": CYAN,
"scrollbar": CYAN, "scrollbar-background": BLUE, "scrollbar-corner-color": BLUE,
},
)
STATE_STYLE = {"active": "bold #55FF55", "running": "bold #55FF55", "present": "#55FF55",
"failed": "bold #FF5555", "inactive": "#FFFF55", "missing": "#FF5555", "stopped": "#FFFF55",
"exited": "#FF5555"}
CODE_STYLE = {"same": "#55FF55", "changed": "bold #FFFF55", "dirty": "#FFFF55"}
CHANGE_STYLE = {"new": ("+", "#55FF55"), "update": ("~", "#FFFF55"), "delete": ("-", "bold #FF5555")}
def line_text(line: str) -> Text:
style = ""
if line.startswith("== "):
style = "bold #55FFFF"
elif line.startswith(("ERROR", "error:")) or "failed" in line.lower():
style = "bold #FF5555"
elif line.startswith("WARN"):
style = "#FFFF55"
return Text(line, style=style)
class ConfirmPlan(ModalScreen[bool]):
BINDINGS = [Binding("escape,n", "dismiss(False)", "Cancel"), Binding("y", "dismiss(True)", "Deploy")]
def __init__(self, plan: Plan):
super().__init__()
self.plan = plan
def compose(self) -> ComposeResult:
p = self.plan
body = Text()
body.append(f"{p.rsync_src}\n→ {p.server.target}:{p.dest}\n", style="bold")
if p.git_rev:
body.append(f"git {p.git_rev}\n")
body.append("\n")
for c in p.changes:
sign, style = CHANGE_STYLE[c.action]
body.append(f"{sign} {c.path}\n", style=style)
if not p.changes:
body.append("(no file changes: install/restart only)\n", style="italic")
for n in p.notes:
body.append(f"! {n}\n", style="bold #FFFF55" if "DELETED" not in n else "bold #FF5555")
with Vertical(id="dialog"):
yield Label(Text(f" Deploy {p.app.name} [{p.app.kind}] "), id="dialog-title")
with VerticalScroll():
yield Static(body)
with Horizontal(id="buttons"):
yield Button("Deploy (y)", id="yes", variant="success")
yield Button("Cancel (n)", id="no")
@on(Button.Pressed)
def pressed(self, e: Button.Pressed) -> None:
self.dismiss(e.button.id == "yes")
class Ask(ModalScreen[bool]):
BINDINGS = [Binding("escape,n", "dismiss(False)", "No"), Binding("y", "dismiss(True)", "Yes")]
def __init__(self, title: str, text: str):
super().__init__()
self.title_, self.text = title, text
def compose(self) -> ComposeResult:
with Vertical(id="dialog", classes="small"):
yield Label(Text(f" {self.title_} "), id="dialog-title")
with VerticalScroll():
yield Static(self.text)
with Horizontal(id="buttons"):
yield Button("Yes (y)", id="yes", variant="warning")
yield Button("No (n)", id="no")
@on(Button.Pressed)
def pressed(self, e: Button.Pressed) -> None:
self.dismiss(e.button.id == "yes")
class PickSnapshot(ModalScreen[str | None]):
BINDINGS = [Binding("escape", "dismiss(None)", "Cancel")]
def __init__(self, app_name: str, snaps: list[str]):
super().__init__()
self.app_name, self.snaps = app_name, snaps
def compose(self) -> ComposeResult:
with Vertical(id="dialog", classes="small"):
yield Label(Text(f" Rollback {self.app_name}: pick a snapshot "), id="dialog-title")
yield OptionList(*self.snaps)
@on(OptionList.OptionSelected)
def picked(self, e: OptionList.OptionSelected) -> None:
self.dismiss(self.snaps[e.option_index])
class AddApp(ModalScreen[config.App | None]):
BINDINGS = [Binding("escape", "dismiss(None)", "Cancel")]
def compose(self) -> ComposeResult:
with Vertical(id="dialog"):
yield Label(" New app ", id="dialog-title")
yield Label("Local project dir or file:")
yield Input(placeholder="~/Dev/Projects/Python/MyApp", id="path")
yield Label("Name (empty = folder name):")
yield Input(id="name")
yield Label("Kind (empty = detect):")
yield Select([(k, k) for k in KINDS], id="kind", prompt="detect")
yield Static("", id="toml")
with Horizontal(id="buttons"):
yield Button("Detect", id="detect")
yield Button("Add", id="add", variant="success")
yield Button("Cancel", id="cancel")
def build(self) -> config.App | None:
raw = self.query_one("#path", Input).value.strip()
path = Path(os.path.expanduser(raw)) if raw else None
if not path or not path.exists():
self.query_one("#toml", Static).update(Text("path not found", style="bold #FF5555"))
return None
kind = self.query_one("#kind", Select).value
app = detect.new_app(path, self.query_one("#name", Input).value.strip() or None,
None if kind is Select.BLANK else kind)
self.query_one("#toml", Static).update(Text(config.app_to_toml(app)))
return app
@on(Button.Pressed)
def pressed(self, e: Button.Pressed) -> None:
if e.button.id == "cancel":
self.dismiss(None)
elif e.button.id == "detect":
self.build()
else:
app = self.build()
if app:
try:
config.validate_app(app)
except config.ConfigError as err:
self.query_one("#toml", Static).update(Text(str(err), style="bold #FF5555"))
return
self.dismiss(app)
class DeployerApp(App):
TITLE = f"Deployer {__version__}"
CSS = """
Screen { background: $background; }
#top { height: 1fr; }
#apps { width: 3fr; border: double $border; border-title-color: $accent; }
#apps > .datatable--header { background: $panel; color: #000000; text-style: bold; }
#apps > .datatable--header-hover { background: $panel; color: #000000; }
#apps > .datatable--header-cursor { background: $panel; color: #000000; }
#side { width: 2fr; border: double $border; border-title-color: $accent; padding: 0 1; }
#info { height: auto; margin-bottom: 1; }
#side Checkbox { background: $background; border: none; padding: 0; height: 1; }
#side Select { margin-top: 1; }
#log { height: 14; border: double $border; border-title-color: $accent; background: $background; }
ModalScreen { align: center middle; background: $background 60%; }
#dialog { width: 90; max-width: 95%; height: 80%; background: $panel; color: #000000;
border: double #FFFFFF; padding: 0 1; }
#dialog.small { height: auto; max-height: 80%; }
#dialog VerticalScroll { height: 1fr; background: $background; color: $foreground; }
#dialog.small VerticalScroll { height: auto; max-height: 20; }
#dialog-title { width: 100%; content-align: center middle; color: #0000AA; text-style: bold; }
#dialog Input, #dialog Select { margin-bottom: 1; }
#dialog OptionList { height: auto; max-height: 20; }
#toml { height: auto; background: $background; color: $foreground; }
#buttons { height: 3; align: center middle; }
#buttons Button { margin: 0 2; }
Toast { background: #000000; color: #FFFFFF; }
"""
BINDINGS = [
Binding("f2", "edit", "Edit"),
Binding("f3", "preview", "Preview"),
Binding("f4", "logs", "Logs"),
Binding("f5", "deploy", "Deploy"),
Binding("f6", "refresh", "Refresh"),
Binding("f7", "add", "Add"),
Binding("f8", "rollback", "Rollback"),
Binding("f9", "import_apps", "Import"),
Binding("f10,q", "quit", "Quit"),
]
def __init__(self, cfg: Config):
super().__init__()
self.cfg = cfg
self.status: dict[str, tuple[str, str, str]] = {}
self.busy = False
# ---------- layout ----------
def compose(self) -> ComposeResult:
with Horizontal(id="top"):
yield DataTable(id="apps", cursor_type="row", zebra_stripes=False)
with VerticalScroll(id="side"):
yield Static(id="info")
yield Select([(k, k) for k in KINDS], id="kind", allow_blank=False, value=KINDS[0])
yield Checkbox("Build locally", True, id="o_build")
yield Checkbox("Restart + health check", True, id="o_restart")
yield Checkbox("Force pip install", False, id="o_deps")
yield Checkbox("Create missing config links", True, id="o_configs")
yield Checkbox("Delete files absent locally", False, id="o_delete")
yield RichLog(id="log", wrap=True, markup=False, max_lines=5000)
yield Footer()
def on_mount(self) -> None:
self.register_theme(MIDNIGHT)
self.theme = "midnight"
t = self.query_one("#apps", DataTable)
t.border_title = f"Apps → {self.cfg.server.target}"
t.add_columns("Name", "Kind", "State", "Code", "Last deploy")
self.query_one("#side").border_title = "Details / options"
self.query_one("#log").border_title = "Output"
self.fill_table()
self.action_refresh()
def fill_table(self) -> None:
t = self.query_one("#apps", DataTable)
row = t.cursor_row
t.clear()
for a in self.cfg.apps:
state, last, code = self.status.get(a.name, ("…", "", ""))
t.add_row(a.name, a.kind, Text(state, style=STATE_STYLE.get(state.split()[0] if state else "", "")),
Text(code, style=CODE_STYLE.get(code, "")), last, key=a.name)
if self.cfg.apps:
t.move_cursor(row=min(row, len(self.cfg.apps) - 1))
self.show_details()
def current(self) -> config.App | None:
t = self.query_one("#apps", DataTable)
if not self.cfg.apps or t.row_count == 0:
return None
key = t.coordinate_to_cell_key(Coordinate(t.cursor_row, 0)).row_key.value
return self.cfg.app(key)
@on(DataTable.RowHighlighted)
def show_details(self) -> None:
a = self.current()
info = self.query_one("#info", Static)
if not a:
info.update("No apps: F7 add, F9 import from the server")
return
lines = [f"[b]{escape(a.name)}[/b]", f"src: {escape(a.src)}", f"dest: {escape(self.cfg.dest_path(a))}"]
if a.is_python:
lines.append(f"unit: {escape(a.unit)} entry: {escape(a.entry)} user: {escape(a.user)}")
if a.configs:
lines.append(f"configs: {escape(', '.join(a.configs))}")
if a.build:
lines.append(f"build: {escape(a.build)}")
if a.artifact:
lines.append(f"artifact: {escape(a.artifact)}")
if a.post:
lines.append(f"post: {escape(a.post)}")
hc = ", ".join(x for x in (a.health_container, a.health_url) if x)
if hc:
lines.append(f"health: {escape(hc)}")
info.update("\n".join(lines))
kind = self.query_one("#kind", Select)
kind.value = a.kind
self.query_one("#o_delete", Checkbox).value = a.delete
self.query_one("#o_build", Checkbox).disabled = not a.build
def options(self) -> Options:
v = lambda i: self.query_one(f"#{i}", Checkbox).value # noqa: E731
return Options(build=v("o_build"), restart=v("o_restart"), install_deps=v("o_deps"),
copy_configs=v("o_configs"), delete=v("o_delete"))
def run_app(self) -> config.App | None:
"""The selected app with the kind chosen for this run."""
a = self.current()
if not a:
return None
kind = self.query_one("#kind", Select).value
if kind != a.kind:
from dataclasses import replace
a = replace(a, kind=kind)
try:
config.validate_app(a)
except config.ConfigError as e:
self.log_line(f"ERROR: {e}")
return None
return a
# ---------- output ----------
def log_line(self, line: str) -> None:
self.query_one("#log", RichLog).write(line_text(line))
def tlog(self, line: str) -> None:
"""Log from a worker thread."""
self.call_from_thread(self.log_line, line)
def start(self) -> bool:
if self.busy:
self.notify("Another operation is running", severity="warning")
return False
self.busy = True
return True
def done(self) -> None:
self.busy = False
# ---------- actions ----------
def action_refresh(self) -> None:
self.refresh_status()
@work(thread=True, group="status", exclusive=True)
def refresh_status(self) -> None:
try:
st = pipeline.status_all(self.cfg)
except remote.RemoteError as e:
self.tlog(f"ERROR: status: {e}")
return
self.call_from_thread(self._set_status, st)
def _set_status(self, st) -> None:
self.status = st
self.fill_table()
def action_preview(self) -> None:
a = self.run_app()
if a and self.start():
self.do_preview(a, self.options(), then_deploy=False)
def action_deploy(self) -> None:
a = self.run_app()
if a and self.start():
self.do_preview(a, self.options(), then_deploy=True)
@work(thread=True, group="op")
def do_preview(self, a: config.App, opts: Options, then_deploy: bool) -> None:
try:
self.tlog(f"== preview {a.name} [{a.kind}]")
plan = pipeline.preview(self.cfg, a, opts, log=self.tlog)
except (pipeline.DeployError, remote.RemoteError, config.ConfigError) as e:
self.tlog(f"ERROR: {e}")
self.call_from_thread(self.done)
return
for c in plan.changes:
sign, _ = CHANGE_STYLE[c.action]
self.tlog(f" {sign} {c.path}")
for n in plan.notes:
self.tlog(f"WARN: {n}")
if not plan.changes:
self.tlog(" (no file changes)")
if not then_deploy:
self.call_from_thread(self.done)
return
def answered(ok: bool | None) -> None:
if ok:
self.do_execute(plan)
else:
self.log_line("cancelled")
self.done()
self.call_from_thread(self.push_screen, ConfirmPlan(plan), answered)
@work(thread=True, group="op")
def do_execute(self, plan: Plan) -> None:
res = pipeline.execute(plan, log=self.tlog)
self.call_from_thread(self.after_execute, plan, res)
def after_execute(self, plan: Plan, res: pipeline.Result) -> None:
self.done()
self.notify(f"{plan.app.name}: {res.message}", severity="information" if res.ok else "error",
timeout=8)
self.action_refresh()
if not res.ok and res.snapshot:
def answered(ok: bool | None) -> None:
if ok and self.start():
self.do_rollback(plan.app, res.snapshot)
self.push_screen(Ask("Deploy failed", f"Roll {plan.app.name} back to snapshot {res.snapshot}?"),
answered)
def action_rollback(self) -> None:
a = self.current()
if a and self.start():
self.load_snapshots(a)
@work(thread=True, group="op")
def load_snapshots(self, a: config.App) -> None:
try:
snaps = snapshots.listing(self.cfg.server, a.name)
except remote.RemoteError as e:
snaps = []
self.tlog(f"ERROR: {e}")
self.call_from_thread(self._pick_snapshot, a, snaps)
def _pick_snapshot(self, a: config.App, snaps: list[str]) -> None:
if not snaps:
self.done()
self.notify(f"{a.name}: no snapshots", severity="warning")
return
def picked(ts: str | None) -> None:
if not ts:
self.done()
return
def confirmed(ok: bool | None) -> None:
if ok:
self.do_rollback(a, ts)
else:
self.done()
self.push_screen(Ask("Rollback", f"Restore {a.name} from {ts} and restart it?"), confirmed)
self.push_screen(PickSnapshot(a.name, snaps), picked)
@work(thread=True, group="op")
def do_rollback(self, a: config.App, ts: str) -> None:
res = pipeline.rollback(self.cfg, a, ts, log=self.tlog)
self.tlog(f"== {res.message}")
self.call_from_thread(self._rolled_back, a, res)
def _rolled_back(self, a: config.App, res: pipeline.Result) -> None:
self.done()
self.notify(f"{a.name}: {res.message}", severity="information" if res.ok else "error", timeout=8)
self.action_refresh()
def action_logs(self) -> None:
a = self.current()
if not a or self.busy:
return
with self.suspend():
print(f"{a.name}: Ctrl+C to return\n")
try:
remote.interactive(self.cfg.server, pipeline.logs_command(self.cfg, a))
except KeyboardInterrupt:
pass
def action_edit(self) -> None:
if self.busy:
return
editor = os.environ.get("VISUAL") or os.environ.get("EDITOR") or "nano"
with self.suspend():
subprocess.call([*shlex.split(editor), str(self.cfg.path)])
self.reload()
def reload(self) -> None:
try:
self.cfg = config.load(self.cfg.path)
except (OSError, config.ConfigError) as e:
self.log_line(f"ERROR: config: {e} (old config kept)")
return
self.log_line(f"== config reloaded: {len(self.cfg.apps)} apps")
self.fill_table()
self.action_refresh()
def action_add(self) -> None:
def added(app: config.App | None) -> None:
if app:
if any(x.name == app.name for x in self.cfg.apps):
self.notify(f"{app.name} already exists", severity="error")
return
config.append_apps(self.cfg.path, [app])
self.log_line(f"== added {app.name}: review it with F2 before the first deploy")
self.reload()
self.push_screen(AddApp(), added)
def action_import_apps(self) -> None:
if self.start():
self.do_import()
@work(thread=True, group="op")
def do_import(self) -> None:
try:
found = detect.import_server(self.cfg, log=self.tlog)
except remote.RemoteError as e:
self.tlog(f"ERROR: {e}")
found = []
self.call_from_thread(self._imported, found)
def _imported(self, found) -> None:
self.done()
if not found:
self.notify("Nothing new on the server")
return
ready = [i.app for i in found if i.matched and i.app.kind.startswith("python")]
drafts = [i.app for i in found if i.app not in ready]
text = "\n".join(f"{'+' if i.app in ready else '?'} {i.app.name} {i.app.src or '(no local source)'}"
for i in found)
def answered(ok: bool | None) -> None:
if ok:
config.append_apps(self.cfg.path, ready, drafts)
self.reload()
self.push_screen(Ask(f"Import: {len(ready)} ready, {len(drafts)} commented drafts",
text + "\n\nAppend to deploy.toml?"), answered)
def run(cfg: Config) -> int:
DeployerApp(cfg).run()
return 0
@@ -0,0 +1,19 @@
# Deployer registry. Edit by hand or append with `deploy add <path>` / `deploy import`.
#
# kind: python-service full install: .venv + unit via ~/app/python/make_py_service.sh + restart
# python-update sync code, pip install only when requirements.txt changed, restart
# docker sync a compose project, `docker compose up -d --build`
# files sync a dir or a file anywhere (dest relative to app_root, or ~/..., /...)
# artifact local build, copy the newest `artifact` match to dest (dest/ = keep its name)
# configs: top-level items kept in configs_root and linked into the app dir; never synced
# Common keys: exclude, use_gitignore (true), delete (false), build, build_env, post, mode,
# health_container, health_url (requested on the server), health_timeout
[server]
host = "server.example.com"
user = "user"
app_root = "/home/user/app"
configs_root = "/home/user/app/configs"
state_dir = "/home/user/app_deploy"
keep = 5
local_roots = ["~/Projects"]
+189
View File
@@ -0,0 +1,189 @@
import os
from pathlib import Path
import pytest
from deployer import config, detect, pipeline, remote
from deployer.kinds import get
SERVER = """
[server]
host = "h"
app_root = "/srv/app"
"""
def write(tmp_path: Path, body: str) -> Path:
p = tmp_path / "deploy.toml"
p.write_text(SERVER + body)
return p
def test_load_and_dest_paths(tmp_path):
cfg = config.load(write(tmp_path, """
[[app]]
name = "a"
kind = "python-update"
src = "~/x"
dest = "python/a"
unit = "a"
configs = ["configs"]
[[app]]
name = "b"
kind = "files"
src = "/tmp"
dest = "~/scripts"
"""))
assert [a.name for a in cfg.apps] == ["a", "b"]
assert cfg.dest_path(cfg.app("a")) == "/srv/app/python/a"
assert cfg.dest_path(cfg.app("b")) == "/home/user/scripts"
assert cfg.app("a").src_path == Path.home() / "x"
@pytest.mark.parametrize("body, msg", [
('name = "a"\nkind = "nope"\nsrc = "x"\ndest = "y"', "kind must be"),
('name = "a"\nkind = "python-update"\nsrc = "x"\ndest = "y"', "unit"),
('name = "a"\nkind = "files"\nsrc = "x"\ndest = "../etc"', "unsafe dest"),
('name = "a"\nkind = "files"\nsrc = "x"\ndest = "y"\ndelete = "yes"', "wrong type"),
('name = "a"\nkind = "files"\nsrc = "x"\ndest = "y"\ntypo = 1', "unknown key"),
('name = "a"\nkind = "artifact"\nsrc = "x"\ndest = "y"', "artifact"),
('name = "a"\nkind = "files"\nsrc = "x"\ndest = "y"\nconfigs = ["a/b"]', "top-level"),
])
def test_validation(tmp_path, body, msg):
with pytest.raises(config.ConfigError, match=msg):
config.load(write(tmp_path, "[[app]]\n" + body))
def test_append_round_trip(tmp_path):
p = write(tmp_path, "# keep me\n")
app = config.App(name="n", kind="docker", src="~/p", dest="docker/n", build_env={"JAVA_HOME": "~/jdk"},
post='echo "hi"')
draft = config.App(name="d", kind="python-update", src="", dest="python/d", unit="d")
config.append_apps(p, [app], [draft])
text = p.read_text()
assert "# keep me" in text and '# name = "d"' in text
cfg = config.load(p)
assert [a.name for a in cfg.apps] == ["n"]
assert cfg.app("n") == app
def test_filters_protect_server_items():
rules = remote.build_filters(protected=["configs", "Makefile"], exclude=["/tests"], use_gitignore=True)
assert rules[:2] == ["- /Makefile", "- /configs"]
assert "- .venv" in rules and "- /tests" in rules
assert rules[-1] == ":- .gitignore"
assert ":- .gitignore" not in remote.build_filters(protected=[], exclude=[], use_gitignore=False)
def test_rsync_cmd_options():
s = config.Server()
cmd = remote.rsync_cmd(s, "src/", "/d/", filters=["- x"], delete=True, dry_run=True, extra=["--chmod=F640"])
assert cmd[0] == "rsync" and "-n" in cmd and "--delete" in cmd and "-c" in cmd
assert cmd.index("--chmod=F640") < cmd.index("--filter=- x")
assert cmd[-2:] == ["src/", "user@localhost:/d/"]
def test_parse_itemize():
out = "\n".join([
".d..t...... ./",
"<f+++++++++ new.py",
"<f.st...... changed.py",
".f..t...... touched.py",
"cd+++++++++ pkg/",
"*deleting old.py",
"cL+++++++++ link -> target",
"created 2 directories for /x",
])
got = [(c.action, c.path) for c in remote.parse_itemize(out)]
assert got == [("new", "new.py"), ("update", "changed.py"), ("new", "pkg/"), ("delete", "old.py"),
("new", "link -> target")]
@pytest.mark.parametrize("local, deployed, state", [
("abc1234", "abc1234", "same"),
("def5678", "abc1234", "changed"),
("def5678-dirty", "abc1234", "changed"),
("abc1234-dirty", "abc1234", "dirty"),
("abc1234", "abc1234-dirty", "dirty"),
("", "abc1234", "?"),
("abc1234", "", "?"),
("abc1234", "-", "?"),
])
def test_code_state(local, deployed, state):
assert pipeline.code_state(local, deployed) == state
def test_deployed_rev():
line = "2026-10-17T10:57:30 EO_Service {} python-update {} rev=22fc8b3 snap=20261017-105714"
assert pipeline.deployed_rev(line.format("deploy", "ok").split()) == "22fc8b3"
assert pipeline.deployed_rev(line.format("deploy", "UNHEALTHY").split()) == "22fc8b3"
assert pipeline.deployed_rev(line.format("deploy", "FAILED").split()) == ""
assert pipeline.deployed_rev(line.format("rollback", "ok").split()) == ""
def test_shell_vars_quote():
v = remote.shell_vars(A="it's", L=["x y", "$z"])
assert v == "A='it'\"'\"'s'\nL=('x y' '$z')\n"
def test_detect_kinds(tmp_path):
py = tmp_path / "MyApp"
(py / "configs").mkdir(parents=True)
(py / "app.py").write_text("")
(py / "secrets.yaml").write_text("")
(py / "docker-compose.yml").write_text("")
app = detect.new_app(py)
assert (app.kind, app.dest, app.unit, app.entry) == ("python-service", "python/MyApp", "MyApp", "app.py")
assert app.configs == ["configs", "secrets.yaml"]
dk = tmp_path / "stack"
dk.mkdir()
(dk / "compose.yaml").write_text("")
assert detect.new_app(dk).kind == "docker"
jv = tmp_path / "Svc"
jv.mkdir()
(jv / "pom.xml").write_text("<project><packaging>war</packaging></project>")
(jv / "mvnw").write_text("")
a = detect.new_app(jv)
assert a.kind == "artifact" and a.artifact == "target/*.war" and a.build.startswith("./mvnw")
def test_match_local(tmp_path):
a = tmp_path / "DasSimpleExporter"
a.mkdir()
(a / "dasExporter.service").write_text("")
b = tmp_path / "weather-proxy"
b.mkdir()
assert detect.match_local([a, b], ["WeatherProxy"]) == b
assert detect.match_local([a, b], ["DasExporter"], unit="dasExporter") == a
assert detect.match_local([a, b], ["nothing"]) is None
def test_python_config_layout(tmp_path):
(tmp_path / "configs").mkdir()
cfg = config.Config(tmp_path / "x.toml", config.Server(), [])
plan = type("P", (), {})()
plan.cfg, plan.dest = cfg, "/home/user/app/python/App"
plan.app = config.App(name="App", kind="python-update", src=str(tmp_path), dest="python/App", unit="u",
configs=["configs"])
k = get("python-update")
assert k.config_items(plan) == [("configs", "/home/user/app/configs/python/App")]
plan.app.configs = [".env", "secrets.yaml"]
assert k.config_items(plan) == [(".env", "/home/user/app/configs/python/App/app.env"),
("secrets.yaml", "/home/user/app/configs/python/App/secrets.yaml")]
plan.dest = "/home/user/app/docker/stack"
plan.app = config.App(name="stack", kind="docker", src=str(tmp_path), dest="docker/stack")
assert get("docker").config_items(plan) == [(".env", "/home/user/app/configs/docker/stack.env")]
def test_ssh_exits_early(tmp_path, monkeypatch):
# ssh dies at once (e.g. unresolvable host): the broken pipe must surface as RemoteError with its message
fake = tmp_path / "ssh"
fake.write_text("#!/bin/sh\necho \"ssh: Could not resolve hostname\" >&2\nexit 255\n")
fake.chmod(0o755)
monkeypatch.setenv("PATH", f"{tmp_path}:{os.environ['PATH']}")
srv = config.Server(host="nohost.invalid", user="u")
with pytest.raises(remote.RemoteError, match="Could not resolve"):
remote.run(srv, "x" * 5_000_000, check=False)
+27
View File
@@ -0,0 +1,27 @@
# Template file for 'deployer'
# In-tree package: sources are in files/ (FILESDIR), a copy of ~/Dev/Projects/Python/deployer.
# Differences from the upstream: the config is in ~/.config/deployer (see bin/deploy) and
# history.log is in ~/.local/state/deployer.
pkgname=deployer
version=0.3.1
revision=1
create_wrksrc=yes
hostmakedepends="python3"
depends="python3 python3-textual openssh rsync"
short_desc="TUI/CLI deploy of local projects to a server over ssh + rsync"
maintainer="Das <anry.das@gmail.com>"
license="MIT"
homepage="https://git.das.org.ua/das/Das-OS"
pycompile_dirs="usr/lib/das/deployer"
python_version=3
do_install() {
local f dir
for f in "${FILESDIR}"/python/deployer/*.py "${FILESDIR}"/python/deployer/kinds/*.py; do
dir=${f#"${FILESDIR}"/python/}
vinstall "${f}" 644 "usr/lib/das/deployer/${dir%/*}"
done
vbin "${FILESDIR}/bin/deploy"
vinstall "${FILESDIR}/share/deploy.toml.example" 644 usr/share/das/deployer
vlicense "${FILESDIR}/LICENSE"
}
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Das
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

Some files were not shown because too many files have changed in this diff Show More