@muthuishere/vsync 0.5.1 → 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
@@ -200,17 +200,24 @@ Every command works fully via flags or fully via prompts.
200
200
 
201
201
  ### `sync` env-file parsing
202
202
 
203
- 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
204
 
205
- - `GCP_SA_KEY_FILE_PATH=<path>`reads the file, pushes the contents as `GCP_SA_KEY` (must look like JSON).
206
- - `SSH_KEY_PATH=<path>`reads the file, pushes as `SSH_PRIVATE_KEY`.
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`.
207
+
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).
207
209
 
208
210
  Two local-only keys (skipped — used by `gh` / `gcloud` on the local machine, not pushed):
209
211
 
210
212
  - `GITHUB_TOKEN`
211
213
  - `GOOGLE_APPLICATION_CREDENTIALS`
212
214
 
213
- 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).
214
221
 
215
222
  ### Audit log
216
223
 
@@ -242,7 +249,7 @@ Auth is **outside vsync's scope** — the lib trusts whatever `gh` and `gcloud`
242
249
 
243
250
  **`vsync sync <env> gh`:**
244
251
  1. Resolves `sync.gh.repo` from per-repo config (or `--gh-repo` flag, or first-run prompt).
245
- 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).
246
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.
247
254
  4. Requires `gh` CLI installed and `gh auth login` already done.
248
255
 
@@ -252,7 +259,33 @@ Auth is **outside vsync's scope** — the lib trusts whatever `gh` and `gcloud`
252
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=-`.
253
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.
254
261
 
255
- **`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).
256
289
 
257
290
  ---
258
291
 
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@muthuishere/vsync",
3
- "version": "0.5.1",
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": {
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
  }