Skip to content

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 / remove only. No default, no rename.
  • Mvsync init requires --profile=<name>. TTY without flag → interactive pick; non-TTY without flag → fail loud.
  • N — No backwards compat. Old ~/.config/vsync/defaults file renamed to defaults.bak on 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.
  • Pvsync status command, offline-first, --check-remote opt-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

json
{
  "version": 1,
  "endpoint": "https://hel1.your-objectstorage.com",
  "region": "auto",
  "bucket": "personal-secrets",
  "prefix": "video-ai/",
  "accessKeyId": "AKIA...",
  "secretAccessKey": "..."
}
FieldTypeRequiredNotes
versionintegeryesEnvelope version. v0.13 emits 1. Readers MUST reject any other value (decision I).
endpointstringyesFull URL incl. scheme. No trailing slash.
regionstringyesPass-through; vsync does not validate against an allow-list (auto is legal).
bucketstringyesBucket name only.
accessKeyIdstringyesIAM access key.
secretAccessKeystringyesIAM secret.
prefixstringnoTrailing 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)

bash
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).

text
$ 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.

text
$ 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:

text
⚠  --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.)

text
$ 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-personal

Non-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:

text
$ 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

bash
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

CaseBehaviour
--profile=<name> given, name existsUse it. Pass-through to step 3.4.
--profile=<name> given, name missingExit 1: profile <name> not found. Existing: a, b, c. Run \vsync profile add <name>` to create.`
No --profile, TTYPresent numbered picker (§3.3).
No --profile, non-TTYExit 1: missing --profile=<name>. Run \vsync profile list` to see available profiles.`
No --profile, TTY, no profiles existExit 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

text
$ 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

> 2

Input 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:

text
prefix = (profile.prefix ?? "") + env + "/"
Profile prefixEnvResulting prefix on config
video-ai/devvideo-ai/dev/
video-ai/prodvideo-ai/prod/
(absent)devinit prompts the operator; persists exactly what they typed

If the profile has no prefix, init prompts:

text
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:

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

text
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)
InputAction
Enter / k / keepExit 0, no changes.
o / overwriteRe-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 / editRe-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 / abortExit 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:

ts
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

  1. 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".
  2. Profiles dir — every *.json under ~/.config/vsync/profiles/ for the global panel.
  3. OS keychain — enumerate entries scoped to <repo>/* if the platform supports listing (macOS Keychain Access via security shell, Linux libsecret via secret-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 classTrigger
key without configKeychain entry exists for <repo>/<env> but no env_<env> file in <XDG>/vsync/config/<repo>/.
config without keyenv_<env> file exists but Bun.secrets.get(<repo>/<env>) returns null.
dangling profileConfig'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

text
$ 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

FlagEffect
--check-remoteOpts 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.
--jsonMachine-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).
--quietExit 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

CodeMeaning
0Status read OK. With --quiet, additionally: every env is ok.
1Usage 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:

text
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 stderr

Notice text (decision N):

text
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 defaults to a named profile — cut. The .bak is 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 defaults file 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 init flags 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 for list / show / add / remove. ~250 lines. Reads from src/profiles.ts; uses src/prompt.ts for interactive add and the --reveal-secret confirmation.
  • bin/status.ts (new)vsync status entry point. ~200 lines. Reads from src/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 of src/repoconfig.ts. Enforces 0600 / 0700 on 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 the checkRemote flag; the renderers do no I/O.

Modified files:

  • bin/init.ts — replace the loadDefaults()-driven prefill with loadProfile(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). Persist initProfile and resolved prefix on the new config.
  • bin/vsync.ts — add "profile" and "status" to SUBCOMMANDS, 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 the saveDefaults export; rename loadDefaults to loadLegacyDefaults to make call sites obvious. Removable in v0.14.
  • src/repoconfig.ts — extend ConfigFile with initProfile?: string and prefix: string (the latter previously implicit). validateConfigFile accepts both for version: 1; configs minted before v0.13 are read as-is, with prefix synthesised 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 and vsync status. The "first run" section rewritten around profile addinit --profile=….
  • package.jsonfiles already includes bin/ and src/; no entry change. Version bump to 0.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-secret TTY-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/vsync tree in a mkdtempSync dir (per the convention in test/configfile.test.ts), assert table and --json snapshots, force each orphan class, verify --quiet exit 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-remote covered 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).

Released under the MIT License.