gripsack — personal environment manager
your whole environment, in one bag.
set up a new machine from one repo — packages, dotfiles and machine-specific config, with generations and rollback.
already installed?
grip self-updatebrew upgrade --cask gripsackcargo install gripsackmise up gripsack
· what's new
release binaries ·
the frontend is embedded in the binary — grip provisions its pinned,
hash-verified Deno runtime automatically on first eval;
npm i @gripsack/core only to pin your own copy
start with one dotfile
you don't have to rebuild your life. gripsack manages user-space tools and your configuration — it does not replace your OS package manager; brew or apt keep handling binaries, and package fetching is adopted later, tool by tool. the ownership question is the whole game:
$ grip init my-env && cd my-env $ grip adopt ~/.config/helix adopting ~/.config/helix — 2 files, 1.1 kB how should gripsack own these files? owned read-only link — the repo is the only editor ❯ tracked_copy real file — the app may rewrite it; never clobbered merge one managed block inside a shared file # it asks, because only you know — the answer lands in modules/helix.ts ✓ how should gripsack own these files? owned wrote configs/helix/ · modules/helix.ts · hosts/laptop.ts ↻ ~/.config/helix/config.toml will be adopted (prior recorded) apply? [y/N] y applied — generation 1 active $ grip rollback rolled back to generation 0 # your original files have been restored — not deleted, restored
four ownership modes, because real dotfiles are messy:
owned — a read-only symlink for disciplined tools;
tracked_copy — a real copy for apps that rewrite their own config (Zed), with
your edits detected, never overwritten;
merge — a managed block inside a shared file like .bashrc;
template — rendered from per-host vars.
the full adopting guide.
see it happen
re-rendered by CI from real demo runs — always the current CLI, in your palette.
treat your computer like code
describe
modules are typed TypeScript in your repo: sources, config files, ownership.
check
eval + semantic passes + 23 config linters, with spans pointing at the exact line. nothing stages on a red check.
plan
review the diff against the live generation before anything moves.
apply
one atomic flip into a new generation — a failed apply restores the previous managed state.
roll back
flip current back — every managed destination follows. that's the whole mechanism.
changing your machine should feel like changing code: typecheck it, inspect the plan, apply it atomically, roll it back.
a module
// modules/helix.ts — a module is a pure exported value import { module, githubRelease, symlink, trackedCopy } from "@gripsack/core"; export const helix = module("helix", { fetch: githubRelease({ repo: "helix-editor/helix", asset: "helix-{version}-{system}.tar.xz", // {version} + {system} — tag and host platform, expanded by the core }), install: { "bin/hx": symlink("~/.local/bin/hx") }, config: { "config.toml": trackedCopy("~/.config/helix/config.toml"), "languages.toml": trackedCopy("~/.config/helix/languages.toml"), }, }); // hosts/laptop.ts — a host is a function; ctx carries facts and probes import { defineEnv } from "@gripsack/core"; import { helix } from "../modules/helix.ts"; import cuda from "../modules/cuda.ts"; export default defineEnv((ctx) => ({ tags: ["desktop"], modules: [ helix, ctx.probe.executable("nvidia-smi") && cuda, // the core binds the probe; eval never runs it ], }));
evaluation is sandboxed — no env vars, no network, no subprocesses.
facts and probes arrive via ctx, injected by the core; effects are
declared, never performed. eval emits IR (JSON) — the Rust core only ever
consumes IR. the frontend is embedded in the binary and fully typed: your
editor gives you autocomplete and inline errors, because a module is just
typed code.
errors that point at your code
your dotfiles repo: helix needs its language servers, so it
declares dep("lsp") — but you renamed that module
pyright last week.
import { dep, githubRelease, module, symlink } from "@gripsack/core"; export default module("helix", { fetch: githubRelease({ repo: "helix-editor/helix", asset: "helix-{version}-{system}.tar.xz" }), install: { "bin/hx": symlink("~/.local/bin/hx") }, depends: [dep("lsp")], });
$ grip plan error[E101]: module "helix" depends on unknown module "lsp" --> modules/helix.ts:7:14 | 7 | depends: [dep("lsp")], | ^ dependency declared here
you copy ripgrep's asset naming from its GitHub releases page and typo the platform placeholder.
export default module("ripgrep", { fetch: githubRelease({ repo: "BurntSushi/ripgrep", asset: "ripgrep-{version}-{sytem}.tar.gz" }), install: { "ripgrep-{version}-{target}/rg": symlink("~/.local/bin/rg") }, });
$ grip check error[E114]: unknown placeholder '{sytem}' in "ripgrep-{version}-{sytem}.tar.gz" --> modules/ripgrep.ts:3:14 | 3 | asset: "ripgrep-{version}-{sytem}.tar.gz" }), | ^ help: did you mean '{system}'?
the config lives one directory up from the repo — tempting to point at. payloads must live inside the repo, so a deploy can never read (or write) outside it.
export default module("git", { config: { "../shared/gitconfig": trackedCopy("~/.gitconfig"), }, });
$ grip check error[E115]: module "git": source "../shared/gitconfig" — `..` escapes are never allowed --> modules/git.ts:3:5 | 3 | "../shared/gitconfig": trackedCopy("~/.gitconfig"), | ^ entry declared here
a stray key that's been sitting in your helix config doing nothing — it deploys fine and fails in the editor, an hour later, in a different terminal. the linter closes that gap at check time.
theme = "catppuccin_mocha" themez = "nord"
$ grip check error[griplint-helix/A01]: unknown key 'themez' --> configs/helix/config.toml:2:1 | 2 | themez = "nord" | ^ help: did you mean 'theme'?
upstream renamed the asset in the latest release — the error tells you what is there, not just what you asked for.
export default module("fd", { fetch: githubRelease({ repo: "sharkdp/fd", asset: "fd-{version}-x86_64-unknown-linux.tar.gz" }), install: { "fd-{version}-{target}/fd": symlink("~/.local/bin/fd") }, });
$ grip apply error[E301]: step resolve failed in fd: no release asset matching "fd-{version}-x86_64-unknown-linux.tar.gz" in sharkdp/fd (have: fd-v10.4.2-x86_64-unknown-linux-musl.tar.gz, …)
every layer speaks the same diagnostic: eval, placeholders, paths,
config linters, execution — stable codes, spans, suggestions, never a bare
error: line. and because the passes are library functions over
span-labeled IR, an LSP is a protocol shim, not new analysis.
what it does
dotfiles that don't fight their applications
your config files, managed per file: read-only links for disciplined tools, copies that never overwrite your edits, managed blocks in shared files. per-host variants pick files by machine facts — no templating language.
any source
install tools from anywhere: github releases, brew bottles, git, tarballs, conda — and anything with a weirder download dance as a small plugin. every byte is hash-checked. the fetchers.
generations
every apply is a complete new generation; switching is one atomic flip, and a failed apply rolls its partial deploys back. roll back by flipping to any previous one. a no-op apply creates nothing.
lockfiles, not sandboxes
inputs pinned by URL and content hash, per host. reproducible inputs, without requiring hermetic builds.
compare
| gripsack | nix + home-manager | chezmoi · stow | apt · brew · mise | |
|---|---|---|---|---|
| manages your packages | ✓ any source | ✓ | · | own repos |
| safely manages messy dotfiles | ✓ never clobbers; drift detected | whole files only | templates | · |
| rolls the managed environment back | ✓ generations | ✓ | git history | · |
| config ownership modes | symlink · tracked copy · managed block · template | no managed blocks, no drift detection | templates | · |
| no custom language | typescript | nix lang | ✓ | ✓ |
| reproducible inputs | lockfile pins | flakes + sandboxed builds (stronger) | ✓ | · |
| no daemon, no root | ✓ | daemon · /nix | ✓ | apt needs root |
| per-host conditionals | facts + tags | modules | templates | · |
| config linters, at eval, with spans | ✓ | · | · | · |
| fair is the point: Nix is the stronger guarantee. gripsack borrows one of its best ideas — generations — and stays a tool you can adopt one dotfile at a time. | ||||
install
release binaries ·
the frontend is embedded in the binary — the pinned Deno runtime provisions itself on first eval
then: grip init my-env to start fresh, or
grip apply --repo git@github.com:you/myenv and you're home.
follow along
shipped so far: fetchers for github releases, brew bottles, git, tarballs,
pixi — plus plugin fetchers over NDJSON, generations with rollback, and ownership
modes that never clobber. latest: constrained eval — the frontend runs sandboxed
under a pinned, hash-verified deno (no env vars, no network, no subprocesses),
facts and probes arrive injected via ctx, and the first eval of an unfamiliar
repo is an explicit trust decision. grip self-update — the
package manager updates itself.
what's new, every release.
star the repo if this is the package manager you also wish existed.
pick a palette
pure CSS variables — the logo re-tints too. your pick persists per browser.