vasari-sync
v0.6.3
Published
Sync non-VCS project files (.env, secrets, local config) to storage you already own
Downloads
1,468
Maintainers
Readme
vasari-sync (vsync)
A cross-platform CLI for syncing the project files that must never touch
version control — .env files, secrets, internal docs, per-machine config —
to storage you already own: your own S3 bucket, your own SFTP server,
your own WebDAV share, or a private GitHub repo.
Why this exists
Every project has a handful of files that are too sensitive for a shared git repo but still need to follow you between machines: your laptop and your desktop, or you and a teammate. The usual answers are bad ones:
- Commit them — leaks secrets into history forever.
- Recreate by hand on every machine — drifts, gets forgotten, breaks at the worst moment.
- A shared cloud drive — works until you want S3, or SFTP, or a repo you already run.
vsync closes that gap. git clone carries your committed files; one
vsync pull carries the rest.
⚠️ Security model — read this first
vsync does not encrypt anything. Files are uploaded to your backend
exactly as they are on disk. The security model is simple:
Trust the backend, not the tool.
- Use a private backend (a private bucket, your own server, a private
repo).
vsyncis not a "safe to push to a public repo" tool. - Credentials (S3 keys, SFTP passwords, GitHub tokens) are stored locally
in
~/.vsync/config.jsonwith0600permissions (owner-only) — readable by your user account and anything running as it. There is no OS-keychain integration in v1; the file permission is the boundary. vsync statusandvsync diffprint paths only by default. Content diffs are opt-in (vsync diff --show-values) and print actual values to your terminal.
Requirements
- Node.js ≥ 22.13
giton your PATH (file discovery uses real.gitignoresemantics)
Install
npm install -g vasari-syncOr run it without installing:
npx vasari-sync --helpQuickstart
Machine A (first time):
# 1. Point vsync at storage you own (interactive; tests the connection
# before saving). Secrets go to ~/.vsync/config.json, never the project.
vsync config
# 2. Inside your project (a git repo): pick a project ID and which
# git-ignored files to track — a folder tree; suggested files (.env*,
# *.pem, id_rsa*, *secret*, ...) are pre-checked, folders select whole
# subtrees. Nested git repos ignored by the parent are scanned too.
vsync init
# 3. Upload the tracked files.
vsync push
# Later — see what changed and sync it:
vsync status # paths only, cheap (one backend listing)
vsync diff # differing paths + untracked candidates
vsync pushinit writes .vsync/manifest.json (backend name, tracked paths +
hashes) and keeps .vsync/ out of git — the manifest lists your secret
paths, so it must never reach the repo. Nothing machine-specific
enters the project: each machine resolves settings and credentials from
its own ~/.vsync/config.json profile.
Machine B (or a fresh clone):
git clone <your-repo> my-project && cd my-project
vsync config # once per machine: save backend settings + credentials
vsync link my-project # rebuild the manifest from the backend, then pull
# (project ID = what `vsync list` shows on machine A)From then on, the loop on any machine is: work → vsync push; sit down
elsewhere → vsync pull. Comparison is live two-way: the current
local files against the backend's current state (tracked in a small
.vsync-index.json sidecar vsync keeps on the backend). A differing file
is simply overwritten in the direction you choose — push makes the
remote match local (files deleted locally are deleted remotely), pull
makes local match the remote. Both show the full plan and ask for
confirmation first (--yes to skip).
Full command reference: docs/commands.md.
Supported backends
| Backend | For | Fields prompted by vsync config |
| ------------- | ---------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| s3 | AWS S3, MinIO, Cloudflare R2, Backblaze B2, DigitalOcean Spaces, any S3-compatible store | region, bucket, endpoint (blank = AWS), accessKeyId *, secretAccessKey *, forcePathStyle |
| sftp | Your own SSH/SFTP server | host, port (default 22), username, password *, privateKeyPath (instead of password), remoteBasePath |
| webdav | Nextcloud, Apache/nginx DAV, any WebDAV share | url, username, password *, remoteBasePath |
| github-repo | A private GitHub repo — the only backend with native versioning (git history) | owner (defaults to gh login), repo (name or URL), branch (blank = repo default), token * (reused from the gh CLI when signed in), remoteBasePath (optional) |
| local-fs | A plain directory — test/eval backend; also handy for sync via a mounted/synced folder (Dropbox-style folders, mounted NAS shares) | basePath |
* = secret; stored only in ~/.vsync/config.json (0600), never in the
github-repo note: files of any size up to GitHub's 100 MB blob limit are supported — reads over 1 MB transparently go through the git blobs API (the contents API caps reads at 1 MB). project config that gets committed.
Remote layout: every project's files live under a <projectId>/ prefix at
the backend (<remoteBasePath>/<projectId>/<project-relative-path>), so
several projects can share one bucket/server without colliding.
What lives where
| Path | Contents | Committed to git? |
| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- |
| .vsync/manifest.json (project) | backend name, tracked paths (a plain list — no hashes) | no — git-ignored, rebuilt via vsync link |
| <projectId>/.vsync-index.json (backend) | vsync's remote index: hash/size/push-time of every file on the backend | n/a — lives on the backend, never local |
| ~/.vsync/config.json (machine) | backend profiles, credentials (0600), local project registry (checkout paths for vsync list) — or any file passed via --config/VSYNC_CONFIG (see below) | no |
Which files does init suggest?
vsync init scans what git ignores (project, nested, and global
excludesfile semantics — via git status --ignored) and classifies:
- Suggested (pre-checked):
.env*,*secret*,*credential*,*.pem,*.key,id_rsa*,config.local.*— under 50 KB. - Shown (unchecked): every other ignored file under 10 MB.
- Never shown: anything inside
node_modules/,dist/,build/,.next/,target/,vendor/,__pycache__/, cache/log dirs, or over 10 MB.
Selection happens in a folder-tree checklist: folders show
[ ]/[~]/[x] (none/some/all selected) and toggling one selects or
clears its whole subtree; →/← expand/collapse, a/n select
all/none.
Nested git repos
Projects sometimes contain other git repos ignored by the parent
(the umbrella-repo pattern: parent .gitignore has /some-subrepo/).
init scans those too — the sub-repo's own .gitignore decides what
counts as a candidate, so a .env ignored inside it is offered under its
project-relative path (some-subrepo/.env) while the sub-repo's tracked
files never are. In the tree picker those folders are tagged
nested repo. This is exactly how you sync .env/CLAUDE.md files from
checked-out sub-repos under one umbrella project.
Scripting & agents
Every command runs without a TTY. Each interactive prompt has a flag
twin: a flag wins, a TTY prompts as before, and with no TTY the missing
flag either takes a safe default or fails fast naming the flag — nothing
hangs. Every command also takes --json for machine-readable output
(exactly one object on stdout; warnings, progress, and errors go to
stderr; exit codes: 0 success, 1 failure). On a partial push/pull the
result object is still printed before the error, so agents get per-file
detail plus exit 1.
Flag matrix
| Command | Non-interactive flags |
| --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| config | --backend <name>, --set key=value (repeatable), --secret key=value (repeatable) — or the VSYNC_SECRET_* env vars; a failed connection test aborts (nothing saved) |
| init | --project-id <id> (default: folder name), --backend <name> (default: global default), --files a,b (repeatable; omitted = track nothing), --yes (re-init), --list (print candidates and exit) |
| link | --pull (pull right after linking; without a TTY the pull is simply skipped — exit 0) |
| update | -y/--yes (without it and no TTY: error) |
| add, rm, status, diff, push, pull | already non-interactive (--yes on push/pull, --show-values on diff) |
Large file lists (add/rm paths, init --files) can go over stdin
instead of argv — vsync add - < paths.txt reads newline-separated
project-relative paths. The OS caps command-line length (cmd.exe ~8k
characters), so lists beyond ~100 paths belong on stdin.
Multiple profiles on one machine (--config)
Every command accepts a global --config <file> flag (or the VSYNC_CONFIG
env var) that points at a complete config file — profiles, credentials,
project registry — instead of ~/.vsync/config.json. Two people sharing a
device (or one person keeping work/personal profiles separate) each pass
their own file and never collide:
# one-time setup per user (secrets via env vars preferred over --secret)
VSYNC_SECRET_ACCESS_KEY_ID=AKIA... VSYNC_SECRET_SECRET_ACCESS_KEY=... \
vsync --config ~/.vsync/alice.json config --backend s3 \
--set region=eu-west-1 --set bucket=alices-vault --json
# then every command through that file — an alias hides the flag
alias va='vsync --config ~/.vsync/alice.json'
va init --project-id myapp --files .env
va push
va list # only alice's projects — registries are per-file--config may appear before or after the subcommand, beats the VSYNC_HOME
env var, and never falls back to the shared ~/.vsync/config.json while
set. Without it, behavior is exactly as before. (The per-project manifest
stays per-checkout — each user keeps their own clone, as with any shared
working tree.)
Secrets for config
Each backend's secret fields can arrive via flag or env var (constant-case
field name, VSYNC_SECRET_ prefix). --secret beats the env var; both
beat a previously saved secret. A secret supplied through --set is
auto-routed to secret storage, never the plaintext profile. A gh CLI
token is reused automatically for github-repo when no other token is
supplied.
VSYNC_SECRET_ACCESS_KEY_ID=AKIA... \
VSYNC_SECRET_SECRET_ACCESS_KEY=... \
vsync config --backend s3 --set region=us-east-1 --set bucket=my-bucket --json| Backend | Secret fields |
| ------------- | -------------------------------------------------------------- |
| s3 | VSYNC_SECRET_ACCESS_KEY_ID, VSYNC_SECRET_SECRET_ACCESS_KEY |
| sftp | VSYNC_SECRET_PASSWORD |
| webdav | VSYNC_SECRET_PASSWORD |
| github-repo | VSYNC_SECRET_TOKEN |
(--secret argv values are visible in process listings — prefer env vars.)
JSON shapes (stable; additive changes only)
status→{projectId, backend, files: [{path, status, note?}]}—status∈differs | missing-locally | remote-missing | unchangeddiff→{projectId, backend, files: [{path, status}], candidates: [{path, size, classification, rule?}], patches?: [{path, patch}]}(patchesonly with--show-values)list→{projects: [{projectId, backend, fileCount|null, linked, path?, lastSyncedAt?, missingOnDisk?}], unreachable: [string]}init→{projectId, backend, files: [string]};init --list→{candidates: [...]}link→{projectId, backend, files: [string], pull?: <pull result>}(pullpresent only with--pull)push/pull→{projectId, backend, files: [{path, outcome, note?}], summary: {<outcome>: count}}add→{added: [string]};rm→{removed: [string]}config→{backend, saved: true, secretsStored: [string]};config --show→{defaultBackend, profiles: {name: {backend, settings, secrets: [fieldNames]}}}update→{current, latest, updated}
Typical agent session
vsync config --backend local-fs --set basePath=/srv/vsync --json </dev/null
cd myproject
vsync init --list --json </dev/null # discovery: what's trackable
vsync init --project-id myproject --files .env --json </dev/null
vsync status --json # paths + statuses only
vsync push --json
vsync pull --json # on the next machine after `link`Updating
vsync update # checks npm, confirms, installs — or: npm install -g vasari-sync@latestDevelopment
npm install
npm run build # tsc → dist/
npm test # vitest (unit + integration, all against fakes/local-fs)
npm run lint
npm run typecheckSee local-docs/PLAN.md for the task breakdown and docs/commands.md for the full command reference.
License
MIT
