@muthuishere/vsync 0.3.1 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,34 +1,91 @@
1
1
  # vsync
2
2
 
3
- Encrypted secret-sync CLI for small teams.
3
+ **One encrypted vault for your environment secrets, shared across your team, mirrored to GitHub & GCP, audited every time someone touches it.**
4
4
 
5
- - **One canonical store on S3** — your `infra/vault/<env>/` folder, sealed with AES-256-GCM and a manifest pointer that prevents silent rollback.
6
- - **Per-machine encryption key** in the OS keychain (`Bun.secrets` — macOS Keychain, Linux libsecret, Windows Credential Manager).
7
- - **Fanout** to GitHub Repo Secrets and GCP Secret Manager from the same source of truth.
8
- - **Share file** for onboarding teammates with one passphrase-protected `.share` and one passphrase, sent on different channels.
5
+ ![vsync flow](https://raw.githubusercontent.com/muthuishere/vsync/main/docs/public/vsync-flow.png)
6
+
7
+ A `.env` file is the friendliest thing in your repo: one line per secret, edited by hand, loaded by every framework. It's also the worst thing in your repo — passed around on Slack, copy-pasted into the wrong window, never the same on any two laptops, **never encrypted, never versioned, never auditable**. The moment one teammate's secrets drift from another's, you stop trusting `.env` and start emailing JSON files.
8
+
9
+ vsync keeps the `.env` you already write, and turns it into a real vault:
10
+
11
+ - **One folder per environment.** Anything secret — `.env.dev`, `gcp-sa.json`, TLS certs, regression fixtures, signing keys — lives under `infra/vault/<env>/`. No naming convention to learn; whatever is in there gets sealed.
12
+ - **Encrypted bucket as the canonical store.** `vsync push <env>` zips the folder, seals it with AES-256-GCM + manifest-anti-rollback, uploads to any S3-compatible bucket: **AWS S3, Hetzner Object Storage, self-hosted MinIO, Cloudflare R2, Backblaze B2**. The bucket holds the only blessed copy.
13
+ - **One-passphrase onboarding.** New teammate runs `vsync import dev <file>.share`, types the passphrase you sent on a separate channel, runs `vsync pull dev`. Done. No shell-rc edits, no env-var blobs, no key sharing in Slack DMs.
14
+ - **Fanout to where prod actually runs.** `vsync sync dev gh` and `vsync sync dev gcp` push the same `.env.<env>` keys to GitHub Actions secrets / GCP Secret Manager. One edit in the vault; both stay in step.
15
+ - **Append-only audit log.** Every push/pull/import/export records `who, where, when, version, free-form note` to a CSV on the bucket. `vsync audit dev` prints it. CI passes `--note="run #1234"` and it shows up.
16
+ - **Per-machine key in the OS keychain.** `Bun.secrets` — macOS Keychain, Linux libsecret, Windows Credential Manager. The S3 bucket alone is useless; the key alone is useless. Both halves required to decrypt.
9
17
 
10
18
  ```bash
11
- bunx @muthuishere/vsync --help
19
+ bun install -g @muthuishere/vsync # or: npm install -g @muthuishere/vsync
20
+ vsync --help
12
21
  ```
13
22
 
14
- No shell-rc edits. No giant base64 blob in `~/.zshrc`. Run via `bunx`; nothing to install.
23
+ One global install, then `vsync` is on PATH. No shell-rc edits, no giant base64 blob in `~/.zshrc`. (Allergic to global installs? `bunx @muthuishere/vsync <subcommand>` works too — same code path, slower invocation.)
24
+
25
+ ---
26
+
27
+ ## What lives in the vault
28
+
29
+ Whatever your app needs at runtime that you'd otherwise scatter across Slack DMs, a password manager, or `~/Downloads/`:
30
+
31
+ ```
32
+ infra/vault/
33
+ dev/
34
+ .env.dev # KV secrets — vsync sync ships these to gh/gcp
35
+ gcp-sa.json # JSON service account key
36
+ regression-fixture.json # test data that mirrors prod shape
37
+ tls/cert.pem
38
+ tls/key.pem
39
+ production/
40
+ .env.production
41
+ gcp-sa.json
42
+ ```
43
+
44
+ vsync doesn't care what's in there — it zips and seals the whole folder. The `.env.<env>` file is special only in that `vsync sync` reads it for KV fanout to GitHub / GCP. Everything else (JSON keys, certs, regression fixtures, anything binary) just rides along in the encrypted bundle and lands back on every teammate's disk after `pull`.
45
+
46
+ So regression tests, scripts, or any tool that needs real-shape inputs read directly from `infra/vault/<env>/whatever.json` — no separate test-data dance.
47
+
48
+ For monorepos, override per-(repo, env): `vsync init dev --vault-folder=apps/foo/infra/vault/dev`. The path is stored in the per-repo config and carried in the `.share` file so teammates inherit it automatically.
49
+
50
+ ---
51
+
52
+ ## Switching environments — `vsync use`
53
+
54
+ So apps don't need to know vault paths, `vsync use <env>` creates a symlink that points the conventional `.env` location at the vault's env file:
55
+
56
+ ```bash
57
+ vsync use dev # ./.env → infra/vault/dev/.env.dev
58
+ vsync use production # repoint to infra/vault/production/.env.production
59
+ vsync use # print current target
60
+ ```
61
+
62
+ Any framework reading `.env` (Vite, Next.js, Bun, dotenv, every Python lib) just works — no path argument, no custom loader. Switch environments with one command; restart your dev server and you're running against the new env.
63
+
64
+ **Pick a different link name or location** with `--link=<path>` — useful when you already have a `.env`, want the conventional `.env.<env>` name, or work in a monorepo:
65
+
66
+ ```bash
67
+ vsync use dev --link=.env.dev # ./.env.dev → infra/vault/dev/.env.dev
68
+ vsync use prod --link=apps/web/.env # apps/web/.env → … (monorepo)
69
+ ```
70
+
71
+ **Safety:** if the link path already exists as a *regular file*, vsync refuses to touch it — no `--force`, by design. Move or delete it first (`mv .env .env.local.bak`) and re-run. An existing *symlink* at that path is replaced silently (symlinks are cheap to recreate). vsync also warns if the link's basename isn't covered by `.gitignore`.
72
+
73
+ **Platform support:** POSIX symlinks on macOS / Linux / WSL out of the box. On Windows it uses the same call with `type: "file"` — requires Developer Mode (Settings → Privacy & security → For developers) or an elevated terminal. vsync prints actionable guidance if the privilege is missing.
15
74
 
16
75
  ---
17
76
 
18
77
  ## Mental model
19
78
 
20
- Two persistent halves per (repo, env). Both required to push or pull:
79
+ Every (repo, env) is held by **two persistent halves**. Both required to push or pull; either one alone is useless.
21
80
 
22
81
  ```
23
82
  ┌──────────────────────────────────────────────────────────────────┐
24
83
  │ Disk (chmod 0600) │
25
84
  │ ~/.config/vsync/<repo>/env_<env> self-contained config │
26
- │ ├── s3.{endpoint, region, bucket, …} required
27
- │ ├── encryption.salt random per init
85
+ │ ├── s3.{endpoint, region, bucket, …} where to find bytes
86
+ │ ├── encryption.salt PBKDF2 input
28
87
  │ ├── files.vaultFolder optional override │
29
- │ │ (default infra/vault/<env>)│
30
88
  │ └── sync.{gh.repo, gcp.project} set by `vsync sync` │
31
- │ ~/.config/vsync/defaults pre-fills `init` only │
32
89
  └──────────────────────────────────────────────────────────────────┘
33
90
 
34
91
  ┌──────────────────────────────────────────────────────────────────┐
@@ -39,43 +96,24 @@ Two persistent halves per (repo, env). Both required to push or pull:
39
96
  └──────────────────────────────────────────────────────────────────┘
40
97
  ```
41
98
 
42
- Anyone with **(S3 read access to the bucket)** AND **(the encryption key in their keychain)** can pull. Either alone is useless: the disk file gets you bucket access but no decrypt; the key gets you decrypt but no bucket location.
43
-
44
- The per-repo file is self-contained — `push`/`pull`/`sync` never read a second config. `~/.config/vsync/defaults` is consulted *only* by `init` to pre-fill prompts on subsequent setups.
45
-
46
- In your repo, all secret content lives in one place. Default layout:
47
-
48
- ```
49
- infra/vault/
50
- dev/
51
- .env.dev
52
- some-secret.json
53
- ...
54
- production/
55
- .env.production
56
- ```
57
-
58
- Apps point dotenv (or equivalent) at the path:
99
+ The disk file gets you bucket access no decrypt. The keychain key gets you decrypt no bucket location. Stealing one is a non-event; you need both to read a single secret. Offboarding cuts the bucket side (the cloud provider's IAM), then `vsync init` mints a fresh key for the team that stays.
59
100
 
60
- ```js
61
- dotenv.config({ path: `infra/vault/${env}/.env.${env}` });
62
- ```
63
-
64
- `vsync init` prints the dotenv snippet so you copy it once.
65
-
66
- **Monorepos:** override the vault folder per (repo, env) at init time — `vsync init dev --vault-folder=apps/foo/infra/vault/dev`. The override is stored per-repo, used by every subsequent `push`/`pull`/`sync`, and carried in the `.share` file so teammates inherit it.
101
+ A `.share` file bundles **both halves** under one passphrase. Sent on a different channel than the passphrase itself, it's the smallest possible onboarding step.
67
102
 
68
103
  ---
69
104
 
70
105
  ## Install
71
106
 
72
- You don't. Run via `bunx`:
73
-
74
107
  ```bash
75
- bunx @muthuishere/vsync <subcommand>
108
+ bun install -g @muthuishere/vsync # or: npm install -g @muthuishere/vsync
109
+ vsync --help
76
110
  ```
77
111
 
78
- Requires Bun ≥ 1.2.21 (for `Bun.secrets`). For local development of vsync itself:
112
+ Requires Bun ≥ 1.2.21 on PATH (for `Bun.secrets`) — the shebang is `#!/usr/bin/env bun`, so `bun` must be installed even if you used `npm install -g` for the package itself. Most users have Bun anyway; if not, see [bun.sh](https://bun.sh).
113
+
114
+ Don't want to install? `bunx @muthuishere/vsync <subcommand>` runs the same code from npm cache each time — fine for trying it out, slower for daily use.
115
+
116
+ For local development of vsync itself:
79
117
 
80
118
  ```bash
81
119
  git clone git@github.com:muthuishere/vsync.git
@@ -91,14 +129,14 @@ bun test
91
129
  ```bash
92
130
  # 1. Generate the per-(repo, env) key + config. First-ever invocation prompts
93
131
  # for S3 creds; subsequent inits pre-fill from ~/.config/vsync/defaults.
94
- bunx @muthuishere/vsync init dev
132
+ vsync init dev
95
133
 
96
134
  # 2. Put your secrets under infra/vault/dev/ and push.
97
135
  echo "DATABASE_URL=postgres://..." > infra/vault/dev/.env.dev
98
- bunx @muthuishere/vsync push dev
136
+ vsync push dev
99
137
 
100
138
  # 3. Hand the team a share file + passphrase (different channels).
101
- bunx @muthuishere/vsync export dev
139
+ vsync export dev
102
140
  ```
103
141
 
104
142
  For an onboarding cheat sheet to drop into your repo (so teammates and AI agents know vsync exists), run `vsync docs > infra/AGENTS.md`. Plain stdout — pipe it wherever you want.
@@ -110,11 +148,11 @@ cd <cloned-repo>
110
148
 
111
149
  # 1. Import the share file your teammate sent (carries S3 creds + key).
112
150
  # No prior `init` required on this machine.
113
- bunx @muthuishere/vsync import dev ./reqsume-dev.share
151
+ vsync import dev ./reqsume-dev.share
114
152
  # Passphrase: <paste>
115
153
 
116
154
  # 2. Pull the encrypted bundle.
117
- bunx @muthuishere/vsync pull dev
155
+ vsync pull dev
118
156
  ```
119
157
 
120
158
  After step 2, `infra/vault/dev/` is populated and the encryption key is in your keychain.
@@ -123,18 +161,18 @@ After step 2, `infra/vault/dev/` is populated and the encryption key is in your
123
161
 
124
162
  ```bash
125
163
  # I edited infra/vault/dev/.env.dev locally:
126
- bunx @muthuishere/vsync push dev
164
+ vsync push dev
127
165
 
128
166
  # Get the latest from S3:
129
- bunx @muthuishere/vsync pull dev
167
+ vsync pull dev
130
168
 
131
169
  # See what versions exist on S3:
132
- bunx @muthuishere/vsync versions dev
170
+ vsync versions dev
133
171
 
134
172
  # Push secrets out to GitHub / GCP:
135
- bunx @muthuishere/vsync sync dev gh
136
- bunx @muthuishere/vsync sync dev gcp
137
- bunx @muthuishere/vsync sync dev all
173
+ vsync sync dev gh
174
+ vsync sync dev gcp
175
+ vsync sync dev all
138
176
  ```
139
177
 
140
178
  `pull` makes a local backup at `~/.config/vsync/backups/<env>-<ts>.zip.enc` before overwriting (two-deep rolling buffer). See "Recovering a local backup" below if you ever need one.
@@ -149,13 +187,15 @@ Every command works fully via flags or fully via prompts.
149
187
 
150
188
  | Cmd | Purpose |
151
189
  |---|---|
152
- | `init <env>` | Generate AES key (→ keychain), write self-contained per-repo config, create the resolved vault folder, relocate an existing root `.env.<env>` if found (with a prompt). First-ever run on a machine also writes `~/.config/vsync/defaults` from the supplied values; subsequent runs pre-fill from defaults. Flags: `--bucket --endpoint --region --access-key --secret-key --use-ssl --vault-folder=<path> --migrate-from=<path> --no-migrate`. |
153
- | `export <env>` | Write a passphrase-encrypted `.share` file containing the full per-repo config + key. Flags: `--out=<path>` (default `./<repo>-<env>.share`), `--passphrase=<p>` (default: auto-generated readable passphrase). |
154
- | `import <env> <file>` | Decrypt a `.share` file with its passphrase; write the per-repo config + save key to keychain. Idempotent — re-importing overwrites. Flags: `--passphrase=<p>`, `--file=<path>` (alt to positional). |
155
- | `push <env>` | Zip the resolved vault folder manifest-seal AES-256-GCM encrypt upload to `s3://<bucket>/<repo>/<env>/versions/<ts>.enc`, then update `s3://<bucket>/<repo>/<env>/latest`. |
156
- | `pull <env>` | Read `latest` pointer download version verify embedded manifest timestamp matches pointer (anti-rollback) decrypt unzip into the resolved vault folder. Auto-backs up existing contents first. |
190
+ | `init <env>` | Generate AES key (→ keychain), write self-contained per-repo config, create the resolved vault folder, relocate an existing root `.env.<env>` if found (with a prompt). First-ever run on a machine also writes `~/.config/vsync/defaults` from the supplied values; subsequent runs pre-fill from defaults. Flags: `--bucket --endpoint --region --access-key --secret-key --use-ssl --vault-folder=<path> --migrate-from=<path> --no-migrate --audit=on\|off`. |
191
+ | `export <env>` | Write a passphrase-encrypted `.share` file containing the full per-repo config + key. Flags: `--out=<path>` (default `./<repo>-<env>.share`), `--passphrase=<p>` (default: auto-generated readable passphrase), `--no-audit`, `--note=<text>`, `--meta key=value` (repeatable). |
192
+ | `import <env> <file>` | Decrypt a `.share` file with its passphrase; write the per-repo config + save key to keychain. Idempotent — re-importing overwrites. Flags: `--passphrase=<p>`, `--file=<path>` (alt to positional), `--no-audit`, `--note=<text>`, `--meta key=value` (repeatable). |
193
+ | `use <env>` | Symlink the chosen path`<vaultFolder>/.env.<env>` so plain `dotenv.config()` works without a path arg. Default link path is `./.env`; override with `--link=<path>` (e.g. `--link=.env.dev` or `--link=apps/web/.env`). `vsync use` with no env prints the current target. **Refuses to touch an existing regular file at the link path — no `--force`, by design.** Replaces an existing symlink silently. Warns if the link's basename isn't `.gitignore`d. POSIX symlinks everywhere; Windows requires Developer Mode or an elevated terminal. |
194
+ | `push <env>` | Zip the resolved vault folder → manifest-seal AES-256-GCM encryptupload to `s3://<bucket>/<repo>/<env>/versions/<ts>.enc`, then update `s3://<bucket>/<repo>/<env>/latest`. Flags: `--no-audit`, `--note=<text>`, `--meta key=value` (repeatable). |
195
+ | `pull <env>` | Read `latest` pointer → download version → verify embedded manifest timestamp matches pointer (anti-rollback) → decrypt → unzip into the resolved vault folder. Auto-backs up existing contents first. Flags: `--no-audit`, `--note=<text>`, `--meta key=value` (repeatable). |
157
196
  | `versions <env>` | List `s3://<bucket>/<repo>/<env>/versions/`. One line per version with size + age, `* latest` marker on the active one. Read-only; no decrypt. |
158
197
  | `sync <env> <gh\|gcp\|all>` | Read `<vaultFolder>/.env.<env>` → push each KV to the named target. Parallel (6 workers, 10-min timeout). First run prompts for routing config (gh repo / gcp project) and saves it; subsequent runs zero-prompt. Flags: `--gh-repo=<owner/name>`, `--gcp-project=<id>`. |
198
+ | `audit <env>` | Print the S3-side audit log: who/where/when of every pull/push/import/export. Flags: `--limit=N`, `--all`, `--csv`. |
159
199
  | `docs` | Print a short onboarding reference (commands, vault layout, backup recovery procedure) to stdout. Pipe wherever you want — e.g. `vsync docs > infra/AGENTS.md`. |
160
200
 
161
201
  ### `sync` env-file parsing
@@ -172,6 +212,28 @@ Two local-only keys (skipped — used by `gh` / `gcloud` on the local machine, n
172
212
 
173
213
  Everything else is pushed verbatim.
174
214
 
215
+ ### Audit log
216
+
217
+ Every `pull`, `push`, `import`, and `export` appends a row to `s3://<bucket>/<repo>/<env>/audit.csv` so the team can see who did what, from where, and when. Columns:
218
+
219
+ - `ts, action, version_ts, hostname, local_ip, os_user, git_email, vsync_version, bun_version, meta`
220
+
221
+ On by default. Skip a single invocation with `--no-audit`. Disable per (repo, env) with `vsync init <env> --audit=off` (or pick "off" at the first-time prompt).
222
+
223
+ Tag any row with free-form context via `--note=<text>` (sugar for `--meta note=<text>`) or `--meta key=value` (repeatable). The matching env vars `VSYNC_AUDIT_NOTE` and `VSYNC_AUDIT_META` (a JSON object) merge in for CI ergonomics. Example one-liner:
224
+
225
+ ```bash
226
+ VSYNC_AUDIT_META='{"run_id":"7891234","commit":"abc123"}' \
227
+ vsync pull production --note="prod deploy" --meta ticket=BUG-42
228
+ ```
229
+
230
+ View the log with `vsync audit <env>` (`--limit=N`, `--all`, `--csv`).
231
+
232
+ Two honesty bullets:
233
+
234
+ - This is a transparency aid, not tamper-proof. Anyone with bucket write can rewrite the CSV; vsync just makes honest activity legible.
235
+ - The log does not let you reclaim already-pulled secrets. Once a teammate has pulled, they hold a local copy — rotate the key to invalidate future pulls.
236
+
175
237
  ---
176
238
 
177
239
  ## How sync works (gh + gcp)
@@ -241,17 +303,13 @@ In practice, just don't lose the keychain entry. `pull` itself is the recovery p
241
303
 
242
304
  ## Versioning
243
305
 
244
- This is **0.3.0** a clean break from `@muthuishere/secret-lib` 0.2.x. New package name, new bin (`vsync`), new keychain service (`tools.vsync`), new config root (`~/.config/vsync/`), new vault layout (`infra/vault/<env>/.env.<env>`). The crypto envelope (`RQE1`) is unchanged.
245
-
246
- 0.3.x does not auto-migrate from 0.2.x. The supported upgrade path is to re-`init` from scratch:
247
-
248
- ```bash
249
- vsync init dev # auto-relocates root .env.dev if it exists
250
- vsync push dev
251
- vsync export dev # re-share with team
252
- ```
306
+ | Release | What's in it |
307
+ |---|---|
308
+ | **0.5.0** | `vsync use <env>` symlinks `./.env` (or `--link=<path>`) at the vault's env file so `dotenv.config()` just works; switch envs with one command. README rewrite + flow diagram. |
309
+ | 0.4.0 | Append-only audit log at `s3://<bucket>/<repo>/<env>/audit.csv` + `vsync audit` viewer. Expandable `meta` JSON cell via `--note` / `--meta` + matching env vars. |
310
+ | 0.3.0 | Opinionated layout: vault folder at `infra/vault/<env>/` with `--vault-folder` override; self-contained per-(repo, env) config; `vsync sync` for GitHub / GCP fanout. |
253
311
 
254
- Any leftover 0.2.x on-disk config tree and keychain entries can be deleted; nothing in 0.3.x reads them. `@muthuishere/secret-lib` 0.2.x stays on npm for users who can't migrate.
312
+ All 0.x releases are wire-compatible with each other on the S3 bundle envelope (`RQE1`) and manifest seal (`RQEM0001`). New clients tolerate the absence of features added in later versions; old clients ignore new objects (like `audit.csv`) on the bucket.
255
313
 
256
314
  ---
257
315
 
package/bin/audit.ts ADDED
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env bun
2
+ // Usage: vsync audit <env> [--limit=N] [--all] [--csv] [--repo=<name>]
3
+ //
4
+ // Fetches s3://<bucket>/<repo>/<env>/audit.csv and prints it. Default is a
5
+ // pretty table of the last 50 rows (newest first); --all shows everything;
6
+ // --limit=N picks a custom cap; --csv passes the raw CSV through for piping
7
+ // into shell tools or spreadsheets.
8
+ //
9
+ // Read-only: per spec §6, observing the log must not perturb it — this
10
+ // command does NOT append an audit row of its own.
11
+
12
+ import { parseArgs } from "../src/argv";
13
+ import { getRepoName } from "../src/repo";
14
+ import { loadConfigFile, configFilePath } from "../src/repoconfig";
15
+ import {
16
+ makeAuditClient,
17
+ readAuditLog,
18
+ formatAuditTable,
19
+ formatAuditCsv,
20
+ } from "../src/audit";
21
+
22
+ export async function main(argv: string[]): Promise<void> {
23
+ const { positional, flags } = parseArgs(argv);
24
+ const env = positional[0];
25
+ if (!env) {
26
+ console.error("usage: vsync audit <env> [--limit=N] [--all] [--csv] [--repo=<name>]");
27
+ process.exit(1);
28
+ }
29
+ const repo = await getRepoName({ override: flags.repo });
30
+
31
+ const cfg = await loadConfigFile(repo, env);
32
+ if (!cfg) {
33
+ console.error(
34
+ `no config file for ${repo}/${env} at ${configFilePath(repo, env)}.\n` +
35
+ `Run 'vsync init ${env}' first, or 'vsync import ${env} <share-file>' if a teammate sent you one.`,
36
+ );
37
+ process.exit(1);
38
+ }
39
+
40
+ const client = makeAuditClient(cfg.s3);
41
+
42
+ let rows;
43
+ try {
44
+ rows = await readAuditLog(client, repo, env);
45
+ } catch (e) {
46
+ console.error(
47
+ `failed to read audit log for ${repo}/${env}: ${(e as Error).message}`,
48
+ );
49
+ process.exit(1);
50
+ }
51
+
52
+ if (rows.length === 0) {
53
+ console.log(`(no audit log yet for ${repo}/${env})`);
54
+ return;
55
+ }
56
+
57
+ if (flags.csv === "true") {
58
+ process.stdout.write(formatAuditCsv(rows));
59
+ return;
60
+ }
61
+
62
+ const all = flags.all === "true";
63
+ const limit = flags.limit !== undefined ? parseInt(flags.limit, 10) : undefined;
64
+ if (limit !== undefined && (!Number.isFinite(limit) || limit <= 0)) {
65
+ console.error(`--limit must be a positive integer (got "${flags.limit}")`);
66
+ process.exit(1);
67
+ }
68
+ console.log(formatAuditTable(rows, { limit, all }));
69
+ }
70
+
71
+ if (import.meta.main) {
72
+ await main(process.argv.slice(2));
73
+ }
package/bin/export.ts CHANGED
@@ -11,17 +11,23 @@
11
11
 
12
12
  import { parseArgs } from "../src/argv";
13
13
  import { getRepoName } from "../src/repo";
14
- import { loadConfigFile, configFilePath } from "../src/repoconfig";
14
+ import { loadConfigFile, configFilePath, DEFAULT_AUDIT_ENABLED } from "../src/repoconfig";
15
15
  import { getKey } from "../src/keychain";
16
16
  import { EXPORT_BLOB_VERSION, type ExportPayload } from "../src/envconfig";
17
17
  import { buildShareFile } from "../src/sharefile";
18
18
  import { generatePassphrase } from "../src/passphrase";
19
19
  import { askText, isTty } from "../src/prompt";
20
+ import {
21
+ appendAuditRow,
22
+ buildMeta,
23
+ gatherRowMetadata,
24
+ makeAuditClient,
25
+ } from "../src/audit";
20
26
  import { writeFile } from "node:fs/promises";
21
27
  import { resolve } from "node:path";
22
28
 
23
29
  export async function main(argv: string[]): Promise<void> {
24
- const { positional, flags } = parseArgs(argv);
30
+ const { positional, flags, lists } = parseArgs(argv);
25
31
  const env = positional[0];
26
32
  if (!env) {
27
33
  console.error("usage: vsync export <env> [--repo=<name>] [--out=<path>] [--passphrase=<pp>]");
@@ -85,6 +91,50 @@ export async function main(argv: string[]): Promise<void> {
85
91
  console.log("(e.g. file via Slack DM, passphrase via SMS).\n");
86
92
  console.log("They will run:");
87
93
  console.log(` vsync import ${env} ${out.split("/").pop()}`);
94
+
95
+ await tryAppendAudit(cfg.s3, cfg.audit?.enabled, flags, lists, repo, env);
96
+ }
97
+
98
+ /**
99
+ * Best-effort audit append. Honours both the per-(repo, env) opt-out
100
+ * (`cfg.audit.enabled === false`) and the per-invocation `--no-audit`
101
+ * flag. Any throw from the append path is downgraded to a stderr warning
102
+ * so the parent command's exit code is unaffected.
103
+ */
104
+ async function tryAppendAudit(
105
+ s3: Parameters<typeof makeAuditClient>[0],
106
+ enabled: boolean | undefined,
107
+ flags: Record<string, string>,
108
+ lists: Record<string, string[]>,
109
+ repo: string,
110
+ env: string,
111
+ ): Promise<void> {
112
+ if (flags["no-audit"] === "true") return;
113
+ const on = enabled ?? DEFAULT_AUDIT_ENABLED;
114
+ if (!on) return;
115
+
116
+ let meta;
117
+ try {
118
+ meta = buildMeta({
119
+ envMeta: process.env.VSYNC_AUDIT_META,
120
+ envNote: process.env.VSYNC_AUDIT_NOTE,
121
+ flagMetaList: lists.meta,
122
+ flagNote: flags.note,
123
+ });
124
+ } catch (e) {
125
+ console.error((e as Error).message);
126
+ process.exit(1);
127
+ }
128
+ for (const w of meta.warnings) console.error(w);
129
+
130
+ try {
131
+ const row = await gatherRowMetadata("export", "");
132
+ row.meta = meta.json;
133
+ const client = makeAuditClient(s3);
134
+ await appendAuditRow(client, repo, env, row);
135
+ } catch (e) {
136
+ console.error(`warning: failed to record audit entry: ${(e as Error).message}`);
137
+ }
88
138
  }
89
139
 
90
140
  if (import.meta.main) {
package/bin/import.ts CHANGED
@@ -10,15 +10,21 @@
10
10
 
11
11
  import { parseArgs } from "../src/argv";
12
12
  import { getRepoName } from "../src/repo";
13
- import { saveConfigFile, configFilePath } from "../src/repoconfig";
13
+ import { saveConfigFile, configFilePath, DEFAULT_AUDIT_ENABLED } from "../src/repoconfig";
14
14
  import { setKey } from "../src/keychain";
15
15
  import { parseShareFile } from "../src/sharefile";
16
16
  import { askText, askSecret, isTty } from "../src/prompt";
17
+ import {
18
+ appendAuditRow,
19
+ buildMeta,
20
+ gatherRowMetadata,
21
+ makeAuditClient,
22
+ } from "../src/audit";
17
23
  import { readFile } from "node:fs/promises";
18
24
  import { resolve } from "node:path";
19
25
 
20
26
  export async function main(argv: string[]): Promise<void> {
21
- const { positional, flags } = parseArgs(argv);
27
+ const { positional, flags, lists } = parseArgs(argv);
22
28
  const env = positional[0];
23
29
  let filePath = positional[1] ?? flags.file;
24
30
  if (!env) {
@@ -96,6 +102,50 @@ export async function main(argv: string[]): Promise<void> {
96
102
  console.log("You can safely delete the .share file now — its contents are installed.");
97
103
  // Silence "unused var" linters from finalEnv assignment kept for clarity.
98
104
  void finalEnv;
105
+
106
+ await tryAppendAudit(payload.config.s3, payload.config.audit?.enabled, flags, lists, repo, env);
107
+ }
108
+
109
+ /**
110
+ * Best-effort audit append. Honours both the per-(repo, env) opt-out
111
+ * (`cfg.audit.enabled === false`) and the per-invocation `--no-audit`
112
+ * flag. Any throw from the append path is downgraded to a stderr warning
113
+ * so the parent command's exit code is unaffected.
114
+ */
115
+ async function tryAppendAudit(
116
+ s3: Parameters<typeof makeAuditClient>[0],
117
+ enabled: boolean | undefined,
118
+ flags: Record<string, string>,
119
+ lists: Record<string, string[]>,
120
+ repo: string,
121
+ env: string,
122
+ ): Promise<void> {
123
+ if (flags["no-audit"] === "true") return;
124
+ const on = enabled ?? DEFAULT_AUDIT_ENABLED;
125
+ if (!on) return;
126
+
127
+ let meta;
128
+ try {
129
+ meta = buildMeta({
130
+ envMeta: process.env.VSYNC_AUDIT_META,
131
+ envNote: process.env.VSYNC_AUDIT_NOTE,
132
+ flagMetaList: lists.meta,
133
+ flagNote: flags.note,
134
+ });
135
+ } catch (e) {
136
+ console.error((e as Error).message);
137
+ process.exit(1);
138
+ }
139
+ for (const w of meta.warnings) console.error(w);
140
+
141
+ try {
142
+ const row = await gatherRowMetadata("import", "");
143
+ row.meta = meta.json;
144
+ const client = makeAuditClient(s3);
145
+ await appendAuditRow(client, repo, env, row);
146
+ } catch (e) {
147
+ console.error(`warning: failed to record audit entry: ${(e as Error).message}`);
148
+ }
99
149
  }
100
150
 
101
151
  if (import.meta.main) {
package/bin/init.ts CHANGED
@@ -30,6 +30,8 @@
30
30
  // --vault-folder=<path> Override default infra/vault/<env> for monorepos
31
31
  // --migrate-from=<path> Use a non-default source for the .env relocation
32
32
  // --no-migrate Skip the root .env.<env> migration prompt entirely
33
+ // --audit=on|off Enable/disable the per-(repo, env) audit log
34
+ // (default on; prompted interactively when unset)
33
35
  // --interactive Prompt even for fields already provided via flags
34
36
 
35
37
  import { existsSync, mkdirSync, renameSync, readFileSync } from "node:fs";
@@ -39,6 +41,7 @@ import { getRepoName, getRepoRoot } from "../src/repo";
39
41
  import {
40
42
  saveConfigFile,
41
43
  configFilePath,
44
+ DEFAULT_AUDIT_ENABLED,
42
45
  type ConfigFile,
43
46
  } from "../src/repoconfig";
44
47
  import { setKey, generateKey } from "../src/keychain";
@@ -73,6 +76,14 @@ function randomSalt(): string {
73
76
  return Buffer.from(bytes).toString("base64").replace(/=+$/, "");
74
77
  }
75
78
 
79
+ function parseOnOff(raw: string, label: string): boolean {
80
+ const v = raw.toLowerCase();
81
+ if (v === "on" || v === "true" || v === "yes" || v === "1") return true;
82
+ if (v === "off" || v === "false" || v === "no" || v === "0") return false;
83
+ console.error(`${label} must be "on" or "off" (got "${raw}")`);
84
+ process.exit(1);
85
+ }
86
+
76
87
  export async function main(argv: string[]): Promise<void> {
77
88
  const { positional, flags } = parseArgs(argv);
78
89
  const env = envFromArg(positional[0]);
@@ -133,11 +144,26 @@ export async function main(argv: string[]): Promise<void> {
133
144
  const vaultFolder = vaultFolderOverride ?? defaultVaultFolder;
134
145
  const hasVaultOverride = !!vaultFolderOverride && vaultFolderOverride !== defaultVaultFolder;
135
146
 
147
+ // --audit=on|off — explicit flag wins; otherwise prompt when interactive
148
+ // (or no flag and TTY); otherwise default to enabled.
149
+ const auditFlag = flags.audit;
150
+ let auditEnabled: boolean;
151
+ if (auditFlag !== undefined && !interactive) {
152
+ auditEnabled = parseOnOff(auditFlag, "--audit");
153
+ } else if (isTty() && (interactive || auditFlag === undefined)) {
154
+ const prefilled =
155
+ auditFlag !== undefined ? parseOnOff(auditFlag, "--audit") : DEFAULT_AUDIT_ENABLED;
156
+ auditEnabled = askBool("Enable audit log?", prefilled);
157
+ } else {
158
+ auditEnabled = auditFlag !== undefined ? parseOnOff(auditFlag, "--audit") : DEFAULT_AUDIT_ENABLED;
159
+ }
160
+
136
161
  const cfg: ConfigFile = {
137
162
  version: 1,
138
163
  s3: { endpoint, region, bucket, accessKeyId, secretAccessKey, useSsl },
139
164
  encryption: { salt: randomSalt() },
140
165
  ...(hasVaultOverride ? { files: { vaultFolder } } : {}),
166
+ audit: { enabled: auditEnabled },
141
167
  };
142
168
 
143
169
  const filePath = await saveConfigFile(repo, env, cfg);
package/bin/pull.ts CHANGED
@@ -12,14 +12,21 @@ import { join } from "node:path";
12
12
  import { parseArgs } from "../src/argv";
13
13
  import { getRepoName, getRepoRoot } from "../src/repo";
14
14
  import { loadEnvConfig, resolveVaultFolder } from "../src/envconfig";
15
+ import { loadConfigFile, DEFAULT_AUDIT_ENABLED } from "../src/repoconfig";
15
16
  import { unzipTo } from "../src/archive";
16
17
  import { decrypt } from "../src/crypto";
17
18
  import { unwrap } from "../src/manifest";
18
19
  import { makeClient } from "../src/s3";
19
20
  import { makeBackup } from "../src/backup";
21
+ import {
22
+ appendAuditRow,
23
+ buildMeta,
24
+ gatherRowMetadata,
25
+ makeAuditClient,
26
+ } from "../src/audit";
20
27
 
21
28
  export async function main(argv: string[]): Promise<void> {
22
- const { positional, flags } = parseArgs(argv);
29
+ const { positional, flags, lists } = parseArgs(argv);
23
30
  const env = positional[0];
24
31
  if (!env) {
25
32
  console.error("usage: vsync pull <env> [--repo=<name>]");
@@ -34,6 +41,9 @@ export async function main(argv: string[]): Promise<void> {
34
41
  console.error((e as Error).message);
35
42
  process.exit(1);
36
43
  }
44
+ // Reload the on-disk ConfigFile to pick up `audit.enabled` (EnvConfig
45
+ // doesn't carry it through).
46
+ const cfgFile = await loadConfigFile(repo, env);
37
47
  const root = await getRepoRoot();
38
48
  const vaultFolder = resolveVaultFolder(cfg, env);
39
49
 
@@ -96,6 +106,51 @@ export async function main(argv: string[]): Promise<void> {
96
106
  } finally {
97
107
  if (existsSync(tmpZip)) unlinkSync(tmpZip);
98
108
  }
109
+
110
+ await tryAppendAudit(cfg.s3, cfgFile?.audit?.enabled, flags, lists, repo, env, remoteTs);
111
+ }
112
+
113
+ /**
114
+ * Best-effort audit append. Honours both the per-(repo, env) opt-out
115
+ * (`cfg.audit.enabled === false`) and the per-invocation `--no-audit`
116
+ * flag. Any throw from the append path is downgraded to a stderr warning
117
+ * so the parent command's exit code is unaffected.
118
+ */
119
+ async function tryAppendAudit(
120
+ s3: Parameters<typeof makeAuditClient>[0],
121
+ enabled: boolean | undefined,
122
+ flags: Record<string, string>,
123
+ lists: Record<string, string[]>,
124
+ repo: string,
125
+ env: string,
126
+ versionTs: string,
127
+ ): Promise<void> {
128
+ if (flags["no-audit"] === "true") return;
129
+ const on = enabled ?? DEFAULT_AUDIT_ENABLED;
130
+ if (!on) return;
131
+
132
+ let meta;
133
+ try {
134
+ meta = buildMeta({
135
+ envMeta: process.env.VSYNC_AUDIT_META,
136
+ envNote: process.env.VSYNC_AUDIT_NOTE,
137
+ flagMetaList: lists.meta,
138
+ flagNote: flags.note,
139
+ });
140
+ } catch (e) {
141
+ console.error((e as Error).message);
142
+ process.exit(1);
143
+ }
144
+ for (const w of meta.warnings) console.error(w);
145
+
146
+ try {
147
+ const row = await gatherRowMetadata("pull", versionTs);
148
+ row.meta = meta.json;
149
+ const client = makeAuditClient(s3);
150
+ await appendAuditRow(client, repo, env, row);
151
+ } catch (e) {
152
+ console.error(`warning: failed to record audit entry: ${(e as Error).message}`);
153
+ }
99
154
  }
100
155
 
101
156
  if (import.meta.main) {