vsync v0.7.0 — Spec
Status: design · target package @muthuishere/vsync · wire-format compatible with 0.6.x — two intentional behavior breaks: see §5. (No defaults, and in-env routing keys removed.)
One theme: the parser has zero policy. No hardcoded suffixes, no hardcoded exclude list, no implicit defaults anywhere — not in the parser, not in src/defaults.ts, not in the CLI. If the operator doesn't pass --inline-file-suffix=_PATH, then a key called FOO_PATH is a plain KV with value keys/foo. If the operator doesn't pass --exclude-property=GITHUB_TOKEN, then GITHUB_TOKEN gets pushed. Nothing happens that the call site didn't ask for, in writing.
Motivation: today src/envfile.ts quietly skips GITHUB_TOKEN and quietly inlines anything ending in _PATH. Both are reasonable defaults — until someone needs different ones, or someone wonders why their _PATH env var arrived at GitHub Actions with a different shape than they wrote. v0.7 makes the operator name every rule at the call site.
For prior context, see v0.6-vault-relative-file-refs.md, and bin/sync.ts + src/envfile.ts as they ship in 0.6.0.
1. Diff from 0.6.x
| 0.6.x | 0.7.0 | |
|---|---|---|
parseEnvFile signature | parseEnvFile(path) | parseEnvFile(path, opts: ParseOptions) — all rules required |
| File-ref suffixes | hardcoded ["_PATH", "_FILE"] inside envfile.ts | no default. Caller passes via opts.inlineFileSuffixes. Empty list = no file inlining at all. |
| Local-only / excluded keys | hardcoded {GITHUB_TOKEN, GOOGLE_APPLICATION_CREDENTIALS} inside envfile.ts | no default. Caller passes via opts.excludeProperties. Empty list = nothing skipped. |
Default constants in src/defaults.ts | n/a (no such constants existed) | none added (deliberate — see §2.2) |
In-env routing keys (GITHUB_REPO, GCP_PROJECT_ID) | parsed out into ParsedEnv.meta, never pushed | removed. Routing lives only in cfg.sync.gh.repo / cfg.sync.gcp.project. See §5. |
ParsedEnv shape | { tasks, meta } | { tasks, skipped } — meta gone, skipped added (§2.4) |
CLI flags on vsync sync | --gh-repo, --gcp-project, --repo | + --inline-file-suffix=<suf> (repeatable), --exclude-property=<key> (repeatable) |
| Active-policy visibility | not printed | sync prints the active policy header on every run (§4) |
Wire format (RQE1, RQEM0001, SLS1) / audit log / config schema | unchanged | unchanged |
Bundle / share-file / audit-log / config-schema compatibility: 0.6.x and 0.7.0 clients can read each other's bytes. The break lives at the parse layer (parseEnvFile signature + dropped routing keys) and at the CLI invocation surface (no defaults = bare vsync sync won't skip GITHUB_TOKEN anymore).
2. What changes — at the library layer
2.1 parseEnvFile signature
export type ParseOptions = {
/** Suffixes that turn a key into a file reference. Empty = no inlining. */
inlineFileSuffixes: string[];
/** Keys to skip entirely (never pushed). Empty = nothing skipped. */
excludeProperties: string[];
/** Placeholder expansion in values. Default true. */
expandPlaceholders?: boolean;
};
export type ParsedEnv = {
tasks: SecretTask[];
skipped: Array<{ key: string; reason: "excluded" }>;
};
export function parseEnvFile(path: string, opts: ParseOptions): ParsedEnv;Both inlineFileSuffixes and excludeProperties are required fields — not optional with a default. Passing [] is a deliberate "nothing here" statement and is allowed; omitting the field is a TypeScript error.
The parser has no module-level constants describing policy. The complete behavior is a function of (path, opts). Two calls with the same inputs return the same output, and the file the parser reads is the only side-channel.
2.2 No src/defaults.ts additions — intentional
The first draft of this spec proposed DEFAULT_FILE_SUFFIXES and DEFAULT_EXCLUDED_KEYS constants that the CLI would auto-apply. Cut. A "default" the CLI silently applies is the same magic as a default the parser silently applies — it just lives one module up. The whole point is to make the operator type the policy. We document common invocations in the README (§4.2) and in vsync sync --help; that's where the suggestion lives, not in code that runs automatically.
If this creates ergonomic pain in real use (every Taskfile carries the same 4-line invocation), revisit by letting cfg.sync.parse persist the lists per (repo, env). Don't shortcut that decision now with a "harmless default."
2.3 CLI flag surface — vsync sync
vsync sync <env> <gh|gcp|all>
[--inline-file-suffix=<suffix>] # repeatable; e.g. --inline-file-suffix=_PATH
[--exclude-property=<key>] # repeatable; e.g. --exclude-property=GITHUB_TOKEN
[--gh-repo=<owner/name>]
[--gcp-project=<id>]
[--repo=<name>]Repeatable, not comma-separated. One value per flag occurrence. Each occurrence appends to the list.
vsync sync dev gh \
--inline-file-suffix=_PATH \
--inline-file-suffix=_FILE \
--exclude-property=GITHUB_TOKEN \
--exclude-property=GOOGLE_APPLICATION_CREDENTIALSWhy repeated over comma-separated:
- A key or suffix is a single semantic unit. Comma-joining them encodes a list inside a string and forces the parser to split — which means special handling for commas-in-values, escaping, etc. Repeated flags push that complexity out to the shell, which already knows how to quote things.
- It composes cleanly with Taskfiles, makefiles, and CI YAML — each value is one line; diffs are minimal when adding or removing an item.
- It reads naturally: every flag in the command is one rule. Five suffixes = five lines. Same on stdout (
--help) and in the policy header (§4).
src/argv.ts::parseArgs already records repeated flags as lists[key]: string[] — no parser change needed.
No --no-… toggles. Absence of the flag is the off state. If you want nothing excluded, pass nothing; the policy header (§4) will print excluded: (none). Adding --no-exclude-property would imply that a default exists to negate, contradicting §2.2.
2.4 skipped replaces meta
ParsedEnv.meta is gone (routing keys no longer recognized; see §5). In its place, ParsedEnv.skipped lists every key the parser dropped because it appeared in excludeProperties. The CLI uses this in the run output so operators can see exactly what got excluded on this invocation.
The reason field is currently a single literal "excluded". It's a discriminated union so future versions can extend it (e.g. "empty-stem" for _PATH=foo with no key prefix) without breaking consumers.
3. What does NOT change
- Bundle wire format (
RQE1,RQEM0001), share-file format (SLS1) — untouched. 0.6.x ↔ 0.7.0 bundles are mutually readable. - Audit log — unchanged. One row per push attempt, same columns.
- CLI verbs — no new subcommands;
vsync syncgains two repeatable flags. - Config schema —
ConfigFileshape unchanged.cfg.sync.gh.repo/cfg.sync.gcp.projectremain the routing source of truth. - Path resolution rules — vault-root-relative, placeholder expansion (
${VAULT_ROOT},${HOME}, leading~/), all-or-none file errors — unchanged from v0.6. - Suffix-strip semantics for inlined files — same as v0.6:
FOO_PATH=keys/foo(with--inline-file-suffix=_PATHin effect) pushes secretFOOwith the file's bytes.
4. Visibility — sync prints its policy
4.1 The policy header
Every vsync sync run prints the active parser policy before the first push:
$ vsync sync dev gh \
--inline-file-suffix=_PATH \
--inline-file-suffix=_FILE \
--exclude-property=GITHUB_TOKEN \
--exclude-property=GOOGLE_APPLICATION_CREDENTIALS
Parser policy:
inline-file-suffix: _PATH
inline-file-suffix: _FILE
exclude-property: GITHUB_TOKEN
exclude-property: GOOGLE_APPLICATION_CREDENTIALS
Syncing 11 secrets to GitHub: repo=muthuishere/vsync, environment=dev
skipped (excluded): GITHUB_TOKEN
Setting secret: SSH_PRIVATE_KEY
✓ SSH_PRIVATE_KEY
…When a list is empty:
Parser policy:
inline-file-suffix: (none — file refs disabled)
exclude-property: (none — nothing skipped)Rationale: a pure parser only buys you predictability if you can see the inputs it was called with. Two lines per run, zero ambiguity about why a key was or wasn't pushed.
4.2 README — the "typical invocation" recipe
Since there are no defaults, the README gets a copy-paste recipe documenting the most common case (the old v0.6 behavior, made explicit):
### Typical `vsync sync` invocation
The parser has no defaults. Pass the rules you want, every time. The shape
that matches v0.6 behavior is:
```bash
vsync sync dev gh \
--inline-file-suffix=_PATH \
--inline-file-suffix=_FILE \
--exclude-property=GITHUB_TOKEN \
--exclude-property=GOOGLE_APPLICATION_CREDENTIALS
```
Drop this into your Taskfile / Makefile / CI so the call site shows the
whole policy at a glance.This is documentation, not code. It's identical to what v0.6 did silently, but now you can see it and edit it.
5. Migration — 0.6.x → 0.7.0
There are two breaks. Read both before upgrading.
5.1 No defaults — bare vsync sync changes behavior
If your Taskfile / CI / shell history runs:
vsync sync dev gh…then in v0.6 this implicitly skipped GITHUB_TOKEN / GOOGLE_APPLICATION_CREDENTIALS and inlined *_PATH / *_FILE. In v0.7, none of that happens. GITHUB_TOKEN gets pushed as a literal secret; FOO_PATH=keys/foo gets pushed with the value keys/foo (the path string, not the file contents).
To preserve v0.6 behavior verbatim, append the four flags from §4.2 to every invocation. Update Taskfiles in one pass — the patch is mechanical.
5.2 In-env routing keys removed
If your .env.<env> files contain GITHUB_REPO=… or GCP_PROJECT_ID=…, those lines are no longer special. They become plain KVs and (unless you --exclude-property them) will be pushed to whichever backend you sync to.
| 0.6.x | 0.7.0 |
|---|---|
.env.<env> line: GITHUB_REPO=muthuishere/vsync | move to cfg.sync.gh.repo (set via vsync sync dev gh --gh-repo=muthuishere/vsync once; persisted) |
.env.<env> line: GCP_PROJECT_ID=my-project | move to cfg.sync.gcp.project (set via vsync sync dev gcp --gcp-project=my-project once; persisted) |
accessing routing from the library: parseEnvFile(p).meta.GITHUB_REPO | use cfg.sync.gh.repo from loadConfigFile(repo, env) |
Recommended: delete the lines from .env.<env> once routing is in config. They're dead weight there now.
5.3 Library consumers of parseEnvFile
// 0.6.x
import { parseEnvFile } from "./src/envfile";
const { tasks, meta } = parseEnvFile(path);
// 0.7.0
import { parseEnvFile } from "./src/envfile";
const { tasks, skipped } = parseEnvFile(path, {
inlineFileSuffixes: ["_PATH", "_FILE"],
excludeProperties: ["GITHUB_TOKEN", "GOOGLE_APPLICATION_CREDENTIALS"],
});There's no helper to "give me v0.6 defaults" — by design. If a project wants those values shared between multiple call sites, the project (not vsync) declares the constants.
6. Implementation notes
Files touched:
src/envfile.ts— drop module-levelLOCAL_ONLY,ROUTING,PATH_SUFFIXESconstants.parseEnvFiletakes requiredParseOptions. Routing-meta extraction removed.skippedcollected and returned.src/defaults.ts— no changes. Do not addDEFAULT_FILE_SUFFIXES/DEFAULT_EXCLUDED_KEYS. (§2.2.)bin/sync.ts— readlists["inline-file-suffix"]andlists["exclude-property"]fromparseArgs; both default to[](= empty list = no rules). Pass toparseEnvFile. Print policy header. Surfaceskippedin CLI output.src/argv.ts— no changes; existinglistssupport handles repeated flags already.test/envfile.test.ts— every existing test updated to passinlineFileSuffixes/excludePropertiesexplicitly. New tests:- empty
inlineFileSuffixes→FOO_PATH=keys/foobecomes plain KV{ FOO_PATH: "keys/foo" } - empty
excludeProperties→GITHUB_TOKENis pushed - custom suffix
_KEYworks - custom exclude key
- empty
bin/sync.test.ts(or equivalent) — flag parsing (repeated flags accumulate), policy header output, behavior when both lists are empty.README.md— add the "Typical invocation" recipe from §4.2.
Backward-compat shim — not added. No parseEnvFile(path) overload that auto-applies anything. Library consumers update their call sites. This is the whole point.
Test count delta: ~+10–12 (5 new cases for explicit options + edge cases, 3 for flag parsing, 2 for policy-header output). Existing 184 stay green after mechanical signature updates.
7. Non-goals
- Default constants the CLI auto-applies — explicitly out (§2.2). The README is the suggestion surface, not code.
- Comma-separated value lists in flags — explicitly out (§2.3). Repeated flags compose better with shell quoting and YAML.
--no-…negation flags — explicitly out (§2.3). Absence of the flag is the off state; nothing to negate.- Persisting
inlineFileSuffixes/excludePropertiesincfg— out for v0.7. Revisit only if real call sites become unwieldy. - Per-key opt-in/opt-out for the suffix rule — out. If a non-file value's name happens to end in
_PATH, rename the key or don't pass--inline-file-suffix=_PATH. - Wildcard / regex matching in
excludeProperties— out. Literal-string list only. - Auto-loading rules from
.vsyncrc— out. Same magic problem as defaults. - Per-target exclude lists (exclude X from gh but not gcp) — out. One list applies before any target is chosen. For target-specific filtering, run
vsync synctwice with different flags. - Audit-log entry for the parser policy — out. Policy is stdout-only for v0.7.