@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 +102 -42
- 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 +1 -1
- 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,90 @@
|
|
|
1
1
|
# vsync
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**Your `.env` files — kept as simple, made as safe as a vault.**
|
|
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
|
bunx @muthuishere/vsync --help
|
|
12
20
|
```
|
|
13
21
|
|
|
14
|
-
No shell-rc edits
|
|
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
|
-
|
|
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, …}
|
|
27
|
-
│ ├── encryption.salt
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
| `
|
|
156
|
-
| `
|
|
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 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). |
|
|
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.
|
|
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
|
|
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) {
|