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.x | 0.8.0 | |
|---|---|---|
| Targets | gh, gcp | gh, gcp, aws, azure, vault |
| Dispatch | inline if/else in bin/sync.ts | HANDLERS registry in src/synctargets/ |
| Concurrency | per-task pool (6 workers) for both targets | per-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 policy | unchanged | unchanged |
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
// 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>;
};// 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:
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)
| Flag | Persists to | Required? |
|---|---|---|
--aws-region=<region> | cfg.sync.aws.region | yes |
--aws-secret-prefix=<prefix> | cfg.sync.aws.secretPrefix | optional |
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)
| Flag | Persists to | Required? |
|---|---|---|
--azure-vault=<vault-name> | cfg.sync.azure.vaultName | yes (vault name, NOT URL) |
Per-task push (shared runPool):
az keyvault secret set --vault-name <vault> --name <KEY> --file /dev/stdinaz 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)
| Flag | Persists to | Required? |
|---|---|---|
--vault-addr=<url> | cfg.sync.vault.addr | yes |
--vault-mount=<mount> | cfg.sync.vault.mount | yes (e.g. secret) |
--vault-path=<path> | cfg.sync.vault.secretPath | yes (e.g. myapp/dev) |
Single bulk write — runPool 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:
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 syncinvocation (the v0.7.1allremoval 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).HANDLERSregistry: 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
allor any multi-target shorthand.