Skip to content

vsync v0.8.0 — Spec

Status: design · target package @muthuishere/vsync · wire-format compatible with 0.7.x · purely additive — no break for existing users.

One theme: 2 backends → 5, with the dispatcher growing by one line per backend. Extract a TargetHandler interface; rewrite bin/sync.ts::main to look up handlers by name; add three new handlers: AWS Secrets Manager, Azure Key Vault, HashiCorp Vault KV v2. Parser policy (--inline-file-suffix, --exclude-property) from v0.7 is target-agnostic and reused unchanged.

For prior context, see v0.7-explicit-sync-parser.md. Current dispatcher in bin/sync.ts (v0.7.1) is the starting point.


1. Diff from 0.7.x

0.7.x0.8.0
Targetsgh, gcpgh, gcp, aws, azure, vault
Dispatchinline if/else in bin/sync.tsHANDLERS registry in src/synctargets/
Concurrencyper-task pool (6 workers) for both targetsper-handler decision: gh/gcp/aws/azure use the pool; vault writes once (KV v2 is atomic at the path)
ConfigFile.sync{ gh?, gcp? }+ { aws?, azure?, vault? }
New flags--aws-region, --aws-secret-prefix, --azure-vault, --vault-addr, --vault-mount, --vault-path
Wire / share / audit / parser policyunchangedunchanged

Every 0.7.x invocation works in 0.8.0 byte-for-byte. New targets are opt-in by name; bare vsync sync dev gh doesn't touch any new code.


2. TargetHandler interface + registry

ts
// src/synctargets/types.ts
export type ResolveResult<R> = { routing: R; mutated: boolean };

export type RunSyncOpts = {
  workers: number;
  timeoutMs: number;
  env: string;
  signal: AbortSignal;
};

export type TargetHandler<R = unknown> = {
  name: string;                                  // also the cfg.sync[name] key
  bin: string;                                   // required CLI on PATH
  banner(routing: R, env: string, n: number): string;
  resolveRouting(cfg: ConfigFile, flags: Record<string, string>): Promise<ResolveResult<R>>;
  runSync(tasks: SecretTask[], routing: R, opts: RunSyncOpts): Promise<SyncResult>;
};
ts
// src/synctargets/index.ts
export const HANDLERS = {
  gh: ghHandler,
  gcp: gcpHandler,
  aws: awsHandler,
  azure: azureHandler,
  vault: vaultHandler,
} as const;
export type TargetName = keyof typeof HANDLERS;

bin/sync.ts::main becomes:

ts
const handler = HANDLERS[target];
if (!handler) { usage(); process.exit(1); }
const { routing, mutated } = await handler.resolveRouting(cfg, flags);
if (mutated) await saveConfigFile(repo, env, cfg);
await ensureBinary(handler.bin);
console.log(handler.banner(routing, env, tasks.length));
printSkipped(skipped);
const result = await handler.runSync(tasks, routing, { workers: WORKERS, timeoutMs: TIMEOUT_MS, env, signal: ctrl.signal });

Why per-handler runSync instead of per-task setSecret: Vault is bulk. A per-task interface would force N round-trips to write what should be one atomic JSON map. Lifting the concurrency decision into the handler keeps the abstraction honest.


3. AWS Secrets Manager (aws)

FlagPersists toRequired?
--aws-region=<region>cfg.sync.aws.regionyes
--aws-secret-prefix=<prefix>cfg.sync.aws.secretPrefixoptional

Per-task push (shared runPool, 6 workers):

aws secretsmanager describe-secret --secret-id <prefix><KEY> --region <region>
  → exit 0   = exists → aws secretsmanager put-secret-value …
  → exit ≠ 0 = create → aws secretsmanager create-secret …

aws secretsmanager <create-secret|put-secret-value> \
  --name <prefix><KEY> --secret-string fileb:///dev/stdin --region <region>

Value via stdin (Bun.spawn({ stdin: TextEncoder.encode(t.value) })). AWS Secrets Manager accepts /_+=.@- + alphanumeric, so typical SCREAMING_SNAKE_CASE works as-is.

Auth: aws configure / aws sso login / env vars. Out of vsync's scope.


4. Azure Key Vault (azure)

FlagPersists toRequired?
--azure-vault=<vault-name>cfg.sync.azure.vaultNameyes (vault name, NOT URL)

Per-task push (shared runPool):

az keyvault secret set --vault-name <vault> --name <KEY> --file /dev/stdin

az keyvault secret set is idempotent (creates if missing, adds a version if present). No describe-first dance.

Naming constraint: Azure Key Vault allows only 0-9 A-Z a-z -. Underscores fail at push time with an az CLI error. Per the v0.7 no-magic theme, vsync does not silently translate _-. Operator options: rename keys in .env.<env>, --exclude-property the offending keys, or maintain an Azure-shaped env file. A future --key-translate=<from>:<to> parser flag could solve this — out of scope for v0.8.

Auth: az login. Out of scope.


5. HashiCorp Vault KV v2 (vault)

FlagPersists toRequired?
--vault-addr=<url>cfg.sync.vault.addryes
--vault-mount=<mount>cfg.sync.vault.mountyes (e.g. secret)
--vault-path=<path>cfg.sync.vault.secretPathyes (e.g. myapp/dev)

Single bulk writerunPool is bypassed:

VAULT_ADDR=<addr> vault kv put -mount=<mount> <secretPath> KEY1=value1 KEY2=value2 …

All KVs passed as positional args via Bun.spawn's cmd array, so shell escaping is moot. runSync returns { ok: tasks.length, failed: [] } on success or { ok: 0, failed: tasks.map(t => t.key) } on failure (atomic — either all landed or none).

Hard limit: ARG_MAX (~2 MiB on Linux). If ever hit, a future patch switches to @file.json mode. v0.8 lets E2BIG surface loudly.

Auth: vault login (token in ~/.vault-token). Out of scope.

KV v1, Transit/PKI engines, Vault namespaces — out of scope.


6. Config schema additions

src/repoconfig.ts::ConfigFile.sync — additive, version stays at 1:

ts
sync?: {
  gh?: { repo: string };                                       // unchanged
  gcp?: { project: string };                                   // unchanged
  aws?: { region: string; secretPrefix?: string };             // NEW
  azure?: { vaultName: string };                               // NEW
  vault?: { addr: string; mount: string; secretPath: string }; // NEW
};

validateConfigFile extended with the same pattern as existing gh/gcp branches: type-check each new block when present.

Forward/backward compat: 0.7.x clients ignore unknown extras; 0.8 clients reading a 0.7-era config see cfg.sync.aws|azure|vault === undefined (the "not configured" state).


7. What does NOT change

  • Bundle / share-file / audit log / parser policy / every other verb — untouched.
  • 0.7.x ↔ 0.8.0 bundles are mutually readable.
  • One target per vsync sync invocation (the v0.7.1 all removal stays).
  • One parser policy per invocation (uniform across all 5 targets).

8. Tests

Existing 201 stay green (phase 1 of the implementation is a mechanical extraction — gh/gcp move into handlers, dispatcher gets shorter, behavior identical).

New tests per handler:

  • resolveRouting: flag → cfg → no-tty error precedence (3 cases × 5 handlers = ~15 tests).
  • Command construction: extract a pure buildCmd(task, routing) helper per handler and test the array shape directly. No spawn mocking needed. (~6 tests).
  • vault.runSync: assert single spawn, all KVs in one args array (~2 tests).
  • HANDLERS registry: unknown target → usage + exit 1 (~1 test).

Target: ~225 tests total. No process-spawning in unit tests.


9. Non-goals

  • Vault KV v1 / Transit / PKI / namespaces / Enterprise features.
  • AWS SSM Parameter Store; Azure App Configuration. Different services; separate target names later if asked.
  • Direct API calls (always shell out to vendor CLI — keeps vsync small, auth is the operator's problem).
  • Credential management for any backend.
  • Key-name translation (deferred — would be a parser-level --key-translate=<from>:<to> flag).
  • --env-file=<path> override (deferred).
  • Reintroducing all or any multi-target shorthand.

Released under the MIT License.