Skip to content
ks1686Public

About

GENV - The Global Environment Manager

Topics

Resources

Contributing

Security policy

Stars

3 stars

Watchers

0 watching

Forks

Latest commit

 

History

494 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

genv

Track, sync, and reproduce your software environment across macOS, Windows, Arch Linux, Ubuntu-like Linux, and WSL2.

genv is a thin layer over the package managers you already use. Desired state lives in one git-friendly genv.json. Applied state lives in a machine-local lock file. Run genv apply and the machine matches the spec.

Current release: latest (v4.7.6): schema v10 local module composition, verified dependency-aware service changes, and genv service restart. v4.7.6 installs an external archive that contains an unused hard link or symlink next to the binary. Schema v8 portable multi-target configs and schema v9 external releases remain supported.

genv add git                          # track + install
genv apply --dry-run                  # preview reconcile
genv apply --yes                      # apply without prompt
genv status                           # show drift
genv migrate --write                  # upgrade a legacy spec to v8
genv export --target ubuntu --out ./u # single-target snapshot + report
genv map --target arch                # assist-only manager suggestions

Install

Platform Install
macOS brew tap ks1686/tap && brew install --cask genv
Arch / Manjaro paru -S genv or yay -S genv (or genv-bin)
Other Linux GitHub release tarball (see below)
Windows Scoop (self-hosted bucket) or GitHub release zip (see below)
Any (from source) go install github.com/ks1686/genv@latest (Go 1.26.9+)

Linux x86-64 example (replace the version to match Releases):

curl -Lo genv.tar.gz https://github.com/ks1686/genv/releases/latest/download/genv_4.7.6_linux_amd64.tar.gz
tar -xzf genv.tar.gz
sudo mv genv /usr/local/bin/
genv version

Windows (Scoop, after Scoop itself is installed):

scoop bucket add ks1686 https://github.com/ks1686/scoop-bucket
scoop install genv

The bucket is self-hosted (not Scoop extras). scoop install genv needs a root genv.json on that bucket, which the first stable tag uploaded.

Windows (PowerShell zip):

Invoke-WebRequest -Uri https://github.com/ks1686/genv/releases/latest/download/genv_4.7.6_windows_amd64.zip -OutFile genv.zip
Expand-Archive genv.zip -DestinationPath .
# put genv.exe on PATH, then:
genv version

winget and Chocolatey installers for the genv binary are not published (GoReleaser Pro). Once genv is on PATH, it still manages packages through winget, Scoop, and Chocolatey.

Release archives ship cosign-signed checksums (keyless). Darwin binaries are also Developer ID signed and notarized when Apple secrets are configured — see SECURITY.md.

Platform walkthroughs: Linux · macOS · Windows · WSL2 · multi-machine


Quick start

One machine

genv init                              # optional wizard
genv add git
genv add neovim --version "0.10.*"
genv scan                              # bulk-adopt user-facing installs (not brew deps / stdlib)
genv status
genv apply --dry-run
genv apply --yes

Default paths (respect $XDG_CONFIG_HOME):

File Location Role
Spec ~/.config/genv/genv.json Desired state — edit / commit this
Lock ~/.config/genv/genv.lock.json Applied state — machine-local, never commit

Multiple machines (schema v8)

Put shared settings in defaults, OS-specific packages under targets.*, commit the spec, then apply per machine:

genv migrate --file genv.json --write   # if upgrading from v1–v7
genv map --target ubuntu                # see manager gaps (read-only)
genv apply --target ubuntu --dry-run
genv apply --target ubuntu --yes

Active target resolution: --target → $GENV_TARGET → host classification.

Full guide: docs/multi-machine.md.


Targets and package managers

Target Detected when Typical managers
macos macOS brew, mas
windows native Windows winget, scoop, choco
arch native Arch / Arch-like pacman, paru, yay
ubuntu Ubuntu-like Linux or Ubuntu-like WSL2 apt, snap, linuxbrew
wsl-arch Arch-like WSL2 pacman, paru, yay
linux optional catch-all (set via --target / GENV_TARGET) apt, dnf, apk, snap, linuxbrew, …

WSL2 does not inherit native arch automatically. Put shared bits in defaults; use targets.ubuntu or targets.wsl-arch for distro-specific packages.

Also available (explicit prefer / managers): bun, npm, pnpm, yarn, deno, volta, uv, pipx, pip-user, poetry, conda, mamba, pixi, cargo, go, rustup, gem, composer, dotnet-tool, ghcup, stack, opam, juliaup, sdkman, asdf, mise, krew, helm, vscode.

On schema v1-v8, external is a track-only pseudo-manager for software installed outside a package manager. Schema v9 can instead attach a managed external recipe that discovers GitHub Releases or structured HTTP releases, verifies the selected artifact, and installs/removes direct executables, archives, or installer scripts. See managed external releases and SCHEMA.md.

Schema v8 also accepts top-level adapters for plugin CLIs genv does not ship built-in (claude plugin, gh extension, …). Set prefer to the adapter name. Details and examples: SCHEMA.md.

Native apt, dnf, and apk adapters are registered system managers (prefer: apt|dnf|apk). Use genv map / genv export when moving a spec across Linux families.


Spec format (schema v8/v9/v10)

Recommended shape for new configs:

{
  "schemaVersion": "8",
  "defaults": {
    "env": {
      "EDITOR": { "value": "nvim" }
    },
    "shell": {
      "aliases": {
        "ll": { "value": "ls -lah" }
      }
    }
  },
  "targets": {
    "macos": {
      "packages": [
        { "id": "git", "prefer": "brew" },
        { "id": "ripgrep", "prefer": "brew" }
      ]
    },
    "ubuntu": {
      "packages": [
        { "id": "git", "prefer": "apt" },
        { "id": "ripgrep", "prefer": "apt" }
      ],
      "env": {
        "EDITOR": null
      }
    },
    "windows": {
      "packages": [
        {
          "id": "git",
          "prefer": "winget",
          "managers": { "winget": "Git.Git", "scoop": "git", "choco": "git" }
        }
      ],
      "shell": {
        "aliases": {
          "ll": { "value": "Get-ChildItem", "shell": "powershell" }
        }
      }
    }
  },
  "updates": {
    "enabled": true,
    "interval": "24h",
    "autoApply": false,
    "notify": true
  },
  "repo": {
    "url": "https://github.com/example/dotfiles",
    "ref": "main"
  }
}

v8 rules (short):

  • Desired state lives under defaults and/or targets.<id> — not as top-level packages / env / shell / files / services / hooks.
  • No per-record host in v8 (use target buckets).
  • Target map entries may be null tombstones to drop a default for one OS (EDITOR above).
  • repo and updates stay top-level.
  • Schema v7 adds "shell": "powershell" (POSIX-only when omitted). On native Windows, genv prefers pwsh, else Windows PowerShell, for profile fragments and hooks.

Legacy v1–v7 specs still load. Convert with genv migrate. Field-by-field reference: SCHEMA.md.


Modules (schema v10)

A spec can be assembled from local module documents instead of being one large file. genv.json registers them; each target selects them:

{
  "schemaVersion": "10",
  "modules": { "base": "modules/base.json", "dev": "modules/dev.json" },
  "targets": { "macos": { "useModules": ["dev"] } }
}

useModules is additive — selecting dev also pulls in base because dev requires it. Every command that reconciles (apply, status, upgrade, updates, scan) sees the composed result, so a module's packages install and report exactly like root ones.

Rules worth knowing before you split a spec up:

  • Modules are local and trusted. They are files on disk inside the spec directory; genv never fetches or verifies a signature.
  • A target overlay replaces the defaults array inside one module. Declaring both defaults.packages and targets.macos.packages in the same module gives you only the second, exactly as in v8. Keep a module's packages in one place.
  • No overrides. If the root and a module declare a resource identically it is kept once and both are recorded as owners; if they differ, composition fails and names both origins. Edit the module instead.
  • Module-owned resources are read-only from the CLI. add, remove, disown, adopt, env, shell, service, and files adopt refuse and tell you which module to edit.
  • Ask before you guess: genv config --target macos prints the composition, genv explain package jq --target macos prints who declares it.
  • genv export flattens a composed spec into a single-target snapshot and records the materialized modules in report.json.

How apply and locks work

  1. Read the spec and the lock.
  2. For v8: resolve the active target, merge defaults + target (+ tombstones).
  3. Refuse a foreign lock (wrong target / OS / unavailable managers). Recover with genv apply --force-new-lock (backs up the lock) or by removing it locally.
  4. Install packages in the spec but not the lock; uninstall lock entries removed from the spec.
  5. Reconcile env, shell, files, services, hooks as configured.
  6. Update the lock (v8 records target + goos).

Convenience commands (add / remove / adopt / disown / scan) update the spec and usually the live system in one step. genv add installs first and only persists the spec after a successful install (unresolved or failed installs exit 4 and leave the spec unchanged; use adopt to track without installing). On v8 they write into targets.<active> (--target or $GENV_TARGET / classification).

Human apply prompts unless --yes. Because a JSON caller cannot answer a prompt, genv apply --json is plan-only unless --yes is passed (or --dry-run to plan explicitly); a wet run without --yes returns the plan with wet-run requires --yes and changes nothing. Hooks receive the same consent through GENV_YES. An already-applied spec still reports ok, matching genv upgrade --json.

genv.json itself is written 0600 (like the lock and the private fragments) because a spec can hold env values marked sensitive. A spec left world-readable by an older genv is tightened to match the lock.

Managed links are compared by resolved path, so a relative link pointing at the same file as an absolute source counts as correct. Relative file sources and hook file paths resolve against the spec directory (--source-root overrides), which is where genv pull and genv export place bundled assets.

genv scan adopts user-facing installs by default: Homebrew brew leaves plus casks (not the full formula tree), Ruby gems that are not default or bundled with the interpreter, and pip-user packages that are not dependencies of other user-site packages (minus installer/stdlib-like noise such as certifi / setuptools). npm/pnpm/yarn already list top-level globals only (and scan never proposes npm itself). uv proposes tool names from uv tool list headers, not - entrypoint bullets. rustup toolchains are not proposed. Pass --all or --deps to adopt every ListInstalled name, including Homebrew libraries and language stdlib. Scan still never proposes -, npm via npm, or toolchain:*. Preview with --dry-run; text mode prompts unless --yes is set.

genv pull fetches genv.json and relative files assets plus hook scripts from repo.url. It never overwrites the lock or secrets. Only relative sources are bundled: a source that is absolute, ~/…, or contains a $VAR is reported rather than copied, and symlinks are refused.


CLI reference

Command Purpose
add / remove (rm) Track + install / untrack + uninstall
adopt / disown Track without install / untrack without uninstall
scan Bulk-adopt user-facing installs (--dry-run, --yes; --all / --deps for full trees). Exits non-zero if a manager's inventory could not be read
list (ls) Show lock-tracked packages
status Spec ↔ lock drift (--files includes content drifted, --offline, --verify, --target); a version-less lock entry the live inventory contradicts is unknown
apply Reconcile (--dry-run, --yes, --json, --force, --backup, --strict, --quiet, --skip-packages, --timeout <d>, --no-hooks, --hook-timeout <d>, --target, --force-new-lock, --state-dir, --source-root <dir>)
config Composed environment for a target: module selection, fingerprint, counts (--registry lists modules without composing; --kind + --name shows one resource's owners)
explain Where one resource comes from and whether a mutation command may change it (genv explain package jq --target macos)
validate Validate spec + every registered module + genv-managed agent executables
upgrade Upgrade tracked packages plus OS vendor updates (--all, --only / leftover IDs, --skip, --only-manager, --skip-manager, --target; --json wet-run requires --yes)
updates Background checker (check / start / stop / status; --target, --only, --skip, --only-manager, --skip-manager on check/start)
profile Named overlays (list / create / switch; refused on portable specs, schema v8 and newer)
pull Fetch spec + file assets from repo
migrate v1–v7 → v8 targets (refuses v9 and v10; they are already portable)
export Single-target snapshot + report + assets (--verify proves live installs)
map Assist-only manager mapping suggestions
init / edit Wizard / $EDITOR
env / shell / service / files Env vars, aliases, user services (launchd / systemd templates, Windows scheduled_task), files adopt
completion bash / zsh / fish / powershell
clean Clear detected manager caches. A busy uv cache is skipped (one-second wait unless UV_LOCK_TIMEOUT is set). An empty pip cache is not a failure
version / help Build info / usage

Shell completions

Install for your shell (auto-detects from $SHELL when omitted):

genv completion install        # bash, zsh, or fish
genv completion install powershell

Tab completion on add / adopt suggests package names from available managers (Homebrew-style local dumps when possible). After you accept a name, interactive genv add still asks which manager to use when multiple match.

Common flags

  • --file <path> — spec path for commands that read genv.json (default under ~/.config/genv/). updates status and updates stop inspect the scheduler and do not take --file.
  • --lock-file <path> — lock path (default genv.lock.json next to --file)
  • --state-dir <dir> — directory for lock and env/shell fragments (default: directory of --file)
  • --target <id> — v8 target for apply / status / upgrade / updates / mutate / export / map / scan
  • --host <name> — legacy host filter override for v1–v7 records / hooks (defaults via host classification, not hostname)
  • --json — machine-readable envelope on stdout; subprocess noise on stderr

Apply / portability flags worth knowing

genv apply --target windows --yes                 # adopt live apps, install only the missing
genv apply --skip-packages --yes                  # links + env only
genv apply --timeout 30m --hook-timeout 2m        # cap each subprocess / hook (default 10m; 0 disables)
genv apply --target ubuntu --dry-run --json
genv apply --force --backup --yes                 # overwrite mismatched files; keep *.backup.*
genv files adopt ~/.foo --dry-run                 # seed missing source, backup live file, link
genv apply --target ubuntu --force-new-lock --yes   # after a foreign lock refuse
genv apply --dry-run --file ./worktree/genv.json --source-root ~/.config/genv
genv status --target windows                      # present vs missing vs ok
genv status --verify                              # Query each manager: is this package actually installed?
genv adopt cursor --target windows                # lock Anysphere.Cursor if already installed
genv export --target macos --out ./dist/macos --strict
genv export --target macos --out ./dist/macos --verify --strict
genv migrate --write

File mismatches without --force no longer block packages/services: non-conflicting file ops still apply, each mismatched path is printed, and apply exits 4 if any remain.

Lifecycle hooks

Hooks should check-then-act and exit without doing work when the system is already current. On success, print one of:

echo GENV_HOOK_STATUS=changed
echo GENV_HOOK_STATUS=skipped

Non-zero exit is error. Exit 0 without a status line is treated as changed (legacy exit-only hooks). After each phase, apply prints changed, skipped (no-op), or error next to the hook name, exit code, and duration. See SCHEMA.md.

User services (launchd / systemd / Task Scheduler)

Declare a supervisor-backed service in the spec instead of a postApply hook:

"services": {
  "syncthing": {
    "launchd": { "plist": "launchd/com.example.syncthing.plist" },
    "systemd": { "unit": "systemd/syncthing.service" },
    "scheduled_task": {
      "action": "C:\\Program Files\\Syncthing\\syncthing.exe",
      "args": ["--no-browser"],
      "trigger": "logon",
      "restart_on_failure": true
    }
  }
}

On macOS and Linux, genv apply renders the template (__HOME__ and the other files.templates[] placeholders), writes ~/Library/LaunchAgents/<Label>.plist or ~/.config/systemd/user/<basename>.service, and loads it. Editing the template and applying again re-bootstraps the launchd job or restarts the systemd unit.

On Windows, scheduled_task renders the Task Scheduler definition itself — no template file — and registers it with schtasks. action must be an absolute path (Task Scheduler does not search PATH); trigger is logon (default), boot, daily or weekly, with at (HH:MM) required for the last two. The default user principal registers without elevation.

genv service status syncthing reads supervisor state. genv service restart syncthing is the safe imperative counterpart: it uses brew services restart, launchd re-bootstrap, systemctl --user restart, a declared restart argv, or a stop-then-start fallback as appropriate. A scheduled Task is a trigger rather than a resident process, so restart refuses it. For a service with schema-v10 requires, restart refuses before touching anything when a dependency is down; it never restarts dependencies as an unexpected side effect. A declared health_check is run after the restart and makes the command fail if the service is not ready. Removing the service from the spec unloads it and deletes the unit file or unregisters the task. Declaring a backend for a platform you are not on is skipped, not an error, so one spec can target several. See SCHEMA.md.

Dependency-aware service changes (schema v10)

A service can say what it depends on and what it watches (schema v10):

"api": {
  "start": ["api-server"],
  "requires": ["db"],
  "watch": ["postgres", "~/.config/api/api.conf"],
  "restart_policy": "ifRunning",
  "health_check": { "command": ["curl", "-fsS", "localhost:8080/health"] }
}

genv upgrade then restarts api only when postgres actually changed, and genv apply restarts it only when that config file was created or rewritten. It starts it first if it was stopped (under ifRunning), and waits for the health check before calling the run done. A watch entry that is neither a tracked package nor a managed file is refused rather than accepted and silently ignored. Watching another service is not a trigger yet.

The part worth knowing is what it does not claim:

  • An upgrade that ran and left the installed version unchanged causes no restart.
  • A version genv cannot establish — many managers never report one — produces a deferral, not a restart on a guess. "I could not tell" is an outcome.
  • A readiness failure is reported separately from a restart failure: the service did start, it is simply not healthy, and the remedies differ.
  • The health check never runs from status or any dry run.

If genv is interrupted mid-restart, it says so on the next run instead of assuming the service came back. The unattended updates worker will not upgrade a package whose watched service it cannot restart and verify without a human — it defers that package and takes the rest — and it restarts the services it can once the upgrade lands. The same phase runs under genv upgrade --json, which reports each service under a services key: an output flag never changes what the command does.

Updates checker

genv updates start registers a user systemd timer (Linux), launchd job (macOS), or Task Scheduler task (schtasks, Windows). Default behavior is check / log / notify for tracked packages only. Set "autoApply": true in the updates block to apply those tracked upgrades automatically. The timer is non-interactive: it never prompts for sudo or UAC, and skips packages that need elevation (logged in updates.log). OS vendor and firmware updates are not part of the checker — use genv upgrade for that. Details: SCHEMA.md.

Exit codes

Code Meaning
0 Success
1 Bad arguments / unknown command
2 I/O or serialization error
3 Spec failed validation
4 Semantic error (also status when drift exists; foreign lock refuse)

Resolution and upgrades (summary)

Install resolution: detect available managers → honor prefer → try managers map → fall back to system managers using the package id. Language / toolchain / plugin managers (including v8 spec adapters) are explicit-only (must set prefer or managers) so git never silently resolves through npm.

Upgrades: genv upgrade applies tracked-package updates, then OS vendor updates for the active target (macOS softwareupdate, Windows Update Agent COM via PowerShell (often needs an elevated session; genv does not auto-elevate; WUA ResultCodes are mapped to readable errors), Arch pacman -Syu, Ubuntu apt-get upgrade). Firmware uses fwupdmgr on Linux when present; macOS firmware ships through softwareupdate, and Windows firmware is skipped as vendor-specific. genv updates check and the timer stay tracked-packages-only and share the tracked planner with upgrade. The timer rewrites refresh/apply sudo to sudo -n and skips unelevated winget/choco (and paru/yay) rather than prompting. Before outdated detection, the planner refreshes each index-based manager that still has candidates (brew update, sudo apt-get update, sudo pacman -Sy, and the equivalents on paru/yay/dnf/apk/scoop/winget). Live registries (mas, npm/bun, uv/pipx, cargo, volta, choco, snap, vscode) are queried as-is. A failed refresh keeps that manager's packages and prints a warning — same conservative rule as a failed outdated query. A manager whose binary is gone (Available() false) is skipped with an explicit reason instead of planning brew update / brew upgrade that cannot run. By default they plan packages with a detected update; --all on upgrade still refreshes, then plans every unconstrained tracked package. Leftover positional IDs (genv upgrade git) are treated as --only. Human upgrade prompts unless --yes; upgrade --json wet-run also requires --yes (or --dry-run to plan only). Packages with a non-empty version are always skipped; genv does not yet plan range-satisfying upgrades. Batched where the manager allows (brew, pacman/paru/yay, apt/dnf/apk, mas, snap, scoop, choco, …). The vscode manager compares against the marketplace's newest stable version (pre-release builds are ignored; --install-extension --force cannot install them). It invokes cursor when that CLI is on PATH, otherwise code. brew outdated uses --greedy so auto-updating casks are not hidden after the index fetch.


Project status

Area State
Core CLI + declarative apply Stable
macOS / Windows / Arch / Ubuntu / WSL targets Stable (v4.0.0)
Schema v7 PowerShell profiles Stable
Schema v8 portable targets Stable
Background updates + profiles + hooks Stable
apt / dnf / apk adapters Stable
Publish genv to Scoop Self-hosted bucket ks1686/scoop-bucket; uploads when SCOOP_BUCKET_GITHUB_TOKEN is set
Publish genv to winget / choco Not published; publishers are GoReleaser Pro-only

Historical milestone checklists: ROADMAP.md. Release notes: CHANGELOG.md. Tag-driven publishing: RELEASING.md.


Contributing

See CONTRIBUTING.md.

License

MIT

About

GENV - The Global Environment Manager

Topics

Resources

Contributing

Security policy

Stars

3 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages