@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 +124 -66
- package/bin/audit.ts +73 -0
- package/bin/export.ts +52 -2
- package/bin/import.ts +52 -2
- package/bin/init.ts +26 -0
- package/bin/pull.ts +56 -1
- package/bin/push.ts +56 -1
- package/bin/use.ts +175 -0
- package/bin/vsync.ts +19 -0
- package/package.json +7 -3
- package/src/argv.ts +15 -3
- package/src/audit.ts +752 -0
- package/src/repoconfig.ts +19 -0
- package/src/templates/docs.md.ts +27 -0
package/README.md
CHANGED
|
@@ -1,34 +1,91 @@
|
|
|
1
1
|
# vsync
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
- **
|
|
8
|
-
|
|
5
|
+

|
|
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
|
+
bun install -g @muthuishere/vsync # or: npm install -g @muthuishere/vsync
|
|
20
|
+
vsync --help
|
|
12
21
|
```
|
|
13
22
|
|
|
14
|
-
No shell-rc edits
|
|
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
|
-
|
|
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, …}
|
|
27
|
-
│ ├── encryption.salt
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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`).
|
|
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
|
-
|
|
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
|
-
|
|
136
|
+
vsync push dev
|
|
99
137
|
|
|
100
138
|
# 3. Hand the team a share file + passphrase (different channels).
|
|
101
|
-
|
|
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
|
-
|
|
151
|
+
vsync import dev ./reqsume-dev.share
|
|
114
152
|
# Passphrase: <paste>
|
|
115
153
|
|
|
116
154
|
# 2. Pull the encrypted bundle.
|
|
117
|
-
|
|
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
|
-
|
|
164
|
+
vsync push dev
|
|
127
165
|
|
|
128
166
|
# Get the latest from S3:
|
|
129
|
-
|
|
167
|
+
vsync pull dev
|
|
130
168
|
|
|
131
169
|
# See what versions exist on S3:
|
|
132
|
-
|
|
170
|
+
vsync versions dev
|
|
133
171
|
|
|
134
172
|
# Push secrets out to GitHub / GCP:
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
| `
|
|
156
|
-
| `
|
|
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 encrypt → upload 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
|
-
|
|
245
|
-
|
|
246
|
-
0.
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
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) {
|