From 6611ff6fd0d6d6494de4d2204999cc9c040e937b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20Ko=C5=82odziejczyk?= Date: Thu, 7 May 2026 14:50:51 +0200 Subject: [PATCH 1/2] Stop vm command --- CHANGELOG.md | 4 ++ README.md | 7 ++ bin/dvm | 2 +- docs/commands.md | 22 ++++++- share/dvm/lib/core.sh | 2 + share/dvm/lib/vm_admin.sh | 131 ++++++++++++++++++++++++++++++++++++++ tests/smoke.sh | 52 +++++++++++++++ 7 files changed, 218 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b8b6e7..00dd054 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,10 @@ - Tightened CLI arg validation: `init`, `sync`, `sh`, `ssh-key`, `gpg-key`, `ls`, and `stop` now reject unexpected extra arguments with a clear error rather than silently ignoring them. +- Added `dvm stop --all` to stop every DVM-managed Lima instance while preserving VM + disks and config, plus `dvm stop --inactive` / `dvm stop --all --inactive` for + stopping only VMs without a detected active shell, `tmux`/`zellij`, or known DVM + service unit. - Tightened `dvm rm` dirty check: when `git` is not installed in the guest, the check now exits with status 2 and refuses to delete the VM unless `--force` is passed. Previously the check exited cleanly when `git` was absent, allowing diff --git a/README.md b/README.md index f92f901..289f9e9 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,8 @@ dvm ssh-key app dvm gpg-key app dvm ls dvm stop app +dvm stop --all +dvm stop --inactive dvm rm app --yes ``` @@ -59,6 +61,11 @@ tools such as AI CLIs across every active VM. `dvm rm` requires `--yes` and checks nested Git repos for dirty work before deleting. Use `--force` only when you intentionally want to skip that check. +`dvm stop --all` stops every DVM-managed Lima instance listed with the internal +`dvm-` prefix. This releases VM memory without deleting disks or config. +Use `dvm stop --inactive` to stop only VMs without a detected active shell, +`tmux`/`zellij`, or known DVM service unit. + `dvm ssh-key ` creates separate VM-local SSH keys for GitHub access and Git commit signing. Use the access key as a deploy/authentication key and add the signing key to your GitHub account's SSH signing keys. diff --git a/bin/dvm b/bin/dvm index 330de5e..859d201 100755 --- a/bin/dvm +++ b/bin/dvm @@ -64,7 +64,7 @@ main() { ssh-key) [ "$#" -eq 1 ] || die "ssh-key takes one VM name"; ssh_key_vm "$@" ;; gpg-key) [ "$#" -eq 1 ] || die "gpg-key takes one VM name"; gpg_key_vm "$@" ;; ls) [ "$#" -eq 0 ] || die "ls takes no arguments"; list_vms ;; - stop) [ "$#" -eq 1 ] || die "stop takes one VM name"; stop_vm "$@" ;; + stop) stop_command "$@" ;; rm) [ "$#" -ge 1 ] || die "rm requires a VM name"; rm_vm "$@" ;; help | -h | --help) usage ;; *) usage >&2; die "unknown command: $cmd" ;; diff --git a/docs/commands.md b/docs/commands.md index 3d15262..1ed458b 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -136,9 +136,29 @@ internal `dvm-` prefix and are aligned for terminal output. ```bash dvm stop app +dvm stop --all +dvm stop --all --inactive +dvm stop --inactive +dvm stop --inactive --force ``` -Stops the Lima VM. +`stop ` stops one Lima VM. + +`stop --all` stops every DVM-managed Lima instance listed with the internal `dvm-` +prefix, including instances whose DVM config was later removed. It releases VM memory +without deleting disks or config. + +`stop --inactive` is shorthand for `stop --all --inactive`. It probes each running +DVM VM and stops only VMs without a detected interactive shell, `tmux` process, +`zellij` process, or active known DVM service unit (`dvm-cloudflared.service`, +`dvm-llama.service`, `tailscaled.service`). This is intentionally conservative; it +does not prove that arbitrary background jobs or dev servers are idle unless they are +inside one of those detected sessions or services. + +Bulk stop commands skip already stopped instances, report failures, and exit non-zero +if any VM failed to stop. With `--inactive`, active instances are skipped too, and +`--force` stops a VM when the activity probe fails; VMs that are successfully detected +as active are still skipped. To stop active VMs too, use plain `dvm stop --all`. ## Remove diff --git a/share/dvm/lib/core.sh b/share/dvm/lib/core.sh index cacfebd..ea8eebb 100644 --- a/share/dvm/lib/core.sh +++ b/share/dvm/lib/core.sh @@ -14,6 +14,8 @@ usage: dvm gpg-key dvm ls dvm stop + dvm stop --all [--inactive] [--force] + dvm stop --inactive [--force] dvm rm --yes [--force] HELP } diff --git a/share/dvm/lib/vm_admin.sh b/share/dvm/lib/vm_admin.sh index 4cab505..134c0cc 100644 --- a/share/dvm/lib/vm_admin.sh +++ b/share/dvm/lib/vm_admin.sh @@ -77,6 +77,137 @@ stop_vm() { limactl stop "$DVM_LIMA_NAME" } +stop_command() { + local all force inactive name + all=0 + force=0 + inactive=0 + name="" + [ "$#" -gt 0 ] || die "stop requires a VM name, --all, or --inactive" + while [ "$#" -gt 0 ]; do + case "$1" in + --all) all=1 ;; + --inactive) + all=1 + inactive=1 + ;; + --force | -f) force=1 ;; + --*) die "unknown stop option: $1" ;; + *) + [ -z "$name" ] || die "stop takes one VM name" + name="$1" + ;; + esac + shift + done + if [ "$all" = "1" ]; then + [ -z "$name" ] || die "stop --all does not take a VM name" + stop_all_vms "$inactive" "$force" + return + fi + [ "$force" = "0" ] || die "stop does not take --force" + stop_vm "$name" +} + +vm_inactive_probe() { + local lima_name="$1" + limactl shell "$lima_name" bash -s <<'DVM_ACTIVITY_PROBE' +set -euo pipefail + +# dvm activity probe +uid="$(id -u)" +reasons=() + +add_reason() { + reasons+=("$1") +} + +if command -v pgrep >/dev/null 2>&1; then + if pgrep -u "$uid" -f '(^|/|[[:space:]])tmux([[:space:]:]|$)' >/dev/null 2>&1; then + add_reason tmux + fi + if pgrep -u "$uid" -f '(^|/|[[:space:]])zellij([[:space:]]|$)' >/dev/null 2>&1; then + add_reason zellij + fi +elif ps -u "$uid" -o comm= 2>/dev/null | awk '$1 == "tmux" || $1 == "zellij" { found = 1 } END { exit found ? 0 : 1 }'; then + add_reason multiplexer +fi + +if ps -u "$uid" -o pid=,tty=,comm= 2>/dev/null | awk -v self="$$" ' + $1 == self { next } + $2 != "?" && $3 ~ /^(bash|zsh|fish|sh|ksh)$/ { found = 1 } + END { exit found ? 0 : 1 } +'; then + add_reason shell +fi + +if command -v systemctl >/dev/null 2>&1; then + for unit in dvm-cloudflared.service dvm-llama.service tailscaled.service; do + if systemctl is-active --quiet "$unit" 2>/dev/null; then + add_reason "$unit" + fi + done +fi + +if [ "${#reasons[@]}" -gt 0 ]; then + printf 'active:' + printf ' %s' "${reasons[@]}" + printf '\n' + exit 1 +fi + +printf 'inactive\n' +DVM_ACTIVITY_PROBE +} + +stop_all_vms() { + local activity failed force inactive listing name ok rc skipped status + inactive="${1:-0}" + force="${2:-0}" + ok=0 + failed=0 + skipped=0 + listing="$(limactl list --format '{{.Name}} {{.Status}}')" + while read -r name status _; do + case "$name" in + dvm-*) ;; + *) continue ;; + esac + if [ "$status" = "Stopped" ]; then + skipped=$((skipped + 1)) + continue + fi + if [ "$inactive" = "1" ]; then + activity="$(vm_inactive_probe "$name" 2>&1)" && rc=0 || rc=$? + case "$rc" in + 0) ;; + 1) + printf 'dvm: skipping active VM: %s (%s)\n' "$name" "$activity" >&2 + skipped=$((skipped + 1)) + continue + ;; + *) + if [ "$force" = "1" ]; then + printf 'dvm: inactive check failed for %s; forcing stop\n' "$name" >&2 + else + printf 'dvm: inactive check failed for %s: %s\n' "$name" "$activity" >&2 + failed=$((failed + 1)) + continue + fi + ;; + esac + fi + if limactl stop "$name"; then + ok=$((ok + 1)) + else + printf 'dvm: stop failed: %s\n' "$name" >&2 + failed=$((failed + 1)) + fi + done <<<"$listing" + printf 'dvm stop --all: %s stopped, %s skipped, %s failed\n' "$ok" "$skipped" "$failed" + [ "$failed" -eq 0 ] +} + rm_vm() { local force name orphan vm_file yes name="${1:-}" diff --git a/tests/smoke.sh b/tests/smoke.sh index e842afe..7b549cf 100755 --- a/tests/smoke.sh +++ b/tests/smoke.sh @@ -160,6 +160,22 @@ shell) printf 'shell %s %s\n' "$vm" "$*" >>"$state/log" cat >"$state/guest.sh" bash -n "$state/guest.sh" + if grep -Fq '# dvm activity probe' "$state/guest.sh"; then + activity="$(cat "$state/activity/$vm" 2>/dev/null || true)" + case "$activity" in + active:*) + printf '%s\n' "$activity" + exit 1 + ;; + fail:*) + printf '%s\n' "${activity#fail:}" >&2 + exit 2 + ;; + *) + printf 'inactive\n' + ;; + esac + fi ;; delete) printf 'delete %s\n' "$1" >>"$state/log" @@ -228,6 +244,13 @@ set -e [ "$status" -ne 0 ] grep -Fq 'ls takes no arguments' "$TMP/ls-extra.err" +set +e +"$ROOT/bin/dvm" stop --all extra >/dev/null 2>"$TMP/stop-all-extra.err" +status="$?" +set -e +[ "$status" -ne 0 ] +grep -Fq 'stop --all does not take a VM name' "$TMP/stop-all-extra.err" + rm -f "$TMP/config/vms/newapp.sh" "$TMP/config/vms/llama.sh" "$ROOT/bin/dvm" sync app 2>"$TMP/apply.err" @@ -411,6 +434,35 @@ grep -Fq 'Git commit signing public key' "$TMP/state/guest.sh" "$ROOT/bin/dvm" gpg-key app grep -Fq 'shell dvm-app env DVM_NAME=app bash -s' "$TMP/state/log" +mkdir -p "$TMP/state/activity" +printf 'active: zellij\n' >"$TMP/state/activity/dvm-app" +printf 'fail: probe unavailable\n' >"$TMP/state/activity/dvm-rooted" +printf 'active: dvm-cloudflared.service\n' >"$TMP/state/activity/dvm-cloudflared" +printf 'active: tailscaled.service\n' >"$TMP/state/activity/dvm-tailscale" +: >"$TMP/state/log" +"$ROOT/bin/dvm" stop --inactive --force >"$TMP/stop-inactive.out" 2>"$TMP/stop-inactive.err" +grep -Fq 'dvm stop --all: 2 stopped, 3 skipped, 0 failed' "$TMP/stop-inactive.out" +grep -Fq 'skipping active VM: dvm-app (active: zellij)' "$TMP/stop-inactive.err" +grep -Fq 'inactive check failed for dvm-rooted; forcing stop' "$TMP/stop-inactive.err" +grep -Fq 'skipping active VM: dvm-cloudflared (active: dvm-cloudflared.service)' "$TMP/stop-inactive.err" +grep -Fq 'skipping active VM: dvm-tailscale (active: tailscaled.service)' "$TMP/stop-inactive.err" +grep -Fq 'stop dvm-rooted' "$TMP/state/log" +grep -Fq 'stop dvm-race' "$TMP/state/log" +if grep -Fq 'stop dvm-app' "$TMP/state/log" || + grep -Fq 'stop dvm-cloudflared' "$TMP/state/log" || + grep -Fq 'stop dvm-tailscale' "$TMP/state/log"; then + printf 'dvm stop --inactive stopped an active VM\n' >&2 + exit 1 +fi +rm -rf "$TMP/state/activity" + +: >"$TMP/state/log" +"$ROOT/bin/dvm" stop --all >"$TMP/stop-all.out" +grep -Fq 'dvm stop --all: 5 stopped, 0 skipped, 0 failed' "$TMP/stop-all.out" +for vm in dvm-app dvm-rooted dvm-cloudflared dvm-tailscale dvm-race; do + grep -Fq "stop $vm" "$TMP/state/log" +done + "$ROOT/bin/dvm" stop app grep -Fq 'stop dvm-app' "$TMP/state/log" From 61453a38e62d0cb81f6dc3df34ba1206e5d7b774 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20Ko=C5=82odziejczyk?= Date: Thu, 7 May 2026 15:08:20 +0200 Subject: [PATCH 2/2] zsh completion --- CHANGELOG.md | 2 + README.md | 11 ++ docs/commands.md | 19 ++++ install.sh | 1 + share/dvm/completions/_dvm | 203 +++++++++++++++++++++++++++++++++++++ tests/smoke.sh | 13 +++ 6 files changed, 249 insertions(+) create mode 100644 share/dvm/completions/_dvm diff --git a/CHANGELOG.md b/CHANGELOG.md index 00dd054..a2a555a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,8 @@ disks and config, plus `dvm stop --inactive` / `dvm stop --all --inactive` for stopping only VMs without a detected active shell, `tmux`/`zellij`, or known DVM service unit. +- Added an opt-in zsh completion file at `share/dvm/completions/_dvm` for commands, + options, VM names, and bundled `init` templates. - Tightened `dvm rm` dirty check: when `git` is not installed in the guest, the check now exits with status 2 and refuses to delete the VM unless `--force` is passed. Previously the check exited cleanly when `git` was absent, allowing diff --git a/README.md b/README.md index 289f9e9..8f1e31a 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,17 @@ from a temporary snapshot of `bin/dvm` and its shell libraries, so editing or pu this repo cannot corrupt a long-running `dvm sync`. Bundled recipes, the Lima template, and example VM configs stay in the repo under `share/dvm`. +For zsh completion, add the in-repo completion directory before `compinit` in +`~/.zshrc`: + +```zsh +fpath=(/path/to/dvm/share/dvm/completions $fpath) +autoload -Uz compinit +compinit +``` + +If your `~/.zshrc` already runs `compinit`, add only the `fpath=...` line above it. + ## Commands ```bash diff --git a/docs/commands.md b/docs/commands.md index 1ed458b..bf11309 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -6,6 +6,25 @@ DVM keeps the command surface small. Most day-to-day work should still be `sync` Use public project names in DVM commands: `app`, `eshlox-net`, `llama`. The `dvm-` prefix is reserved for internal Lima instance names. +## Zsh Completion + +DVM ships an opt-in zsh completion file in the repo: + +```zsh +# ~/.zshrc +fpath=(/path/to/dvm/share/dvm/completions $fpath) +autoload -Uz compinit +compinit +``` + +Zsh loads completion functions from directories in `fpath`, so the path points to the +`completions` directory, not directly to `_dvm`. If your `~/.zshrc` already runs +`compinit`, add only the `fpath=...` line above the existing `compinit` call. + +The completion includes DVM commands, command options, VM names from +`$DVM_CONFIG/vms/*.sh`, DVM Lima instances from `limactl list`, and bundled `init` +templates. + ## Init ```bash diff --git a/install.sh b/install.sh index 51b1d10..eb9fe7e 100755 --- a/install.sh +++ b/install.sh @@ -88,6 +88,7 @@ init_config() { rel="${file#"$src"/}" case "$rel" in lima.yaml.in) ;; + completions/*) ;; lib/*) ;; recipes/*) ;; vms/*) ;; diff --git a/share/dvm/completions/_dvm b/share/dvm/completions/_dvm new file mode 100644 index 0000000..4cb3cda --- /dev/null +++ b/share/dvm/completions/_dvm @@ -0,0 +1,203 @@ +#compdef dvm + +local -a _dvm_commands +_dvm_commands=( + 'init:create a VM config from a bundled template' + 'sync:create or update a VM' + 'sh:open an interactive VM shell' + 'ssh:run a command in a VM' + 'cp:copy files between host and VM' + 'log:show VM journal logs' + 'ssh-key:create or show VM-local SSH keys' + 'gpg-key:create or show a VM-local GPG key' + 'ls:list DVM-managed VMs' + 'stop:stop one or more VMs' + 'rm:delete a VM' + 'help:show help' +) + +_dvm_vm_names() { + local config_dir file name + local -a names + config_dir="${DVM_CONFIG:-$HOME/.config/dvm}" + + for file in "$config_dir"/vms/*.sh(N); do + names+=("${file:t:r}") + done + + if (( $+commands[limactl] )); then + while IFS= read -r name; do + [[ "$name" == dvm-* ]] && names+=("${name#dvm-}") + done < <(limactl list --format '{{.Name}}' 2>/dev/null) + fi + + (( $#names )) && print -r -- "${(F)${(u)names}}" +} + +_dvm_complete_vm_names() { + local expl + local -a names + names=("${(@f)$(_dvm_vm_names)}") + (( $#names )) && _wanted vms expl 'DVM VM' compadd "$expl[@]" -a names +} + +_dvm_share_dirs() { + local dir share + local -a dirs + + [[ -n "${DVM_SHARE:-}" && -d "$DVM_SHARE/vms" ]] && dirs+=("$DVM_SHARE") + [[ -n "${DVM_ROOT:-}" && -d "$DVM_ROOT/share/dvm/vms" ]] && dirs+=("$DVM_ROOT/share/dvm") + + for dir in $fpath; do + share="${dir:A:h}" + [[ -r "$dir/_dvm" && -d "$share/vms" ]] && dirs+=("$share") + done + + (( $#dirs )) && print -r -- "${(F)${(u)dirs}}" +} + +_dvm_complete_templates() { + local dir expl file + local -a templates + + for dir in "${(@f)$(_dvm_share_dirs)}"; do + for file in "$dir"/vms/*.sh(N); do + templates+=("${file:t:r}") + done + done + + templates=("${(@u)templates}") + (( $#templates )) && _wanted templates expl 'DVM template' compadd "$expl[@]" -a templates +} + +_dvm_complete_vm_paths() { + local name + local -a names paths + names=("${(@f)$(_dvm_vm_names)}") + for name in $names; do + paths+=("$name:") + done + (( $#paths )) && compadd -S '' -a paths +} + +_dvm_describe() { + local tag="$1" label="$2" + shift 2 + local -a values + values=("$@") + _describe -t "$tag" "$label" values +} + +_dvm_complete_sync() { + local -a opts + opts=('--all:sync every VM config') + _dvm_describe options 'sync option' "$opts[@]" + _dvm_complete_vm_names +} + +_dvm_complete_stop() { + local -a opts + opts=( + '--all:stop every DVM-managed VM' + '--inactive:stop only VMs without detected active sessions or services' + '--force:with --inactive, stop when the activity probe fails' + '-f:alias for --force' + ) + _dvm_describe options 'stop option' "$opts[@]" + (( ${words[(I)--all]} || ${words[(I)--inactive]} )) || _dvm_complete_vm_names +} + +_dvm_complete_rm() { + local -a opts + opts=( + '--yes:confirm VM deletion' + '--force:skip dirty checks' + '-f:alias for --force' + ) + _dvm_describe options 'rm option' "$opts[@]" + _dvm_complete_vm_names +} + +_dvm_complete_cp() { + case "${words[CURRENT - 1]}" in + --backend) + compadd auto scp rsync + return + ;; + esac + case "$PREFIX" in + --backend=*) + compset -P '--backend=' + compadd auto scp rsync + return + ;; + esac + local -a opts + opts=( + '-r:copy directories recursively' + '--recursive:copy directories recursively' + '-v:show copy details' + '--verbose:show copy details' + '--backend=:select copy backend' + ) + _dvm_describe options 'copy option' "$opts[@]" + _dvm_complete_vm_paths + _files +} + +_dvm_complete_log() { + local -a units + units=( + 'dvm-cloudflared.service:cloudflared service' + 'dvm-llama.service:llama service' + 'tailscaled.service:tailscale service' + ) + if (( CURRENT == 3 )); then + _dvm_complete_vm_names + else + _dvm_describe units 'systemd unit' "$units[@]" + fi +} + +local cmd + +if (( CURRENT == 2 )); then + _dvm_describe commands 'dvm command' "$_dvm_commands[@]" + return +fi + +cmd="$words[2]" +case "$cmd" in +init) + if (( CURRENT == 4 )); then + _dvm_complete_templates + fi + ;; +sync) + _dvm_complete_sync + ;; +sh | ssh-key | gpg-key) + _dvm_complete_vm_names + ;; +ssh) + if (( CURRENT == 3 )); then + _dvm_complete_vm_names + else + _normal + fi + ;; +cp) + _dvm_complete_cp + ;; +log) + _dvm_complete_log + ;; +stop) + _dvm_complete_stop + ;; +rm) + _dvm_complete_rm + ;; +*) + ;; +esac diff --git a/tests/smoke.sh b/tests/smoke.sh index 7b549cf..b4f5323 100755 --- a/tests/smoke.sh +++ b/tests/smoke.sh @@ -209,9 +209,22 @@ grep -Fxq old-target "$TMP/old-dvm" grep -Fq 'dvm-run.' "$TMP/install-bin/dvm" grep -Fq 'DVM_LIB_DIR="$tmp_parent/lib"' "$TMP/install-bin/dvm" [ ! -e "$TMP/install-config/lib" ] +[ ! -e "$TMP/install-config/completions" ] "$TMP/install-bin/dvm" help >"$TMP/install-help.out" grep -Fq 'dvm init [template]' "$TMP/install-help.out" +completion="$ROOT/share/dvm/completions/_dvm" +[ -f "$completion" ] +grep -Fq '#compdef dvm' "$completion" +grep -Fq 'DVM_CONFIG:-$HOME/.config/dvm' "$completion" +grep -Fq "limactl list --format '{{.Name}}'" "$completion" +for cmd in init sync sh ssh cp log ssh-key gpg-key ls stop rm help; do + grep -Fq "'$cmd:" "$completion" +done +if command -v zsh >/dev/null 2>&1; then + zsh -n "$completion" +fi + "$ROOT/bin/dvm" init newapp [ -f "$TMP/config/vms/newapp.sh" ] grep -Fq 'DVM_CODE_DIR="~/code/$DVM_NAME"' "$TMP/config/vms/newapp.sh"