@muthuishere/vsync 0.5.1 → 0.7.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
@@ -194,23 +194,47 @@ Every command works fully via flags or fully via prompts.
194
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
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). |
196
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. |
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>`. |
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. Parser has no defaults — pass `--inline-file-suffix=<suf>` and `--exclude-property=<key>` (both repeatable) to control file inlining and excluded keys; see "Typical `vsync sync` invocation" below. Flags: `--inline-file-suffix=<suf>`, `--exclude-property=<key>`, `--gh-repo=<owner/name>`, `--gcp-project=<id>`. |
198
198
  | `audit <env>` | Print the S3-side audit log: who/where/when of every pull/push/import/export. Flags: `--limit=N`, `--all`, `--csv`. |
199
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`. |
200
200
 
201
201
  ### `sync` env-file parsing
202
202
 
203
- Two special-case keys (path file content inlining):
203
+ As of v0.7 the parser has **zero implicit policy** — no hardcoded suffixes, no hardcoded exclude list, no defaults applied by the CLI. Every rule is named at the call site.
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
+ **File references — opt in with `--inline-file-suffix=<suf>` (repeatable).** Any key in `.env.<env>` whose name ends in a configured suffix is read from disk; vsync pushes the file's contents under the key with the suffix stripped. Pass `--inline-file-suffix=_PATH --inline-file-suffix=_FILE` for the v0.6 shape:
207
206
 
208
- Two local-only keys (skipped used by `gh` / `gcloud` on the local machine, not pushed):
207
+ - `SSH_PRIVATE_KEY_PATH=keys/reqsume_dev` pushes `<vault>/keys/reqsume_dev` as `SSH_PRIVATE_KEY`.
208
+ - `GCP_SA_KEY_FILE=keys/sa.json` → pushes `<vault>/keys/sa.json` as `GCP_SA_KEY`.
209
+
210
+ 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).
211
+
212
+ **Excluded keys — opt in with `--exclude-property=<key>` (repeatable).** Any key in this list is dropped from the run and never pushed. Typical candidates are tokens that exist locally for `gh` / `gcloud` to use directly:
209
213
 
210
214
  - `GITHUB_TOKEN`
211
215
  - `GOOGLE_APPLICATION_CREDENTIALS`
212
216
 
213
- Everything else is pushed verbatim.
217
+ If you pass no `--exclude-property` flag at all, **nothing is skipped** — every KV gets pushed. Same for `--inline-file-suffix`: with no flag, no file inlining happens.
218
+
219
+ Every `vsync sync` run prints the active parser policy header before the first push, so you can see exactly which rules were in effect. Full convention in [`docs/guide/sync.md`](docs/guide/sync.md) and design context in [`docs/specs/v0.7-explicit-sync-parser.md`](docs/specs/v0.7-explicit-sync-parser.md).
220
+
221
+ ### Typical `vsync sync` invocation
222
+
223
+ The parser has no defaults. Pass the rules you want, every time. The shape
224
+ that matches v0.6 behavior is:
225
+
226
+ ```bash
227
+ vsync sync dev gh \
228
+ --inline-file-suffix=_PATH \
229
+ --inline-file-suffix=_FILE \
230
+ --exclude-property=GITHUB_TOKEN \
231
+ --exclude-property=GOOGLE_APPLICATION_CREDENTIALS
232
+ ```
233
+
234
+ Drop this into your Taskfile / Makefile / CI so the call site shows the
235
+ whole policy at a glance.
236
+
237
+ **Migrating from v0.6?** Bare `vsync sync dev gh` no longer skips `GITHUB_TOKEN` / `GOOGLE_APPLICATION_CREDENTIALS` and no longer inlines `*_PATH` / `*_FILE`. Append the four flags above to every invocation to preserve the old behavior. The in-env routing keys `GITHUB_REPO` / `GCP_PROJECT_ID` are also no longer recognized — move them into config via `--gh-repo` / `--gcp-project` (persisted) and delete the lines from `.env.<env>`. See [`docs/specs/v0.7-explicit-sync-parser.md`](docs/specs/v0.7-explicit-sync-parser.md) §5 for full migration steps.
214
238
 
215
239
  ### Audit log
216
240
 
@@ -242,7 +266,7 @@ Auth is **outside vsync's scope** — the lib trusts whatever `gh` and `gcloud`
242
266
 
243
267
  **`vsync sync <env> gh`:**
244
268
  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).
269
+ 2. Parses `<vaultFolder>/.env.<env>` into push-ready KVs (using the `--inline-file-suffix` / `--exclude-property` flags you passed — see below).
246
270
  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
271
  4. Requires `gh` CLI installed and `gh auth login` already done.
248
272
 
@@ -252,7 +276,38 @@ Auth is **outside vsync's scope** — the lib trusts whatever `gh` and `gcloud`
252
276
  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
277
  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
278
 
255
- **`vsync sync <env> all`** runs both in sequence. Failures don't abort siblings; final summary lists what failed.
279
+ **`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.
280
+
281
+ Every run also prints the active parser policy header before the first push, so the operator can see which suffixes and exclusions were in effect for this invocation.
282
+
283
+ ### File-reference convention (`.env.<env>`) — explicit opt-in
284
+
285
+ Vsync reads each line of `.env.<env>` and pushes a KV to gh/gcp. When you pass `--inline-file-suffix=<suffix>`, keys ending in that suffix are treated as file paths — vsync reads the file and pushes its bytes under the stripped name.
286
+
287
+ With `--inline-file-suffix=_PATH --inline-file-suffix=_FILE` in effect:
288
+
289
+ | In env file | Pushed as | Notes |
290
+ |---|---|---|
291
+ | `FOO_PATH=keys/foo` | `FOO` = file contents | suffix `_PATH` stripped; file resolved vault-relative |
292
+ | `FOO_FILE=keys/foo` | `FOO` = file contents | suffix `_FILE` stripped; same resolution |
293
+ | `SSH_PRIVATE_KEY_PATH=keys/dev` | `SSH_PRIVATE_KEY` = file contents | name the env-file key after the secret you want |
294
+
295
+ With no `--inline-file-suffix` flag, no inlining happens — `FOO_PATH=keys/foo` is pushed as the literal string `keys/foo`.
296
+
297
+ With `--exclude-property=GITHUB_TOKEN --exclude-property=GOOGLE_APPLICATION_CREDENTIALS` in effect, those keys are dropped from the run. With no `--exclude-property` flag, nothing is skipped.
298
+
299
+ **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:
300
+
301
+ | Form | Means |
302
+ |---|---|
303
+ | `${VAULT_ROOT}/keys/foo` | `<vault>/keys/foo` (explicit) |
304
+ | `keys/foo` or `./keys/foo` | `<vault>/keys/foo` (implicit — no placeholder needed) |
305
+ | `~/.ssh/id_rsa` or `${HOME}/.ssh/id_rsa` | `$HOME/.ssh/id_rsa` |
306
+ | `/abs/path` | absolute, pass-through |
307
+
308
+ **All-or-none on file refs.** If any configured file-ref references a missing or unreadable file, vsync collects every such error and aborts before pushing anything. No partial syncs.
309
+
310
+ The canonical short-form reference lives in the header comment of [`src/envfile.ts`](src/envfile.ts); design context is in [`docs/specs/v0.7-explicit-sync-parser.md`](docs/specs/v0.7-explicit-sync-parser.md).
256
311
 
257
312
  ---
258
313
 
@@ -305,7 +360,9 @@ In practice, just don't lose the keychain entry. `pull` itself is the recovery p
305
360
 
306
361
  | Release | What's in it |
307
362
  |---|---|
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. |
363
+ | **0.7.0** | `vsync sync` parser has zero implicit policy. New repeatable flags `--inline-file-suffix=<suf>` and `--exclude-property=<key>` replace the old hardcoded `_PATH` / `_FILE` suffixes and the implicit `GITHUB_TOKEN` / `GOOGLE_APPLICATION_CREDENTIALS` skip-list — name every rule at the call site. In-env routing keys `GITHUB_REPO` / `GCP_PROJECT_ID` are no longer recognized (routing lives in config only). Every run prints the active policy header before pushing. Two intentional breaks vs. 0.6.x — see [`docs/specs/v0.7-explicit-sync-parser.md`](docs/specs/v0.7-explicit-sync-parser.md) §5. |
364
+ | 0.6.0 | `.env.<env>` file-reference convention: any key ending in `_PATH` / `_FILE` is read from disk and the file's contents are pushed under the stripped name. Paths anchor to `VAULT_ROOT`; `${VAULT_ROOT}` / `${HOME}` / `~/` placeholders work in every value. All-or-none on missing files. (Superseded in 0.7 — suffixes are no longer hardcoded; pass `--inline-file-suffix` explicitly.) |
365
+ | 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
366
  | 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
367
  | 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. |
311
368
 
package/bin/sync.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  #!/usr/bin/env bun
2
- // Usage: vsync sync <env> <gh|gcp|all> [--gh-repo=<owner/name>] [--gcp-project=<id>]
2
+ // Usage: vsync sync <env> <gh|gcp|all>
3
+ // [--inline-file-suffix=<suf>] (repeatable)
4
+ // [--exclude-property=<key>] (repeatable)
5
+ // [--gh-repo=<owner/name>] [--gcp-project=<id>] [--repo=<name>]
3
6
  //
4
7
  // Reads <vaultFolder>/.env.<env> and pushes each variable to the named
5
8
  // secret backend, in parallel (6 workers, 10-min overall timeout).
@@ -8,10 +11,17 @@
8
11
  // cfg.sync.gcp.project), NOT in the .env file. First run prompts for
9
12
  // missing routing and saves it; subsequent runs are zero-prompt.
10
13
  //
11
- // Path-expansion + skip rules in src/envfile.ts:
12
- // - GCP_SA_KEY_FILE_PATH → GCP_SA_KEY (file content)
13
- // - SSH_KEY_PATH SSH_PRIVATE_KEY (file content)
14
- // - GITHUB_TOKEN, GOOGLE_APPLICATION_CREDENTIALS skipped (local-only)
14
+ // Parser policy is explicit per-invocation (v0.7 zero defaults; see
15
+ // docs/specs/v0.7-explicit-sync-parser.md):
16
+ // - --inline-file-suffix=<suf> keys ending in <suf> are file refs; the
17
+ // suffix is stripped and the file's bytes
18
+ // are pushed under the stripped name.
19
+ // Repeatable. Omit = no file inlining.
20
+ // - --exclude-property=<key> keys to drop (never pushed). Repeatable.
21
+ // Omit = nothing skipped.
22
+ // - Path resolution anchors to VAULT_ROOT (dir of the .env.<env> being parsed);
23
+ // ${VAULT_ROOT} / ${HOME} / leading ~/ are expanded in every value.
24
+ // - Any missing referenced file aborts the whole sync before any push.
15
25
 
16
26
  import { join } from "node:path";
17
27
  import { parseArgs } from "../src/argv";
@@ -33,22 +43,18 @@ const WORKERS = 6;
33
43
  const TIMEOUT_MS = 10 * 60 * 1000;
34
44
 
35
45
  export async function main(argv: string[]): Promise<void> {
36
- const { positional, flags } = parseArgs(argv);
46
+ const { positional, flags, lists } = parseArgs(argv);
37
47
  const env = positional[0];
38
48
  const target = positional[1] as Target | undefined;
39
49
 
40
50
  if (!env || !target || !TARGETS.includes(target)) {
41
- console.error("usage: vsync sync <env> <gh|gcp|all>");
42
- console.error("");
43
- console.error(" env environment name; reads <vaultFolder>/.env.<env>");
44
- console.error(" gh push to GitHub repo secrets (env = <env>)");
45
- console.error(" gcp push to GCP Secret Manager (project from cfg.sync.gcp.project)");
46
- console.error(" all push to every configured target");
47
- console.error("");
48
- console.error("Flags: --gh-repo=<owner/name>, --gcp-project=<id>, --repo=<name>");
51
+ usage();
49
52
  process.exit(1);
50
53
  }
51
54
 
55
+ const inlineFileSuffixes = lists["inline-file-suffix"] ?? [];
56
+ const excludeProperties = lists["exclude-property"] ?? [];
57
+
52
58
  const repo = await getRepoName({ override: flags.repo });
53
59
  const root = await getRepoRoot();
54
60
 
@@ -63,15 +69,20 @@ export async function main(argv: string[]): Promise<void> {
63
69
  const vaultFolder = resolveVaultFolder(cfg, env);
64
70
  const envFilePath = join(root, vaultFolder, `.env.${env}`);
65
71
 
72
+ printPolicyHeader(inlineFileSuffixes, excludeProperties);
73
+
66
74
  let parsed;
67
75
  try {
68
- parsed = parseEnvFile(envFilePath);
76
+ parsed = parseEnvFile(envFilePath, {
77
+ inlineFileSuffixes,
78
+ excludeProperties,
79
+ });
69
80
  } catch (e) {
70
81
  console.error((e as Error).message);
71
82
  process.exit(1);
72
83
  }
73
84
 
74
- const { tasks } = parsed;
85
+ const { tasks, skipped } = parsed;
75
86
  if (tasks.length === 0) {
76
87
  console.error(`no secrets to sync from ${envFilePath}`);
77
88
  process.exit(1);
@@ -108,6 +119,7 @@ export async function main(argv: string[]): Promise<void> {
108
119
  console.log(
109
120
  `\nSyncing ${tasks.length} secrets to GitHub: repo=${ghRepo}, environment=${env}`,
110
121
  );
122
+ printSkipped(skipped);
111
123
  result = await runPool(tasks, WORKERS, TIMEOUT_MS, (task, signal) =>
112
124
  setGhSecret(task, ghRepo!, env, signal),
113
125
  );
@@ -116,6 +128,7 @@ export async function main(argv: string[]): Promise<void> {
116
128
  console.log(
117
129
  `\nSyncing ${tasks.length} secrets to GCP Secret Manager: project=${gcpProject}`,
118
130
  );
131
+ printSkipped(skipped);
119
132
  result = await runPool(tasks, WORKERS, TIMEOUT_MS, (task, signal) =>
120
133
  setGcpSecret(task, gcpProject!, signal),
121
134
  );
@@ -139,6 +152,51 @@ export async function main(argv: string[]): Promise<void> {
139
152
  console.log(`\n✅ All ${totalOk} secrets synced across ${targets.length} target(s).`);
140
153
  }
141
154
 
155
+ function usage(): void {
156
+ console.error("usage: vsync sync <env> <gh|gcp|all>");
157
+ console.error("");
158
+ console.error(" env environment name; reads <vaultFolder>/.env.<env>");
159
+ console.error(" gh push to GitHub repo secrets (env = <env>)");
160
+ console.error(" gcp push to GCP Secret Manager (project from cfg.sync.gcp.project)");
161
+ console.error(" all push to every configured target");
162
+ console.error("");
163
+ console.error("Parser policy (no defaults — pass explicitly):");
164
+ console.error(" --inline-file-suffix=<suf> key suffix that turns a value into a file ref (repeatable)");
165
+ console.error(" --exclude-property=<key> key to skip entirely (repeatable)");
166
+ console.error("");
167
+ console.error("Routing flags: --gh-repo=<owner/name>, --gcp-project=<id>, --repo=<name>");
168
+ }
169
+
170
+ /** Print the active parser policy to stdout (spec v0.7 §4.1). */
171
+ export function printPolicyHeader(
172
+ inlineFileSuffixes: string[],
173
+ excludeProperties: string[],
174
+ ): void {
175
+ console.log("\nParser policy:");
176
+ if (inlineFileSuffixes.length === 0) {
177
+ console.log(" inline-file-suffix: (none — file refs disabled)");
178
+ } else {
179
+ for (const suf of inlineFileSuffixes) {
180
+ console.log(` inline-file-suffix: ${suf}`);
181
+ }
182
+ }
183
+ if (excludeProperties.length === 0) {
184
+ console.log(" exclude-property: (none — nothing skipped)");
185
+ } else {
186
+ for (const key of excludeProperties) {
187
+ console.log(` exclude-property: ${key}`);
188
+ }
189
+ }
190
+ }
191
+
192
+ function printSkipped(
193
+ skipped: Array<{ key: string; reason: "excluded" }>,
194
+ ): void {
195
+ for (const s of skipped) {
196
+ console.log(` skipped (${s.reason}): ${s.key}`);
197
+ }
198
+ }
199
+
142
200
  async function resolveGhRepo(
143
201
  cfg: ConfigFile,
144
202
  flags: Record<string, string>,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@muthuishere/vsync",
3
- "version": "0.5.1",
3
+ "version": "0.7.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,37 +1,68 @@
1
1
  // Parse a `.env.<ENV>` file into push-ready secret tasks.
2
2
  //
3
- // Mirrors the parsing behavior of reqsume/secrets.go:
3
+ // Zero-policy parser (v0.7): the parser carries no defaults. Every rule that
4
+ // affects what gets pushed is supplied by the caller via `ParseOptions`.
5
+ //
6
+ // Behavior overview:
4
7
  // - skip blank lines + `#` comments
5
8
  // - first `=` splits key/value, both trimmed
6
9
  // - strip a single pair of surrounding `"` or `'` from the value
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
10
+ // - keys listed in `opts.excludeProperties` are dropped onto `skipped` and
11
+ // never pushed; passing `[]` means nothing is skipped.
12
+ // - keys ending in one of `opts.inlineFileSuffixes` (and strictly longer
13
+ // than the suffix) are treated as file references — the suffix is
14
+ // stripped and the file's bytes are pushed under the stripped key.
15
+ // Passing `[]` disables file inlining entirely.
16
+ // - placeholder expansion in every value: `${VAULT_ROOT}`, `${HOME}`,
17
+ // leading `~/`. `VAULT_ROOT` = the directory the env file lives in.
18
+ // Disable with `opts.expandPlaceholders === false`.
19
+ //
20
+ // File-reference convention (when a suffix is configured, e.g. `_PATH`):
21
+ //
22
+ // FOO_PATH=keys/foo -> push as FOO with contents of <vault>/keys/foo
23
+ // FOO_FILE=./keys/foo -> push as FOO with contents of <vault>/keys/foo
24
+ //
25
+ // Relative paths are always resolved against `VAULT_ROOT` (i.e. the env
26
+ // file's own directory). Absolute paths and `~/` are honored as-is.
10
27
  //
11
- // GITHUB_REPO and GCP_PROJECT_ID are pulled out into `meta` and never pushed
12
- // as secrets they're routing config consumed by sync-secrets itself.
28
+ // All-or-none: if any file referenced by an inline-file-suffix key is missing
29
+ // or unreadable, parseEnvFile throws a single aggregated error and emits no
30
+ // tasks. Sync must not run partially.
13
31
 
14
32
  import { existsSync, readFileSync } from "node:fs";
15
33
  import { homedir } from "node:os";
16
- import { join } from "node:path";
34
+ import { dirname, isAbsolute, join } from "node:path";
17
35
 
18
36
  export type SecretTask = { key: string; value: string };
19
37
 
38
+ export type ParseOptions = {
39
+ /** Suffixes that turn a key into a file reference. Empty = no inlining. */
40
+ inlineFileSuffixes: string[];
41
+
42
+ /** Keys to skip entirely (never pushed). Empty = nothing skipped. */
43
+ excludeProperties: string[];
44
+
45
+ /** Placeholder expansion in values. Default true. */
46
+ expandPlaceholders?: boolean;
47
+ };
48
+
20
49
  export type ParsedEnv = {
21
50
  tasks: SecretTask[];
22
- meta: { GITHUB_REPO?: string; GCP_PROJECT_ID?: string };
51
+ skipped: Array<{ key: string; reason: "excluded" }>;
23
52
  };
24
53
 
25
- const LOCAL_ONLY = new Set(["GITHUB_TOKEN", "GOOGLE_APPLICATION_CREDENTIALS"]);
26
- const ROUTING = new Set(["GITHUB_REPO", "GCP_PROJECT_ID"]);
27
-
28
- export function parseEnvFile(path: string): ParsedEnv {
54
+ export function parseEnvFile(path: string, opts: ParseOptions): ParsedEnv {
29
55
  if (!existsSync(path)) {
30
56
  throw new Error(`.env file not found: ${path}`);
31
57
  }
32
58
  const raw = readFileSync(path, "utf8");
59
+ const vaultRoot = dirname(path);
33
60
  const tasks: SecretTask[] = [];
34
- const meta: ParsedEnv["meta"] = {};
61
+ const skipped: ParsedEnv["skipped"] = [];
62
+ const errors: string[] = [];
63
+
64
+ const excludeSet = new Set(opts.excludeProperties);
65
+ const expand = opts.expandPlaceholders !== false;
35
66
 
36
67
  for (const rawLine of raw.split(/\r?\n/)) {
37
68
  const line = rawLine.trim();
@@ -41,42 +72,35 @@ export function parseEnvFile(path: string): ParsedEnv {
41
72
  if (eq === -1) continue;
42
73
 
43
74
  const key = line.slice(0, eq).trim();
44
- let value = stripQuotes(line.slice(eq + 1).trim());
75
+ const rawValue = stripQuotes(line.slice(eq + 1).trim());
45
76
 
46
- if (LOCAL_ONLY.has(key)) {
47
- console.log(`Skipping ${key} (local use only)`);
77
+ if (excludeSet.has(key)) {
78
+ skipped.push({ key, reason: "excluded" });
48
79
  continue;
49
80
  }
50
81
 
51
- if (ROUTING.has(key)) {
52
- meta[key as keyof ParsedEnv["meta"]] = value;
53
- continue;
54
- }
82
+ const value = expand ? expandPlaceholders(rawValue, vaultRoot) : rawValue;
55
83
 
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
- }
84
+ // Generic suffix rule: caller-supplied suffixes → strip + inline file.
85
+ const stripped = stripFileSuffix(key, opts.inlineFileSuffixes);
86
+ if (stripped) {
87
+ readFileRef(value, vaultRoot, key, errors, (content) => {
88
+ tasks.push({ key: stripped, value: content });
89
+ });
73
90
  continue;
74
91
  }
75
92
 
93
+ // Plain value.
76
94
  tasks.push({ key, value });
77
95
  }
78
96
 
79
- return { tasks, meta };
97
+ if (errors.length > 0) {
98
+ throw new Error(
99
+ `parseEnvFile: aborting sync — ${errors.length} file reference(s) could not be resolved:\n - ${errors.join("\n - ")}`,
100
+ );
101
+ }
102
+
103
+ return { tasks, skipped };
80
104
  }
81
105
 
82
106
  function stripQuotes(value: string): string {
@@ -89,7 +113,39 @@ function stripQuotes(value: string): string {
89
113
  return value;
90
114
  }
91
115
 
92
- function readFileExpandTilde(path: string): string {
93
- if (path.startsWith("~/")) path = join(homedir(), path.slice(2));
94
- return readFileSync(path, "utf8");
116
+ function expandPlaceholders(value: string, vaultRoot: string): string {
117
+ let out = value;
118
+ if (out.startsWith("~/")) out = join(homedir(), out.slice(2));
119
+ out = out.replaceAll("${VAULT_ROOT}", vaultRoot);
120
+ out = out.replaceAll("${HOME}", homedir());
121
+ return out;
122
+ }
123
+
124
+ function stripFileSuffix(key: string, suffixes: string[]): string | null {
125
+ for (const suf of suffixes) {
126
+ if (!suf) continue;
127
+ if (key.endsWith(suf) && key.length > suf.length) {
128
+ return key.slice(0, -suf.length);
129
+ }
130
+ }
131
+ return null;
132
+ }
133
+
134
+ function readFileRef(
135
+ value: string,
136
+ vaultRoot: string,
137
+ key: string,
138
+ errors: string[],
139
+ onSuccess: (content: string) => void,
140
+ ): void {
141
+ const resolved = isAbsolute(value) ? value : join(vaultRoot, value);
142
+ if (!existsSync(resolved)) {
143
+ errors.push(`${key}: file not found at ${resolved}`);
144
+ return;
145
+ }
146
+ try {
147
+ onSuccess(readFileSync(resolved, "utf8"));
148
+ } catch (e) {
149
+ errors.push(`${key}: error reading ${resolved}: ${(e as Error).message}`);
150
+ }
95
151
  }