vsync v0.6.0 — Spec
Status: design · target package @muthuishere/vsync · additive over 0.5.x (no wire-format break, no config-schema break) — one intentional behavior break: see §5.
One theme: .env.<env> can reference files inside the vault by relative path, and vsync sync ships the file contents (not the path string) to GitHub / GCP. The rule is a single suffix convention: KEY_PATH=… or KEY_FILE=… → secret named KEY carries the bytes.
Motivating use case: an SSH private key generated locally and stored under <vault>/keys/dev_ssh. In 0.5.x only two hardcoded keys handled this (SSH_KEY_PATH → SSH_PRIVATE_KEY and GCP_SA_KEY_FILE_PATH → GCP_SA_KEY), and their path resolution was CWD-relative — so task <env>:sync:gh invoked from a subdirectory silently looked in the wrong place. v0.6 replaces both hardcoded cases with one generic suffix rule, anchors path resolution to the env file's own directory (the vault root), and makes file-ref errors fail the whole sync instead of skipping silently.
For prior context, see v0.2-secret-lib.md, v0.3-vsync-rebrand.md, v0.4-audit-log.md, and the vsync use work shipped in 0.5.x.
1. Diff from 0.5.x
| 0.5.x | 0.6.0 | |
|---|---|---|
| File-ref convention | 2 hardcoded keys (SSH_KEY_PATH, GCP_SA_KEY_FILE_PATH) | one generic suffix rule: *_PATH / *_FILE → strip suffix, push file body |
| Pushed-name mapping | SSH_KEY_PATH → SSH_PRIVATE_KEY, GCP_SA_KEY_FILE_PATH → GCP_SA_KEY (asymmetric) | Stripped suffix only. Pushed name == env-file key minus _PATH/_FILE. Symmetric. |
| GCP JSON pre-validation | Yes (startsWith("{") check) | No. vsync pushes whatever bytes are in the file. Validation is the operator's problem. |
| Path resolution | CWD-relative; ~/ expanded | vault-root-relative, ~/, ${HOME}, ${VAULT_ROOT} placeholders, absolute pass-through |
| Missing referenced file | SSH_KEY_PATH warned & skipped; GCP threw on bad JSON | All-or-none: any missing/unreadable file aborts the sync before any push, with aggregated error |
| Placeholder expansion | Only ~/ (in the two hardcoded paths) | ${VAULT_ROOT}, ${HOME}, ~/ (leading) — expanded in every value |
| Wire format / config schema / CLI verbs | unchanged | unchanged |
No client written against 0.5.x breaks on the wire: bundle format, share-file format, audit log, CLI verbs, config schema — all identical. The break is at the parse layer, and only for env files that used the two hardcoded keys (SSH_KEY_PATH, GCP_SA_KEY_FILE_PATH). See §5 for migration.
2. What changes — at the env-file layer
2.1 File-reference convention
Any line in <vault>/.env.<env> whose key ends in _PATH or _FILE is treated as a path. Vsync reads the file and pushes its bytes as the secret value, under the key with the suffix stripped:
| In env file | Pushed as |
|---|---|
FOO_PATH=keys/foo | FOO (suffix _PATH stripped) |
FOO_FILE=keys/foo | FOO (suffix _FILE stripped) |
SSH_PRIVATE_KEY_PATH=keys/dev_ssh | SSH_PRIVATE_KEY |
GCP_SA_KEY_PATH=keys/sa.json | GCP_SA_KEY |
The convention is: name the env-file key after the secret you want, with _PATH or _FILE appended. That's the entire rule — no rename table, no special cases.
Edge case: a bare _PATH=foo or _FILE=foo (empty stem after strip) is treated as a plain KV; we don't push an empty-named secret.
2.2 Path resolution — anchored to VAULT_ROOT
VAULT_ROOT is the directory containing the .env.<env> file that vsync is currently parsing. The user never sets it; it's intrinsic to the parser.
| In env file | Resolves to |
|---|---|
keys/dev_ssh | <VAULT_ROOT>/keys/dev_ssh |
./keys/dev_ssh | <VAULT_ROOT>/keys/dev_ssh |
${VAULT_ROOT}/keys/dev_ssh | same (explicit form) |
~/.ssh/id_rsa | $HOME/.ssh/id_rsa |
${HOME}/.ssh/id_rsa | same (explicit form) |
/abs/path/to/key | absolute, pass-through |
There is no ${REPO_ROOT}. The vault is the unit vsync owns; anything outside it isn't guaranteed to exist after a fresh pull, so referencing across the vault boundary is intentionally awkward (use an absolute path; it'll stand out in review).
2.3 Placeholder expansion — uniform across all values
${VAULT_ROOT}, ${HOME}, and leading ~/ are expanded in every value — not only in file-ref values. This is deliberately uniform so users don't need to remember which keys are "magic". A literal $ in a plain value isn't a problem; only the exact two tokens ${VAULT_ROOT} and ${HOME} are substituted.
Examples:
DATA_DIR=${VAULT_ROOT}/cache # plain KV pushed with the expanded absolute path
HOME_HINT=${HOME}/.cache/myapp # same$VAR-shell-style (no braces) is not recognized — keeps the syntax narrow and predictable.
2.4 All-or-none on file errors
If any referenced file is missing or unreadable, parseEnvFile collects all such errors across the whole file and throws a single aggregated error. No secrets are pushed. This protects against partial syncs where the gh/gcp side ends up in a half-rotated state because one file path was wrong.
Aggregated error shape:
parseEnvFile: aborting sync — 2 file reference(s) could not be resolved:
- SSH_PRIVATE_KEY_PATH: file not found at /…/vault/dev/keys/missing
- DEPLOY_KEY_FILE: file not found at /…/vault/dev/deploy.key3. What does NOT change
- Bundle wire format (
RQE1,RQEM0001), share-file format (SLS1) — untouched. v0.5.x clients can stillpullv0.6-produced bundles and vice versa. Path resolution happens at sync time, not bundle time, so the encrypted vault is the same bytes regardless of which client wrote it. - Audit log — unchanged.
syncwrites one row per push attempt, same columns, same CSV. - CLI verbs — unchanged. No new subcommands.
- Config schema — unchanged.
sync.gh.repo/sync.gcp.projectstill live in the per-repo config file. - Routing keys (
GITHUB_REPO,GCP_PROJECT_ID) and local-only skips (GITHUB_TOKEN,GOOGLE_APPLICATION_CREDENTIALS) — unchanged.
4. Implementation notes
Single module change: src/envfile.ts. bin/sync.ts is untouched at the interface level — parseEnvFile(path) still takes a single argument, and VAULT_ROOT is derived internally from dirname(path). No new flags, no new config fields.
Tests live in test/envfile.test.ts, covering each row of the resolution table in §2.2, the generic _PATH / _FILE rule, the all-or-none aggregation, and the placeholder expansion on plain values.
The header comment of src/envfile.ts is the canonical short-form reference; the README's "How sync works" section gets a pointer to it.
5. Migration — 0.5.x → 0.6.0
If your .env.<env> files use either of the two hardcoded 0.5.x keys, rename them before upgrading vsync:
| 0.5.x | 0.6.0 |
|---|---|
SSH_KEY_PATH=<path> (pushed as SSH_PRIVATE_KEY) | SSH_PRIVATE_KEY_PATH=<path> (pushed as SSH_PRIVATE_KEY) |
GCP_SA_KEY_FILE_PATH=<path> (pushed as GCP_SA_KEY, JSON-validated) | GCP_SA_KEY_PATH=<path> or GCP_SA_KEY_FILE=<path> (pushed as GCP_SA_KEY, no validation) |
The pushed secret names stay the same, so nothing downstream (GitHub Actions, GCP Secret Manager) needs to be touched. Only the env-file keys change.
Other behavior shifts to be aware of:
- GCP JSON pre-validation is gone. If your service-account file is malformed JSON, vsync now pushes it anyway and
gcloud secrets versions addwill accept it. If you want the validation back, add it in CI before callingvsync sync. - A missing file aborts the sync (in 0.5.x it warned and skipped for
SSH_KEY_PATH). If you relied on the skip behavior to leave a secret unset, remove the*_PATH/*_FILEline when the file isn't expected to exist. - Path resolution is vault-relative, not CWD-relative. Existing env-files using bare relative paths (e.g.
keys/foo) start working correctly from any CWD — for most users this is a silent fix, not a break.
For env files that didn't use the two hardcoded keys, no migration is needed.
6. Non-goals
${REPO_ROOT}placeholder — explicitly out of scope. The vault is the universe; cross-vault file refs use absolute paths.$VARshell-style expansion (no braces) — not added. Keeps the syntax surface minimal and avoids the ambiguity of$FOO_BAR(is the varFOOorFOO_BAR?).- Per-key opt-out from suffix rule — not added. If you have a non-file value whose name happens to end in
_PATH, rename the key. The convention is the contract. - Recursive placeholder expansion — not done.
${VAULT_ROOT}is expanded once; if the resulting string contains another${…}, it stays literal. Keeps behavior obvious. - Quoting / escaping — unchanged from 0.5.x (single pair of surrounding
"or'stripped; no shell-style escape sequences). - Type-aware validation (JSON, PEM, etc.) — not in vsync's scope. Bytes in, bytes out.