Skip to content

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.x0.6.0
File-ref convention2 hardcoded keys (SSH_KEY_PATH, GCP_SA_KEY_FILE_PATH)one generic suffix rule: *_PATH / *_FILE → strip suffix, push file body
Pushed-name mappingSSH_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-validationYes (startsWith("{") check)No. vsync pushes whatever bytes are in the file. Validation is the operator's problem.
Path resolutionCWD-relative; ~/ expandedvault-root-relative, ~/, ${HOME}, ${VAULT_ROOT} placeholders, absolute pass-through
Missing referenced fileSSH_KEY_PATH warned & skipped; GCP threw on bad JSONAll-or-none: any missing/unreadable file aborts the sync before any push, with aggregated error
Placeholder expansionOnly ~/ (in the two hardcoded paths)${VAULT_ROOT}, ${HOME}, ~/ (leading) — expanded in every value
Wire format / config schema / CLI verbsunchangedunchanged

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 filePushed as
FOO_PATH=keys/fooFOO (suffix _PATH stripped)
FOO_FILE=keys/fooFOO (suffix _FILE stripped)
SSH_PRIVATE_KEY_PATH=keys/dev_sshSSH_PRIVATE_KEY
GCP_SA_KEY_PATH=keys/sa.jsonGCP_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 fileResolves to
keys/dev_ssh<VAULT_ROOT>/keys/dev_ssh
./keys/dev_ssh<VAULT_ROOT>/keys/dev_ssh
${VAULT_ROOT}/keys/dev_sshsame (explicit form)
~/.ssh/id_rsa$HOME/.ssh/id_rsa
${HOME}/.ssh/id_rsasame (explicit form)
/abs/path/to/keyabsolute, 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.key

3. What does NOT change

  • Bundle wire format (RQE1, RQEM0001), share-file format (SLS1) — untouched. v0.5.x clients can still pull v0.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. sync writes one row per push attempt, same columns, same CSV.
  • CLI verbs — unchanged. No new subcommands.
  • Config schema — unchanged. sync.gh.repo / sync.gcp.project still 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.x0.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 add will accept it. If you want the validation back, add it in CI before calling vsync 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/*_FILE line 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.
  • $VAR shell-style expansion (no braces) — not added. Keeps the syntax surface minimal and avoids the ambiguity of $FOO_BAR (is the var FOO or FOO_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.

Released under the MIT License.