Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8db767efb9 | ||
|
|
1a62ddaeb8 | ||
|
|
7e7bcbe017 | ||
|
|
60af93df74 | ||
|
|
396986aaf4 | ||
|
|
62b8b98086 | ||
|
|
ea62ec7a26 | ||
|
|
22dc7832bc | ||
|
|
e01e1786d6 | ||
|
|
6564d1eb17 | ||
|
|
94b1bf5f8b | ||
|
|
a2bffc7fe9 | ||
|
|
66ea614e0d |
@@ -6,3 +6,7 @@ srcpkgs/das-theme/files/xfwm4gen/xfwm4gen
|
|||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.idea/
|
.idea/
|
||||||
|
|
||||||
|
# Python
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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_
|
||||||
|
|
||||||
[](https://voidlinux.org) [](https://xfce.org) [](https://docs.voidlinux.org/config/services/) [](https://go.dev) [](https://www.docker.com) [](#)
|
[](https://voidlinux.org) [](https://xfce.org) [](https://docs.voidlinux.org/config/services/) [](https://go.dev) [](https://www.docker.com) [](https://www.python.org) [](https://textual.textualize.io) [](#)
|
||||||
|
|
||||||
🇺🇦 [Українська версія](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
@@ -1,7 +1,7 @@
|
|||||||
## Das OS
|
## Das OS
|
||||||
### _Легкий і дружній десктоп на базі Void Linux з Xfce та українським колоритом, а також консольна серверна версія_
|
### _Легкий і дружній десктоп на базі Void Linux з Xfce та українським колоритом, а також консольна серверна версія_
|
||||||
|
|
||||||
[](https://voidlinux.org) [](https://xfce.org) [](https://docs.voidlinux.org/config/services/) [](https://go.dev) [](https://www.docker.com) [](#)
|
[](https://voidlinux.org) [](https://xfce.org) [](https://docs.voidlinux.org/config/services/) [](https://go.dev) [](https://www.docker.com) [](https://www.python.org) [](https://textual.textualize.io) [](#)
|
||||||
|
|
||||||
🇬🇧 [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:=-_
|
||||||
|
|||||||
@@ -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` перезбирає все.
|
||||||
|
|
||||||
## Відкладено
|
## Відкладено
|
||||||
|
|||||||
@@ -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
@@ -1,2 +1,2 @@
|
|||||||
Das OS \r (\l)
|
Das OS @VERSION@ (\l)
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 3.3 KiB |
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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,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,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"
|
||||||
|
|||||||
@@ -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
@@ -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"/>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
Executable
+9
@@ -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())
|
||||||
Executable
+9
@@ -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())
|
||||||
Executable
+4
@@ -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
|
||||||
@@ -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
@@ -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")"
|
||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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
@@ -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
@@ -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"}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
@@ -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 "$@"
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
Executable
+8
@@ -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"]
|
||||||
@@ -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)
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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
Reference in New Issue
Block a user