@muthuishere/vsync 0.3.1 → 0.5.0

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,90 @@
1
1
  # vsync
2
2
 
3
- Encrypted secret-sync CLI for small teams.
3
+ **Your `.env` files kept as simple, made as safe as a vault.**
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](docs/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
19
  bunx @muthuishere/vsync --help
12
20
  ```
13
21
 
14
- No shell-rc edits. No giant base64 blob in `~/.zshrc`. Run via `bunx`; nothing to install.
22
+ Run via `bunx`. No install, no shell-rc edits, no giant base64 blob in `~/.zshrc`.
23
+
24
+ ---
25
+
26
+ ## What lives in the vault
27
+
28
+ Whatever your app needs at runtime that you'd otherwise scatter across Slack DMs, a password manager, or `~/Downloads/`:
29
+
30
+ ```
31
+ infra/vault/
32
+ dev/
33
+ .env.dev # KV secrets — vsync sync ships these to gh/gcp
34
+ gcp-sa.json # JSON service account key
35
+ regression-fixture.json # test data that mirrors prod shape
36
+ tls/cert.pem
37
+ tls/key.pem
38
+ production/
39
+ .env.production
40
+ gcp-sa.json
41
+ ```
42
+
43
+ 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`.
44
+
45
+ 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.
46
+
47
+ 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.
48
+
49
+ ---
50
+
51
+ ## Switching environments — `vsync use`
52
+
53
+ 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:
54
+
55
+ ```bash
56
+ vsync use dev # ./.env → infra/vault/dev/.env.dev
57
+ vsync use production # repoint to infra/vault/production/.env.production
58
+ vsync use # print current target
59
+ ```
60
+
61
+ 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.
62
+
63
+ **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:
64
+
65
+ ```bash
66
+ vsync use dev --link=.env.dev # ./.env.dev → infra/vault/dev/.env.dev
67
+ vsync use prod --link=apps/web/.env # apps/web/.env → … (monorepo)
68
+ ```
69
+
70
+ **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`.
71
+
72
+ **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
73
 
16
74
  ---
17
75
 
18
76
  ## Mental model
19
77
 
20
- Two persistent halves per (repo, env). Both required to push or pull:
78
+ Every (repo, env) is held by **two persistent halves**. Both required to push or pull; either one alone is useless.
21
79
 
22
80
  ```
23
81
  ┌──────────────────────────────────────────────────────────────────┐
24
82
  │ Disk (chmod 0600) │
25
83
  │ ~/.config/vsync/<repo>/env_<env> self-contained config │
26
- │ ├── s3.{endpoint, region, bucket, …} required
27
- │ ├── encryption.salt random per init
84
+ │ ├── s3.{endpoint, region, bucket, …} where to find bytes
85
+ │ ├── encryption.salt PBKDF2 input
28
86
  │ ├── files.vaultFolder optional override │
29
- │ │ (default infra/vault/<env>)│
30
87
  │ └── sync.{gh.repo, gcp.project} set by `vsync sync` │
31
- │ ~/.config/vsync/defaults pre-fills `init` only │
32
88
  └──────────────────────────────────────────────────────────────────┘
33
89
 
34
90
  ┌──────────────────────────────────────────────────────────────────┐
@@ -39,31 +95,9 @@ Two persistent halves per (repo, env). Both required to push or pull:
39
95
  └──────────────────────────────────────────────────────────────────┘
40
96
  ```
41
97
 
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:
59
-
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.
98
+ 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.
65
99
 
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.
100
+ 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
101
 
68
102
  ---
69
103
 
@@ -149,13 +183,15 @@ Every command works fully via flags or fully via prompts.
149
183
 
150
184
  | Cmd | Purpose |
151
185
  |---|---|
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. |
186
+ | `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`. |
187
+ | `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). |
188
+ | `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). |
189
+ | `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. |
190
+ | `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). |
191
+ | `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
192
  | `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
193
  | `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>`. |
194
+ | `audit <env>` | Print the S3-side audit log: who/where/when of every pull/push/import/export. Flags: `--limit=N`, `--all`, `--csv`. |
159
195
  | `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
196
 
161
197
  ### `sync` env-file parsing
@@ -172,6 +208,28 @@ Two local-only keys (skipped — used by `gh` / `gcloud` on the local machine, n
172
208
 
173
209
  Everything else is pushed verbatim.
174
210
 
211
+ ### Audit log
212
+
213
+ 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:
214
+
215
+ - `ts, action, version_ts, hostname, local_ip, os_user, git_email, vsync_version, bun_version, meta`
216
+
217
+ 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).
218
+
219
+ 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:
220
+
221
+ ```bash
222
+ VSYNC_AUDIT_META='{"run_id":"7891234","commit":"abc123"}' \
223
+ vsync pull production --note="prod deploy" --meta ticket=BUG-42
224
+ ```
225
+
226
+ View the log with `vsync audit <env>` (`--limit=N`, `--all`, `--csv`).
227
+
228
+ Two honesty bullets:
229
+
230
+ - This is a transparency aid, not tamper-proof. Anyone with bucket write can rewrite the CSV; vsync just makes honest activity legible.
231
+ - 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.
232
+
175
233
  ---
176
234
 
177
235
  ## How sync works (gh + gcp)
@@ -241,9 +299,11 @@ In practice, just don't lose the keychain entry. `pull` itself is the recovery p
241
299
 
242
300
  ## Versioning
243
301
 
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.
302
+ This is **0.4.0** — adds an append-only audit log at `s3://<bucket>/<repo>/<env>/audit.csv` and the `vsync audit` viewer. Fully additive over 0.3.x: no wire-format break, no config migration. Old clients ignore `audit.csv`; new clients tolerate its absence.
303
+
304
+ 0.3.0 was the rebrand 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
305
 
246
- 0.3.x does not auto-migrate from 0.2.x. The supported upgrade path is to re-`init` from scratch:
306
+ 0.3.x and later do not auto-migrate from 0.2.x. The supported upgrade path is to re-`init` from scratch:
247
307
 
248
308
  ```bash
249
309
  vsync init dev # auto-relocates root .env.dev if it exists
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) {
package/bin/push.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 { zipPaths } from "../src/archive";
16
17
  import { encrypt } from "../src/crypto";
17
18
  import { wrap } from "../src/manifest";
18
19
  import { makeClient } from "../src/s3";
19
20
  import { timestamp } 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 push <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
 
39
49
  const vaultFolder = resolveVaultFolder(cfg, env);
@@ -78,6 +88,51 @@ export async function main(argv: string[]): Promise<void> {
78
88
  } finally {
79
89
  if (existsSync(tmpZip)) unlinkSync(tmpZip);
80
90
  }
91
+
92
+ await tryAppendAudit(cfg.s3, cfgFile?.audit?.enabled, flags, lists, repo, env, ts);
93
+ }
94
+
95
+ /**
96
+ * Best-effort audit append. Honours both the per-(repo, env) opt-out
97
+ * (`cfg.audit.enabled === false`) and the per-invocation `--no-audit`
98
+ * flag. Any throw from the append path is downgraded to a stderr warning
99
+ * so the parent command's exit code is unaffected.
100
+ */
101
+ async function tryAppendAudit(
102
+ s3: Parameters<typeof makeAuditClient>[0],
103
+ enabled: boolean | undefined,
104
+ flags: Record<string, string>,
105
+ lists: Record<string, string[]>,
106
+ repo: string,
107
+ env: string,
108
+ versionTs: string,
109
+ ): Promise<void> {
110
+ if (flags["no-audit"] === "true") return;
111
+ const on = enabled ?? DEFAULT_AUDIT_ENABLED;
112
+ if (!on) return;
113
+
114
+ let meta;
115
+ try {
116
+ meta = buildMeta({
117
+ envMeta: process.env.VSYNC_AUDIT_META,
118
+ envNote: process.env.VSYNC_AUDIT_NOTE,
119
+ flagMetaList: lists.meta,
120
+ flagNote: flags.note,
121
+ });
122
+ } catch (e) {
123
+ console.error((e as Error).message);
124
+ process.exit(1);
125
+ }
126
+ for (const w of meta.warnings) console.error(w);
127
+
128
+ try {
129
+ const row = await gatherRowMetadata("push", versionTs);
130
+ row.meta = meta.json;
131
+ const client = makeAuditClient(s3);
132
+ await appendAuditRow(client, repo, env, row);
133
+ } catch (e) {
134
+ console.error(`warning: failed to record audit entry: ${(e as Error).message}`);
135
+ }
81
136
  }
82
137
 
83
138
  if (import.meta.main) {