Skip to content

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.x0.7.0
parseEnvFile signatureparseEnvFile(path)parseEnvFile(path, opts: ParseOptions) — all rules required
File-ref suffixeshardcoded ["_PATH", "_FILE"] inside envfile.tsno default. Caller passes via opts.inlineFileSuffixes. Empty list = no file inlining at all.
Local-only / excluded keyshardcoded {GITHUB_TOKEN, GOOGLE_APPLICATION_CREDENTIALS} inside envfile.tsno default. Caller passes via opts.excludeProperties. Empty list = nothing skipped.
Default constants in src/defaults.tsn/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 pushedremoved. 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 visibilitynot printedsync prints the active policy header on every run (§4)
Wire format (RQE1, RQEM0001, SLS1) / audit log / config schemaunchangedunchanged

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

ts
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.

bash
vsync sync dev gh \
  --inline-file-suffix=_PATH \
  --inline-file-suffix=_FILE \
  --exclude-property=GITHUB_TOKEN \
  --exclude-property=GOOGLE_APPLICATION_CREDENTIALS

Why 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 sync gains two repeatable flags.
  • Config schemaConfigFile shape unchanged. cfg.sync.gh.repo / cfg.sync.gcp.project remain 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=_PATH in effect) pushes secret FOO with 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):

markdown
### 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:

bash
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.x0.7.0
.env.<env> line: GITHUB_REPO=muthuishere/vsyncmove to cfg.sync.gh.repo (set via vsync sync dev gh --gh-repo=muthuishere/vsync once; persisted)
.env.<env> line: GCP_PROJECT_ID=my-projectmove 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_REPOuse 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

ts
// 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-level LOCAL_ONLY, ROUTING, PATH_SUFFIXES constants. parseEnvFile takes required ParseOptions. Routing-meta extraction removed. skipped collected and returned.
  • src/defaults.tsno changes. Do not add DEFAULT_FILE_SUFFIXES / DEFAULT_EXCLUDED_KEYS. (§2.2.)
  • bin/sync.ts — read lists["inline-file-suffix"] and lists["exclude-property"] from parseArgs; both default to [] (= empty list = no rules). Pass to parseEnvFile. Print policy header. Surface skipped in CLI output.
  • src/argv.ts — no changes; existing lists support handles repeated flags already.
  • test/envfile.test.ts — every existing test updated to pass inlineFileSuffixes / excludeProperties explicitly. New tests:
    • empty inlineFileSuffixesFOO_PATH=keys/foo becomes plain KV { FOO_PATH: "keys/foo" }
    • empty excludePropertiesGITHUB_TOKEN is pushed
    • custom suffix _KEY works
    • custom exclude key
  • 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 / excludeProperties in cfg — 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 sync twice with different flags.
  • Audit-log entry for the parser policy — out. Policy is stdout-only for v0.7.

Released under the MIT License.