@muthuishere/vsync 0.5.0 → 0.6.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,8 +1,8 @@
1
1
  # vsync
2
2
 
3
- **Your `.env` files kept as simple, made as safe as a vault.**
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
- ![vsync flow](docs/vsync-flow.png)
5
+ ![vsync flow](https://raw.githubusercontent.com/muthuishere/vsync/main/docs/public/vsync-flow.png)
6
6
 
7
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
8
 
@@ -16,10 +16,11 @@ vsync keeps the `.env` you already write, and turns it into a real vault:
16
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.
17
17
 
18
18
  ```bash
19
- bunx @muthuishere/vsync --help
19
+ bun install -g @muthuishere/vsync # or: npm install -g @muthuishere/vsync
20
+ vsync --help
20
21
  ```
21
22
 
22
- Run via `bunx`. No install, no shell-rc edits, no giant base64 blob in `~/.zshrc`.
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.)
23
24
 
24
25
  ---
25
26
 
@@ -103,13 +104,16 @@ A `.share` file bundles **both halves** under one passphrase. Sent on a differen
103
104
 
104
105
  ## Install
105
106
 
106
- You don't. Run via `bunx`:
107
-
108
107
  ```bash
109
- bunx @muthuishere/vsync <subcommand>
108
+ bun install -g @muthuishere/vsync # or: npm install -g @muthuishere/vsync
109
+ vsync --help
110
110
  ```
111
111
 
112
- Requires Bun ≥ 1.2.21 (for `Bun.secrets`). For local development of vsync itself:
112
+ Requires Bun ≥ 1.2.21 on PATH (for `Bun.secrets`) — the shebang is `#!/usr/bin/env bun`, so `bun` must be installed even if you used `npm install -g` for the package itself. Most users have Bun anyway; if not, see [bun.sh](https://bun.sh).
113
+
114
+ Don't want to install? `bunx @muthuishere/vsync <subcommand>` runs the same code from npm cache each time — fine for trying it out, slower for daily use.
115
+
116
+ For local development of vsync itself:
113
117
 
114
118
  ```bash
115
119
  git clone git@github.com:muthuishere/vsync.git
@@ -125,14 +129,14 @@ bun test
125
129
  ```bash
126
130
  # 1. Generate the per-(repo, env) key + config. First-ever invocation prompts
127
131
  # for S3 creds; subsequent inits pre-fill from ~/.config/vsync/defaults.
128
- bunx @muthuishere/vsync init dev
132
+ vsync init dev
129
133
 
130
134
  # 2. Put your secrets under infra/vault/dev/ and push.
131
135
  echo "DATABASE_URL=postgres://..." > infra/vault/dev/.env.dev
132
- bunx @muthuishere/vsync push dev
136
+ vsync push dev
133
137
 
134
138
  # 3. Hand the team a share file + passphrase (different channels).
135
- bunx @muthuishere/vsync export dev
139
+ vsync export dev
136
140
  ```
137
141
 
138
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.
@@ -144,11 +148,11 @@ cd <cloned-repo>
144
148
 
145
149
  # 1. Import the share file your teammate sent (carries S3 creds + key).
146
150
  # No prior `init` required on this machine.
147
- bunx @muthuishere/vsync import dev ./reqsume-dev.share
151
+ vsync import dev ./reqsume-dev.share
148
152
  # Passphrase: <paste>
149
153
 
150
154
  # 2. Pull the encrypted bundle.
151
- bunx @muthuishere/vsync pull dev
155
+ vsync pull dev
152
156
  ```
153
157
 
154
158
  After step 2, `infra/vault/dev/` is populated and the encryption key is in your keychain.
@@ -157,18 +161,18 @@ After step 2, `infra/vault/dev/` is populated and the encryption key is in your
157
161
 
158
162
  ```bash
159
163
  # I edited infra/vault/dev/.env.dev locally:
160
- bunx @muthuishere/vsync push dev
164
+ vsync push dev
161
165
 
162
166
  # Get the latest from S3:
163
- bunx @muthuishere/vsync pull dev
167
+ vsync pull dev
164
168
 
165
169
  # See what versions exist on S3:
166
- bunx @muthuishere/vsync versions dev
170
+ vsync versions dev
167
171
 
168
172
  # Push secrets out to GitHub / GCP:
169
- bunx @muthuishere/vsync sync dev gh
170
- bunx @muthuishere/vsync sync dev gcp
171
- bunx @muthuishere/vsync sync dev all
173
+ vsync sync dev gh
174
+ vsync sync dev gcp
175
+ vsync sync dev all
172
176
  ```
173
177
 
174
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.
@@ -196,17 +200,24 @@ Every command works fully via flags or fully via prompts.
196
200
 
197
201
  ### `sync` env-file parsing
198
202
 
199
- Two special-case keys (path file content inlining):
203
+ **File references `*_PATH` / `*_FILE`.** Any key in `.env.<env>` whose name ends in `_PATH` or `_FILE` is read from disk; vsync pushes the file's contents under the key with the suffix stripped. Examples:
204
+
205
+ - `SSH_PRIVATE_KEY_PATH=keys/reqsume_dev` → pushes `<vault>/keys/reqsume_dev` as `SSH_PRIVATE_KEY`.
206
+ - `GCP_SA_KEY_FILE=keys/sa.json` → pushes `<vault>/keys/sa.json` as `GCP_SA_KEY`.
200
207
 
201
- - `GCP_SA_KEY_FILE_PATH=<path>` reads the file, pushes the contents as `GCP_SA_KEY` (must look like JSON).
202
- - `SSH_KEY_PATH=<path>` → reads the file, pushes as `SSH_PRIVATE_KEY`.
208
+ Relative paths anchor to `VAULT_ROOT` (the directory of the env file being parsed). Placeholders `${VAULT_ROOT}`, `${HOME}`, and leading `~/` are expanded in every value. Any missing or unreadable referenced file aborts the whole sync before any push (all-or-none).
203
209
 
204
210
  Two local-only keys (skipped — used by `gh` / `gcloud` on the local machine, not pushed):
205
211
 
206
212
  - `GITHUB_TOKEN`
207
213
  - `GOOGLE_APPLICATION_CREDENTIALS`
208
214
 
209
- Everything else is pushed verbatim.
215
+ Two routing keys (consumed by `vsync sync` itself, never pushed):
216
+
217
+ - `GITHUB_REPO`
218
+ - `GCP_PROJECT_ID`
219
+
220
+ Everything else is pushed verbatim. Full convention in [`docs/guide/sync.md`](docs/guide/sync.md) and design context in [`docs/specs/v0.6-vault-relative-file-refs.md`](docs/specs/v0.6-vault-relative-file-refs.md).
210
221
 
211
222
  ### Audit log
212
223
 
@@ -238,7 +249,7 @@ Auth is **outside vsync's scope** — the lib trusts whatever `gh` and `gcloud`
238
249
 
239
250
  **`vsync sync <env> gh`:**
240
251
  1. Resolves `sync.gh.repo` from per-repo config (or `--gh-repo` flag, or first-run prompt).
241
- 2. Parses `<vaultFolder>/.env.<env>` into push-ready KVs (after special-case + skip rules).
252
+ 2. Parses `<vaultFolder>/.env.<env>` into push-ready KVs (file-ref + skip rules below).
242
253
  3. For each KV in a 6-worker pool: `gh secret set <KEY> --env <env> --repo <sync.gh.repo>` with the value on stdin.
243
254
  4. Requires `gh` CLI installed and `gh auth login` already done.
244
255
 
@@ -248,7 +259,33 @@ Auth is **outside vsync's scope** — the lib trusts whatever `gh` and `gcloud`
248
259
  3. For each KV: `gcloud secrets describe <KEY> --project=<proj>` to check existence; either `gcloud secrets versions add <KEY>` (exists) or `gcloud secrets create <KEY> --replication-policy=automatic` (new). Value on stdin via `--data-file=-`.
249
260
  4. Requires `gcloud` CLI installed and `gcloud auth login` done. Per-env isolation comes from per-env GCP projects (dev project ≠ prod project) — secret names are flat within a project.
250
261
 
251
- **`vsync sync <env> all`** runs both in sequence. Failures don't abort siblings; final summary lists what failed.
262
+ **`vsync sync <env> all`** runs both in sequence. Per-secret push failures don't abort siblings; final summary lists what failed. Parse-time failures (see "all-or-none" below) abort everything before any push.
263
+
264
+ ### File-reference convention (`.env.<env>`)
265
+
266
+ Vsync reads each line of `.env.<env>` and pushes a KV to gh/gcp. Some keys carry **file paths** instead of literal values — vsync reads the file and pushes its bytes:
267
+
268
+ | In env file | Pushed as | Notes |
269
+ |---|---|---|
270
+ | `FOO_PATH=keys/foo` | `FOO` = file contents | suffix `_PATH` stripped; file resolved vault-relative |
271
+ | `FOO_FILE=keys/foo` | `FOO` = file contents | suffix `_FILE` stripped; same resolution |
272
+ | `SSH_PRIVATE_KEY_PATH=keys/dev` | `SSH_PRIVATE_KEY` = file contents | name the env-file key after the secret you want |
273
+ | `GITHUB_TOKEN=…` | *skipped* | local-only |
274
+ | `GOOGLE_APPLICATION_CREDENTIALS=…` | *skipped* | local-only |
275
+ | `GITHUB_REPO=…`, `GCP_PROJECT_ID=…` | *routing meta* — never pushed | consumed by sync itself |
276
+
277
+ **Path resolution.** Relative paths anchor to `VAULT_ROOT` (the directory of the env file being parsed). Three forms of placeholder expansion are recognised in **every** value — file-ref or plain:
278
+
279
+ | Form | Means |
280
+ |---|---|
281
+ | `${VAULT_ROOT}/keys/foo` | `<vault>/keys/foo` (explicit) |
282
+ | `keys/foo` or `./keys/foo` | `<vault>/keys/foo` (implicit — no placeholder needed) |
283
+ | `~/.ssh/id_rsa` or `${HOME}/.ssh/id_rsa` | `$HOME/.ssh/id_rsa` |
284
+ | `/abs/path` | absolute, pass-through |
285
+
286
+ **All-or-none on file refs.** If any `*_PATH` / `*_FILE` references a missing or unreadable file, vsync collects every such error and aborts before pushing anything. No partial syncs.
287
+
288
+ The canonical short-form reference lives in the header comment of [`src/envfile.ts`](src/envfile.ts); design context is in [`docs/specs/v0.6-vault-relative-file-refs.md`](docs/specs/v0.6-vault-relative-file-refs.md).
252
289
 
253
290
  ---
254
291
 
@@ -299,19 +336,13 @@ In practice, just don't lose the keychain entry. `pull` itself is the recovery p
299
336
 
300
337
  ## Versioning
301
338
 
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.
305
-
306
- 0.3.x and later do not auto-migrate from 0.2.x. The supported upgrade path is to re-`init` from scratch:
307
-
308
- ```bash
309
- vsync init dev # auto-relocates root .env.dev if it exists
310
- vsync push dev
311
- vsync export dev # re-share with team
312
- ```
339
+ | Release | What's in it |
340
+ |---|---|
341
+ | **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. |
342
+ | 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. |
343
+ | 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. |
313
344
 
314
- Any leftover 0.2.x on-disk config tree and keychain entries can be deleted; nothing in 0.3.x reads them. `@muthuishere/secret-lib` 0.2.x stays on npm for users who can't migrate.
345
+ 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.
315
346
 
316
347
  ---
317
348
 
package/bin/sync.ts CHANGED
@@ -8,10 +8,13 @@
8
8
  // cfg.sync.gcp.project), NOT in the .env file. First run prompts for
9
9
  // missing routing and saves it; subsequent runs are zero-prompt.
10
10
  //
11
- // Path-expansion + skip rules in src/envfile.ts:
12
- // - GCP_SA_KEY_FILE_PATH GCP_SA_KEY (file content)
13
- // - SSH_KEY_PATHSSH_PRIVATE_KEY (file content)
11
+ // File-reference + skip rules in src/envfile.ts (canonical short-form ref in
12
+ // that file's header comment; design spec in docs/specs/v0.6-vault-relative-file-refs.md):
13
+ // - *_PATH / *_FILE strip suffix, push file contents under stripped name
14
14
  // - GITHUB_TOKEN, GOOGLE_APPLICATION_CREDENTIALS skipped (local-only)
15
+ // - Path resolution anchors to VAULT_ROOT (dir of the .env.<env> being parsed);
16
+ // ${VAULT_ROOT} / ${HOME} / leading ~/ are expanded in every value.
17
+ // - Any missing referenced file aborts the whole sync before any push.
15
18
 
16
19
  import { join } from "node:path";
17
20
  import { parseArgs } from "../src/argv";
package/bin/vsync.ts CHANGED
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@muthuishere/vsync",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Encrypted secret-sync CLI for small teams. Self-contained per-(repo, env) config + OS keychain key + AES-GCM-on-S3 + share-file onboarding + fanout to GitHub/GCP. Bun-native, run via bunx.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -12,7 +12,10 @@
12
12
  "README.md"
13
13
  ],
14
14
  "scripts": {
15
- "test": "bun test"
15
+ "test": "bun test",
16
+ "docs:dev": "vitepress dev docs",
17
+ "docs:build": "vitepress build docs",
18
+ "docs:preview": "vitepress preview docs"
16
19
  },
17
20
  "keywords": [
18
21
  "secrets",
@@ -41,6 +44,7 @@
41
44
  },
42
45
  "license": "MIT",
43
46
  "devDependencies": {
44
- "@types/bun": "latest"
47
+ "@types/bun": "latest",
48
+ "vitepress": "^1.6.4"
45
49
  }
46
50
  }
package/src/envfile.ts CHANGED
@@ -1,19 +1,32 @@
1
1
  // Parse a `.env.<ENV>` file into push-ready secret tasks.
2
2
  //
3
- // Mirrors the parsing behavior of reqsume/secrets.go:
3
+ // Behavior overview:
4
4
  // - skip blank lines + `#` comments
5
5
  // - first `=` splits key/value, both trimmed
6
6
  // - strip a single pair of surrounding `"` or `'` from the value
7
7
  // - skip GITHUB_TOKEN / GOOGLE_APPLICATION_CREDENTIALS (local-only)
8
- // - GCP_SA_KEY_FILE_PATH=<path> → reads file, pushes as GCP_SA_KEY (must look like JSON)
9
- // - SSH_KEY_PATH=<path> → reads file, pushes as SSH_PRIVATE_KEY
8
+ // - placeholder expansion in every value: `${VAULT_ROOT}`, `${HOME}`,
9
+ // leading `~/`. `VAULT_ROOT` = the directory the env file lives in.
10
+ //
11
+ // File-reference convention (vsync reads the file, pushes its contents
12
+ // under the stripped key name):
13
+ //
14
+ // FOO_PATH=keys/foo -> push as FOO with contents of <vault>/keys/foo
15
+ // FOO_FILE=./keys/foo -> push as FOO with contents of <vault>/keys/foo
16
+ //
17
+ // Relative paths are always resolved against `VAULT_ROOT` (i.e. the env
18
+ // file's own directory). Absolute paths and `~/` are honored as-is.
19
+ //
20
+ // All-or-none: if any file referenced by a `*_PATH`/`*_FILE` key is missing
21
+ // or unreadable, parseEnvFile throws a single aggregated error and emits no
22
+ // tasks. Sync must not run partially.
10
23
  //
11
24
  // GITHUB_REPO and GCP_PROJECT_ID are pulled out into `meta` and never pushed
12
25
  // as secrets — they're routing config consumed by sync-secrets itself.
13
26
 
14
27
  import { existsSync, readFileSync } from "node:fs";
15
28
  import { homedir } from "node:os";
16
- import { join } from "node:path";
29
+ import { dirname, isAbsolute, join } from "node:path";
17
30
 
18
31
  export type SecretTask = { key: string; value: string };
19
32
 
@@ -25,13 +38,17 @@ export type ParsedEnv = {
25
38
  const LOCAL_ONLY = new Set(["GITHUB_TOKEN", "GOOGLE_APPLICATION_CREDENTIALS"]);
26
39
  const ROUTING = new Set(["GITHUB_REPO", "GCP_PROJECT_ID"]);
27
40
 
41
+ const PATH_SUFFIXES = ["_PATH", "_FILE"] as const;
42
+
28
43
  export function parseEnvFile(path: string): ParsedEnv {
29
44
  if (!existsSync(path)) {
30
45
  throw new Error(`.env file not found: ${path}`);
31
46
  }
32
47
  const raw = readFileSync(path, "utf8");
48
+ const vaultRoot = dirname(path);
33
49
  const tasks: SecretTask[] = [];
34
50
  const meta: ParsedEnv["meta"] = {};
51
+ const errors: string[] = [];
35
52
 
36
53
  for (const rawLine of raw.split(/\r?\n/)) {
37
54
  const line = rawLine.trim();
@@ -41,41 +58,39 @@ export function parseEnvFile(path: string): ParsedEnv {
41
58
  if (eq === -1) continue;
42
59
 
43
60
  const key = line.slice(0, eq).trim();
44
- let value = stripQuotes(line.slice(eq + 1).trim());
61
+ const rawValue = stripQuotes(line.slice(eq + 1).trim());
45
62
 
46
63
  if (LOCAL_ONLY.has(key)) {
47
64
  console.log(`Skipping ${key} (local use only)`);
48
65
  continue;
49
66
  }
50
67
 
68
+ const value = expandPlaceholders(rawValue, vaultRoot);
69
+
51
70
  if (ROUTING.has(key)) {
52
71
  meta[key as keyof ParsedEnv["meta"]] = value;
53
72
  continue;
54
73
  }
55
74
 
56
- if (key === "GCP_SA_KEY_FILE_PATH") {
57
- const content = readFileExpandTilde(value).trim();
58
- if (!content.startsWith("{")) {
59
- throw new Error(`GCP key file does not look like JSON: ${value}`);
60
- }
61
- tasks.push({ key: "GCP_SA_KEY", value: content });
62
- continue;
63
- }
64
-
65
- if (key === "SSH_KEY_PATH") {
66
- try {
67
- tasks.push({ key: "SSH_PRIVATE_KEY", value: readFileExpandTilde(value) });
68
- } catch (e) {
69
- console.warn(
70
- `Warning: error reading SSH private key from ${value}: ${(e as Error).message}`,
71
- );
72
- }
75
+ // Generic suffix rule: *_PATH or *_FILE → strip suffix, push file body.
76
+ const stripped = stripPathSuffix(key);
77
+ if (stripped) {
78
+ readFileRef(value, vaultRoot, key, errors, (content) => {
79
+ tasks.push({ key: stripped, value: content });
80
+ });
73
81
  continue;
74
82
  }
75
83
 
84
+ // Plain value.
76
85
  tasks.push({ key, value });
77
86
  }
78
87
 
88
+ if (errors.length > 0) {
89
+ throw new Error(
90
+ `parseEnvFile: aborting sync — ${errors.length} file reference(s) could not be resolved:\n - ${errors.join("\n - ")}`,
91
+ );
92
+ }
93
+
79
94
  return { tasks, meta };
80
95
  }
81
96
 
@@ -89,7 +104,38 @@ function stripQuotes(value: string): string {
89
104
  return value;
90
105
  }
91
106
 
92
- function readFileExpandTilde(path: string): string {
93
- if (path.startsWith("~/")) path = join(homedir(), path.slice(2));
94
- return readFileSync(path, "utf8");
107
+ function expandPlaceholders(value: string, vaultRoot: string): string {
108
+ let out = value;
109
+ if (out.startsWith("~/")) out = join(homedir(), out.slice(2));
110
+ out = out.replaceAll("${VAULT_ROOT}", vaultRoot);
111
+ out = out.replaceAll("${HOME}", homedir());
112
+ return out;
113
+ }
114
+
115
+ function stripPathSuffix(key: string): string | null {
116
+ for (const suf of PATH_SUFFIXES) {
117
+ if (key.endsWith(suf) && key.length > suf.length) {
118
+ return key.slice(0, -suf.length);
119
+ }
120
+ }
121
+ return null;
122
+ }
123
+
124
+ function readFileRef(
125
+ value: string,
126
+ vaultRoot: string,
127
+ key: string,
128
+ errors: string[],
129
+ onSuccess: (content: string) => void,
130
+ ): void {
131
+ const resolved = isAbsolute(value) ? value : join(vaultRoot, value);
132
+ if (!existsSync(resolved)) {
133
+ errors.push(`${key}: file not found at ${resolved}`);
134
+ return;
135
+ }
136
+ try {
137
+ onSuccess(readFileSync(resolved, "utf8"));
138
+ } catch (e) {
139
+ errors.push(`${key}: error reading ${resolved}: ${(e as Error).message}`);
140
+ }
95
141
  }