vsync v0.13.0 — Spec
Status: design · target package @muthuishere/vsync · breaking — replaces ~/.config/vsync/defaults mechanism.
One theme: a profile is a named bag of S3 credentials that init binds to a (repo, env). The single-default file is gone. vsync init requires --profile=<name> (or, on a TTY, an interactive pick from the named list). A new vsync status command answers the question every operator asks five seconds after install — "what's wired up on this machine, and is any of it broken?" — without touching the network.
For prior context, see v0.2-secret-lib.md (config file layout + keychain split), v0.4-audit-log.md (gen counter source-of-truth that status --check-remote reads), v0.10-runtime-token-cli.md (will grow a --profile=<name> overload once profiles exist), v0.11-conformance-test-vectors.md (shared test vector format), and v0.12-vsync-s3-client.md (runtime reader — unaffected).
Decisions locked from the design huddle (referenced where applied):
- L — Profile system replaces single defaults; verbs
list/show/add/removeonly. Nodefault, norename. - M —
vsync initrequires--profile=<name>. TTY without flag → interactive pick; non-TTY without flag → fail loud. - N — No backwards compat. Old
~/.config/vsync/defaultsfile renamed todefaults.bakon first run. - O1 — Profile carries optional
prefix. Init combines as<profile.prefix><env>/. - O2 — Edit on existing-config = full re-prompt with current values as defaults; no field-by-field menu.
- O3 — Profiles ship FIRST, before v0.10 runtime-token gets the
--profile=overload and before v0.12 Python lib lands. - P —
vsync statuscommand, offline-first,--check-remoteopt-in. - I — pre-1.0; no compat shims; fail-loud on unknowns.
1. Profile system
1.1 Storage
One JSON file per profile under ~/.config/vsync/profiles/<name>.json, mode 0600. XDG_CONFIG_HOME respected (same convention as src/configfile.ts and src/repoconfig.ts). The directory itself is 0700.
Profile name grammar: [A-Za-z0-9._-]+, max 64 chars (same sanitiser already used for repo names in src/repo.ts). Reserved names — none. Names are case-sensitive on disk; the CLI does not collapse case.
1.2 Schema
{
"version": 1,
"endpoint": "https://hel1.your-objectstorage.com",
"region": "auto",
"bucket": "personal-secrets",
"prefix": "video-ai/",
"accessKeyId": "AKIA...",
"secretAccessKey": "..."
}| Field | Type | Required | Notes |
|---|---|---|---|
version | integer | yes | Envelope version. v0.13 emits 1. Readers MUST reject any other value (decision I). |
endpoint | string | yes | Full URL incl. scheme. No trailing slash. |
region | string | yes | Pass-through; vsync does not validate against an allow-list (auto is legal). |
bucket | string | yes | Bucket name only. |
accessKeyId | string | yes | IAM access key. |
secretAccessKey | string | yes | IAM secret. |
prefix | string | no | Trailing slash required when present. Concatenated by init as <profile.prefix><env>/. Absent → init prompts for the env's full prefix. |
useSsl is not part of the profile — it's derivable from endpoint (scheme https://) and was the source of confusion in 0.9.x. Init still records useSsl on the per-(repo, env) config as today; it's just not a profile field.
1.3 Verbs (four — no default, no rename)
vsync profile list
vsync profile show <name> [--reveal-secret]
vsync profile add <name>
vsync profile remove <name>All four exit non-zero on missing/unknown name with a message that names the profiles dir and the existing entries.
2. Profile CLI — output rules
2.1 vsync profile list
Tabular columns: name, endpoint, bucket. Secrets never displayed in list view (no access-key column, no secret column — keeps vsync profile list safe to paste in tickets).
$ vsync profile list
name endpoint bucket
hetzner-personal hel1.your-objectstorage.com personal-secrets
aws-video-ai-prod s3.eu-central-1.amazonaws.com video-ai-prod
r2-client-a <accountid>.r2.cloudflarestorage.com client-a-vault
3 profiles at ~/.config/vsync/profiles/Empty state: no profiles yet — run \vsync profile add <name>` to create one.`
2.2 vsync profile show <NAME> — redacted by default
Full content, secret key masked (secretAccessKey: ****), access key last-4 visible (AKIA********GXQ4). Per Senthil's redaction rule from the huddle: the access-key suffix is the part operators need to cross-reference against AWS IAM consoles; the secret key has zero legitimate eyeballs use case.
$ vsync profile show hetzner-personal
profile: hetzner-personal
path: ~/.config/vsync/profiles/hetzner-personal.json (0600)
endpoint: https://hel1.your-objectstorage.com
region: auto
bucket: personal-secrets
prefix: video-ai/
accessKeyId: AKIA********GXQ4
secretAccessKey: ****2.3 vsync profile show <NAME> --reveal-secret
Prints the plaintext secret key, but with a LOUD warning banner and an interactive confirmation:
⚠ --reveal-secret will print the secret access key in cleartext on this
terminal. Anyone who can see this output (screen-share, scrollback,
tmux logging, terminal multiplexer history) can exfiltrate the key.
Are you sure? This will print your secret key. [y/N]Non-TTY: refuse outright with exit 1 — no --yes flag, no --force. The point is that cleartext disclosure only ever happens in front of a human who explicitly typed y. CI / scripts that want the plaintext should read the file directly (it's already 0600); vsync is not the abstraction layer for that path.
2.4 vsync profile add <NAME>
Interactive prompts for all required fields (and optional prefix). Refuses if <name> already exists — recovery is vsync profile remove <name> then re-add. (Rename is out of scope, L.)
$ vsync profile add hetzner-personal
S3 endpoint URL: https://hel1.your-objectstorage.com
S3 region [auto]: auto
S3 bucket name: personal-secrets
S3 access key ID: AKIAIOSFODNN7EXAMPLE
S3 secret access key: ********************************
Optional prefix (e.g. video-ai/, leave empty to skip): video-ai/
✓ Wrote ~/.config/vsync/profiles/hetzner-personal.json (0600)
Next step:
vsync init <env> --profile=hetzner-personalNon-TTY: fail with exit 1 — no flag-driven creation in v0.13 (decision was to keep the surface minimal; flag-driven add can land in v0.14 if real usage demands it). Operators bootstrapping in CI generate the JSON file directly.
2.5 vsync profile remove <NAME>
Confirms before delete:
$ vsync profile remove hetzner-personal
remove profile hetzner-personal? [y/N] y
✓ Removed ~/.config/vsync/profiles/hetzner-personal.json
⚠ 3 env configs reference this profile (run `vsync status` to see them).
Those configs are NOT affected — they keep the creds they were minted with.
But future `vsync init` / `vsync runtime-token --profile=hetzner-personal`
will fail.Non-TTY: refuse without --yes. Removal is one-way; warning above tells the operator what other state is now orphan-adjacent (without taking destructive action on it).
3. vsync init rewrite
3.1 Signature
vsync init <env> --profile=<name>
[--repo=<name>]
[--vault-folder=<path>] [--migrate-from=<path>] [--no-migrate]
[--audit=on|off]
[--interactive]The S3 flags from 0.9.x (--bucket, --endpoint, --region, --access-key, --secret-key, --use-ssl) are removed. All creds come from the profile. This is the v0.13 breaking change in spirit even more than in bytes — there is no escape hatch to "init this env with one-off creds, don't make a profile". If you need to init, you make a profile first.
3.2 Resolution rules
| Case | Behaviour |
|---|---|
--profile=<name> given, name exists | Use it. Pass-through to step 3.4. |
--profile=<name> given, name missing | Exit 1: profile <name> not found. Existing: a, b, c. Run \vsync profile add <name>` to create.` |
No --profile, TTY | Present numbered picker (§3.3). |
No --profile, non-TTY | Exit 1: missing --profile=<name>. Run \vsync profile list` to see available profiles.` |
No --profile, TTY, no profiles exist | Exit 1 with the empty-state message: no profiles configured. Run \vsync profile add <name>` first, then `vsync init <env> --profile=<name>`.Do **not** auto-launchprofile add` (decision: keep verb boundaries crisp). |
3.3 Interactive picker
$ vsync init dev
Pick a profile:
1) hetzner-personal hel1.your-objectstorage.com / personal-secrets
2) aws-video-ai-prod s3.eu-central-1.amazonaws.com / video-ai-prod
3) r2-client-a <accountid>.r2.cloudflarestorage.com / client-a-vault
q) quit
> 2Input parsing: integer 1..N selects; q / Q / empty → exit 0 (no changes); anything else → reprompt up to 3 times, then exit 1. The picker is rendered once per init; it does not chain into profile add.
3.4 Prefix combination (decision O1)
The profile's prefix (if any) is the bucket-wide namespace for an account. The env adds its own segment. Final prefix written to the per-(repo, env) config is:
prefix = (profile.prefix ?? "") + env + "/"| Profile prefix | Env | Resulting prefix on config |
|---|---|---|
video-ai/ | dev | video-ai/dev/ |
video-ai/ | prod | video-ai/prod/ |
| (absent) | dev | init prompts the operator; persists exactly what they typed |
If the profile has no prefix, init prompts:
Profile "aws-staging" has no prefix.
Full S3 prefix for this env [<repo>/dev/]:Default suggestion is <repo>/<env>/ (matches v0.10 runtime-token default and v0.2's original convention). Trailing slash is added if the operator forgets it.
3.5 Existing-config detection
If <XDG>/vsync/config/<repo>/env_<env> already exists, the 0.9.x flow exits with "config already exists, re-run with --repo=<other>". v0.13 replaces that with a four-way prompt — but only after the config can actually be read.
Step 1: try to decrypt. loadEnvConfig(repo, env) reads the gzipped JSON and looks up the AES key via Bun.secrets. On KeyMissingError, fall back to the corrupt-config branch:
Config exists at <path> but no keychain key for <repo>/<env>.
Cannot show current values. [o]verwrite (re-init) or [a]bort? [a]Bare Enter → abort. Only o / overwrite advances to overwrite. keep and edit are not offered here because we cannot show current values to edit.
Step 2: decrypt OK → show summary + four-way prompt.
Config exists for video-ai / dev:
profile (at init): hetzner-personal [still present]
endpoint: hel1.your-objectstorage.com
bucket: personal-secrets
prefix: video-ai/dev/
last push: 2026-04-28 14:12 UTC (gen=3)
vault folder: infra/vault/dev
What now?
[k] keep — exit, no changes (default)
[o] overwrite — re-init this env from scratch
[e] edit — re-prompt with current values as defaults
[a] abort — exit, no changes (alias of keep)| Input | Action |
|---|---|
Enter / k / keep | Exit 0, no changes. |
o / overwrite | Re-init flow: prompt as if for a new env, default profile to the recorded one. Must type o or overwrite — no bare-Enter shortcut into destructive territory. |
e / edit | Re-prompt every field with the current value as the default (matches the askText default-fallback behaviour fixed in src/prompt.ts). Operator hits Enter through fields they don't want to change. (Decision O2 — no field-by-field menu.) |
a / abort | Exit 0, no changes. Alias of keep; offered separately because operators reach for it reflexively. |
"profile at init" is the profile name that was selected when this env was last init'd. Stored on the per-(repo, env) config as cfg.initProfile (new field, optional — absent on configs minted by 0.9.x or earlier). The [still present] / [REMOVED] annotation comes from a cheap directory stat against ~/.config/vsync/profiles/<name>.json.
Non-TTY behaviour for existing-config: exit 1 with config exists for <repo>/<env>; pass --interactive on a TTY to choose keep/overwrite/edit, or remove ~/.config/vsync/config/<repo>/env_<env> manually. No flag for unattended overwrite — keeps the destructive path human-gated.
3.6 What init writes
Same as 0.9.x plus one new field:
type ConfigFile = {
version: 1;
s3: { endpoint; region; bucket; accessKeyId; secretAccessKey; useSsl; };
encryption: { salt: string };
files?: { vaultFolder: string };
audit: { enabled: boolean };
initProfile?: string; // NEW — name of the profile init was bound to
prefix: string; // NEW — the resolved <profile.prefix><env>/ (or operator-typed)
};prefix was previously an implicit <repo>/<env>/; the spec now persists it. initProfile is purely informational (status, edit, re-init defaults) — push/pull never re-resolve from the profile, so deleting a profile after init does not break existing envs.
4. vsync status command
Run inside any repo. Resolves repo name via existing getRepoName(). Offline-first — no network calls by default (decision P).
4.1 Sources
- Per-(repo, env) configs — every file matching
<XDG>/vsync/config/<repo>/env_*. Decrypt each if its keychain key is available; otherwise carry it as "config without key". - Profiles dir — every
*.jsonunder~/.config/vsync/profiles/for the global panel. - OS keychain — enumerate entries scoped to
<repo>/*if the platform supports listing (macOS Keychain Access viasecurityshell, Linux libsecret viasecret-tool search). If enumeration is unsupported (Windows Credential Manager, locked-down distros), print a one-line notice and skip orphan-detection for "key without config". The other orphan classes still work.
4.2 Orphan detection (offline)
| Orphan class | Trigger |
|---|---|
| key without config | Keychain entry exists for <repo>/<env> but no env_<env> file in <XDG>/vsync/config/<repo>/. |
| config without key | env_<env> file exists but Bun.secrets.get(<repo>/<env>) returns null. |
| dangling profile | Config's cfg.initProfile is set but no ~/.config/vsync/profiles/<name>.json exists. |
Each surfaces in the status column with a one-line reason. No auto-repair; status is read-only.
4.3 Default output
$ vsync status
Repo: video-ai (resolved from package.json)
env profile prefix gen last push status
prod aws-video-ai-prod video-ai/prod/ 3 2026-04-28 ok
dev hetzner-personal video-ai/dev/ 1 2026-05-12 ok
staging — — — — ✘ keychain key without config (orphan)
Profiles on this machine (4):
hetzner-personal hel1.your-objectstorage.com
aws-video-ai-prod s3.eu-central-1.amazonaws.com
r2-client-a <accountid>.r2.cloudflarestorage.com
aws-staging s3.eu-central-1.amazonaws.com
Run `vsync init <env> --profile=<name>` to configure a new env.gen and last push come from the per-(repo, env) config's local lastPush cache (written by push since v0.4) — not from S3. A -- means "never pushed from this machine". An env with cfg.initProfile set but no matching profile shows the profile column as <name> (REMOVED).
4.4 Flags
| Flag | Effect |
|---|---|
--check-remote | Opts into a single S3 HEAD per env. Compares local lastPush.gen against the remote manifest meta.gen. Adds drift to the status column: LOCAL IS BEHIND (local gen=3, remote gen=5) / env not pushed yet (no remote manifest) / remote unreachable: <reason>. Auth errors per env are surfaced inline, not fatal to the overall run. |
--json | Machine-readable output. Schema: { repo, envs: [{ env, profile, prefix, gen, lastPush, status: { ok: bool, code: string, message: string } }], profiles: [{ name, endpoint, bucket }], notices: [string] }. Stable enough that scripts can diff between runs; same code enum as documented in §4.2 (ok, orphan-no-config, orphan-no-key, dangling-profile, remote-drift, remote-unreachable). |
--quiet | Exit 0 only if every configured env is ok. Any orphan or (with --check-remote) any drift → exit 1. Output is suppressed unless something is wrong. CI-friendly. |
--json and --quiet are mutually exclusive; passing both → exit 1 with usage.
4.5 Exit codes
| Code | Meaning |
|---|---|
| 0 | Status read OK. With --quiet, additionally: every env is ok. |
| 1 | Usage error (unknown flag, both --json and --quiet, etc.). With --quiet: at least one orphan or drift detected. |
| 2 | --check-remote and at least one env had a fatal local error (e.g. cannot read config file). Remote auth errors are non-fatal per §4.4. |
5. Migration from old single-default
On first invocation of any subcommand after upgrade, the dispatcher (bin/vsync.ts) runs a one-shot migration check:
if `~/.config/vsync/defaults` exists
AND `~/.config/vsync/profiles/` does NOT exist
then rename defaults → defaults.bak (preserving 0600)
mkdir -p profiles (0700)
print the notice to stderrNotice text (decision N):
Note: the single-default mechanism was removed in v0.13. Your previous
defaults are at ~/.config/vsync/defaults.bak. Run `vsync profile add <name>`
to recreate them as a named profile, then `vsync init <env> --profile=<name>`.The notice prints once per upgrade (the rename is idempotent — second run finds defaults.bak already there and the defaults file gone, so the trigger doesn't fire). The notice prints regardless of which subcommand triggered it, including vsync --help. It goes to stderr so it doesn't poison vsync runtime-token | pbcopy.
No flag controls the migration; it's automatic and one-shot. No auto-creation of a default.json from the .bak contents — operators name their profiles deliberately (so a "default" labelled profile is not silently created with the previous deployment's keys).
Existing per-(repo, env) config files are untouched. Push/pull works against them unchanged because they already carry their own s3 block — they never depended on defaults at runtime.
6. Out of scope
- Default profile pointer /
vsync profile default <name>— explicitly cut (decision L). Pick the profile every time, in writing, at the call site. vsync profile rename— cut. Recovery is remove + add, same as the inability to rename keychain entries.- Auto-migration of old
defaultsto a named profile — cut. The.bakis for human inspection; the operator names the profile they recreate. - Live remote sync watch / poll mode for
status— out. Re-run the command. - Field-by-field edit menu on existing config — out (decision O2). Full re-prompt with defaults is enough; the prompt helper already supports the fallback shape.
- Profile inheritance / composition / templating — out. Profiles are leaves.
- Backwards-compat shims for the old
defaultsfile beyond the rename-to-.bak— out (decision I). - Flag-driven
vsync profile add— out for v0.13. Add via prompts or by writing the JSON file directly. vsync initflags for one-off S3 creds (bypass profile) — out. The whole point of profiles is that creds have names.vsync runtime-token --profile=<name>— referenced in v0.10 but lands in v0.14 (forward reference). v0.13 only delivers the profile system that v0.10's overload will consume.
7. Implementation notes
New files:
bin/profile.ts(new) — dispatcher forlist/show/add/remove. ~250 lines. Reads fromsrc/profiles.ts; usessrc/prompt.tsfor interactive add and the--reveal-secretconfirmation.bin/status.ts(new) —vsync statusentry point. ~200 lines. Reads fromsrc/status.ts; renders the table and JSON.src/profiles.ts(new) — typed CRUD:loadProfile(name),saveProfile(name, p),listProfiles(),removeProfile(name),profilePath(name). Mirrors the layout pattern ofsrc/repoconfig.ts. Enforces0600/0700on writes.src/status.ts(new) — pure aggregation:gatherStatus({ repo, checkRemote })returns the{ envs, profiles, notices }shape that both the table renderer and the JSON output consume. Network calls live here behind thecheckRemoteflag; the renderers do no I/O.
Modified files:
bin/init.ts— replace theloadDefaults()-driven prefill withloadProfile(flags.profile). Remove the S3 flag set (--bucket,--endpoint,--region,--access-key,--secret-key,--use-ssl). Add the existing-config four-way prompt (§3.5). PersistinitProfileand resolvedprefixon the new config.bin/vsync.ts— add"profile"and"status"toSUBCOMMANDS, two switch arms, two usage lines. Run the one-shot migration check (§5) on every invocation before dispatch.src/defaults.ts— keep the read-only loader for the duration of v0.13 so the migration check can detect the legacy file. Drop thesaveDefaultsexport; renameloadDefaultstoloadLegacyDefaultsto make call sites obvious. Removable in v0.14.src/repoconfig.ts— extendConfigFilewithinitProfile?: stringandprefix: string(the latter previously implicit).validateConfigFileaccepts both forversion: 1; configs minted before v0.13 are read as-is, withprefixsynthesised as<repo>/<env>/on the fly when absent (read-side default only — writers always persist it).src/prompt.ts— already has the default-fallback fix that §3.5 "edit" depends on; no change.README.md— cheat-sheet adds the four profile verbs andvsync status. The "first run" section rewritten aroundprofile add→init --profile=….package.json—filesalready includesbin/andsrc/; no entry change. Version bump to0.10.0(next minor after 0.9.x, matching how 0.10.x was reserved for the broader rotation/profile cluster).
Test plan:
test/bin/profile.test.ts— list (empty + populated), show (masked +--reveal-secretTTY-only + non-TTY refusal), add (happy path, name-exists rejection, name-grammar rejection), remove (confirm yes/no, dangling-profile warning text).test/bin/init.test.ts— re-init for existing 0.9.x suite; add cases for missing-profile error message, TTY picker, prefix combination matrix from §3.4, existing-config four-way prompt for each branch (keep / overwrite / edit / abort + corrupt-config branch).test/bin/status.test.ts— fixture-driven: build a~/.config/vsynctree in amkdtempSyncdir (per the convention intest/configfile.test.ts), assert table and--jsonsnapshots, force each orphan class, verify--quietexit codes.test/src/profiles.test.ts— file mode (0600), dir mode (0700), schema validation, unknown-version rejection (decision I).test/src/status.test.ts— pure aggregation; no spawn / no S3 in default mode.--check-remotecovered with an in-process S3 stub same as v0.10's runtime-token validation tests.
Test count delta: ~+45 (15 profile, 12 init, 12 status, 6 src). Existing suite stays green.
Verb count: 12 → 14 (profile, status added; the legacy defaults verb never existed as a subcommand — it was always implicit, so no removal needed).