@muthuishere/vsync 0.6.0 → 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 +40 -16
- package/bin/sync.ts +71 -16
- package/package.json +1 -1
- package/src/envfile.ts +38 -28
package/README.md
CHANGED
|
@@ -194,30 +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
|
-
|
|
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
|
+
|
|
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:
|
|
204
206
|
|
|
205
207
|
- `SSH_PRIVATE_KEY_PATH=keys/reqsume_dev` → pushes `<vault>/keys/reqsume_dev` as `SSH_PRIVATE_KEY`.
|
|
206
208
|
- `GCP_SA_KEY_FILE=keys/sa.json` → pushes `<vault>/keys/sa.json` as `GCP_SA_KEY`.
|
|
207
209
|
|
|
208
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).
|
|
209
211
|
|
|
210
|
-
|
|
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:
|
|
211
213
|
|
|
212
214
|
- `GITHUB_TOKEN`
|
|
213
215
|
- `GOOGLE_APPLICATION_CREDENTIALS`
|
|
214
216
|
|
|
215
|
-
|
|
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
|
|
216
222
|
|
|
217
|
-
|
|
218
|
-
|
|
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
|
+
```
|
|
219
233
|
|
|
220
|
-
|
|
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.
|
|
221
238
|
|
|
222
239
|
### Audit log
|
|
223
240
|
|
|
@@ -249,7 +266,7 @@ Auth is **outside vsync's scope** — the lib trusts whatever `gh` and `gcloud`
|
|
|
249
266
|
|
|
250
267
|
**`vsync sync <env> gh`:**
|
|
251
268
|
1. Resolves `sync.gh.repo` from per-repo config (or `--gh-repo` flag, or first-run prompt).
|
|
252
|
-
2. Parses `<vaultFolder>/.env.<env>` into push-ready KVs (file-
|
|
269
|
+
2. Parses `<vaultFolder>/.env.<env>` into push-ready KVs (using the `--inline-file-suffix` / `--exclude-property` flags you passed — see below).
|
|
253
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.
|
|
254
271
|
4. Requires `gh` CLI installed and `gh auth login` already done.
|
|
255
272
|
|
|
@@ -261,18 +278,23 @@ Auth is **outside vsync's scope** — the lib trusts whatever `gh` and `gcloud`
|
|
|
261
278
|
|
|
262
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.
|
|
263
280
|
|
|
264
|
-
|
|
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.
|
|
265
286
|
|
|
266
|
-
|
|
287
|
+
With `--inline-file-suffix=_PATH --inline-file-suffix=_FILE` in effect:
|
|
267
288
|
|
|
268
289
|
| In env file | Pushed as | Notes |
|
|
269
290
|
|---|---|---|
|
|
270
291
|
| `FOO_PATH=keys/foo` | `FOO` = file contents | suffix `_PATH` stripped; file resolved vault-relative |
|
|
271
292
|
| `FOO_FILE=keys/foo` | `FOO` = file contents | suffix `_FILE` stripped; same resolution |
|
|
272
293
|
| `SSH_PRIVATE_KEY_PATH=keys/dev` | `SSH_PRIVATE_KEY` = file contents | name the env-file key after the secret you want |
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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.
|
|
276
298
|
|
|
277
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:
|
|
278
300
|
|
|
@@ -283,9 +305,9 @@ Vsync reads each line of `.env.<env>` and pushes a KV to gh/gcp. Some keys carry
|
|
|
283
305
|
| `~/.ssh/id_rsa` or `${HOME}/.ssh/id_rsa` | `$HOME/.ssh/id_rsa` |
|
|
284
306
|
| `/abs/path` | absolute, pass-through |
|
|
285
307
|
|
|
286
|
-
**All-or-none on file refs.** If any
|
|
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.
|
|
287
309
|
|
|
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.
|
|
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).
|
|
289
311
|
|
|
290
312
|
---
|
|
291
313
|
|
|
@@ -338,7 +360,9 @@ In practice, just don't lose the keychain entry. `pull` itself is the recovery p
|
|
|
338
360
|
|
|
339
361
|
| Release | What's in it |
|
|
340
362
|
|---|---|
|
|
341
|
-
| **0.
|
|
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. |
|
|
342
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. |
|
|
343
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. |
|
|
344
368
|
|
package/bin/sync.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
// Usage: vsync sync <env> <gh|gcp|all>
|
|
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,14 @@
|
|
|
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
|
-
//
|
|
12
|
-
//
|
|
13
|
-
// -
|
|
14
|
-
//
|
|
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.
|
|
15
22
|
// - Path resolution anchors to VAULT_ROOT (dir of the .env.<env> being parsed);
|
|
16
23
|
// ${VAULT_ROOT} / ${HOME} / leading ~/ are expanded in every value.
|
|
17
24
|
// - Any missing referenced file aborts the whole sync before any push.
|
|
@@ -36,22 +43,18 @@ const WORKERS = 6;
|
|
|
36
43
|
const TIMEOUT_MS = 10 * 60 * 1000;
|
|
37
44
|
|
|
38
45
|
export async function main(argv: string[]): Promise<void> {
|
|
39
|
-
const { positional, flags } = parseArgs(argv);
|
|
46
|
+
const { positional, flags, lists } = parseArgs(argv);
|
|
40
47
|
const env = positional[0];
|
|
41
48
|
const target = positional[1] as Target | undefined;
|
|
42
49
|
|
|
43
50
|
if (!env || !target || !TARGETS.includes(target)) {
|
|
44
|
-
|
|
45
|
-
console.error("");
|
|
46
|
-
console.error(" env environment name; reads <vaultFolder>/.env.<env>");
|
|
47
|
-
console.error(" gh push to GitHub repo secrets (env = <env>)");
|
|
48
|
-
console.error(" gcp push to GCP Secret Manager (project from cfg.sync.gcp.project)");
|
|
49
|
-
console.error(" all push to every configured target");
|
|
50
|
-
console.error("");
|
|
51
|
-
console.error("Flags: --gh-repo=<owner/name>, --gcp-project=<id>, --repo=<name>");
|
|
51
|
+
usage();
|
|
52
52
|
process.exit(1);
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
const inlineFileSuffixes = lists["inline-file-suffix"] ?? [];
|
|
56
|
+
const excludeProperties = lists["exclude-property"] ?? [];
|
|
57
|
+
|
|
55
58
|
const repo = await getRepoName({ override: flags.repo });
|
|
56
59
|
const root = await getRepoRoot();
|
|
57
60
|
|
|
@@ -66,15 +69,20 @@ export async function main(argv: string[]): Promise<void> {
|
|
|
66
69
|
const vaultFolder = resolveVaultFolder(cfg, env);
|
|
67
70
|
const envFilePath = join(root, vaultFolder, `.env.${env}`);
|
|
68
71
|
|
|
72
|
+
printPolicyHeader(inlineFileSuffixes, excludeProperties);
|
|
73
|
+
|
|
69
74
|
let parsed;
|
|
70
75
|
try {
|
|
71
|
-
parsed = parseEnvFile(envFilePath
|
|
76
|
+
parsed = parseEnvFile(envFilePath, {
|
|
77
|
+
inlineFileSuffixes,
|
|
78
|
+
excludeProperties,
|
|
79
|
+
});
|
|
72
80
|
} catch (e) {
|
|
73
81
|
console.error((e as Error).message);
|
|
74
82
|
process.exit(1);
|
|
75
83
|
}
|
|
76
84
|
|
|
77
|
-
const { tasks } = parsed;
|
|
85
|
+
const { tasks, skipped } = parsed;
|
|
78
86
|
if (tasks.length === 0) {
|
|
79
87
|
console.error(`no secrets to sync from ${envFilePath}`);
|
|
80
88
|
process.exit(1);
|
|
@@ -111,6 +119,7 @@ export async function main(argv: string[]): Promise<void> {
|
|
|
111
119
|
console.log(
|
|
112
120
|
`\nSyncing ${tasks.length} secrets to GitHub: repo=${ghRepo}, environment=${env}`,
|
|
113
121
|
);
|
|
122
|
+
printSkipped(skipped);
|
|
114
123
|
result = await runPool(tasks, WORKERS, TIMEOUT_MS, (task, signal) =>
|
|
115
124
|
setGhSecret(task, ghRepo!, env, signal),
|
|
116
125
|
);
|
|
@@ -119,6 +128,7 @@ export async function main(argv: string[]): Promise<void> {
|
|
|
119
128
|
console.log(
|
|
120
129
|
`\nSyncing ${tasks.length} secrets to GCP Secret Manager: project=${gcpProject}`,
|
|
121
130
|
);
|
|
131
|
+
printSkipped(skipped);
|
|
122
132
|
result = await runPool(tasks, WORKERS, TIMEOUT_MS, (task, signal) =>
|
|
123
133
|
setGcpSecret(task, gcpProject!, signal),
|
|
124
134
|
);
|
|
@@ -142,6 +152,51 @@ export async function main(argv: string[]): Promise<void> {
|
|
|
142
152
|
console.log(`\n✅ All ${totalOk} secrets synced across ${targets.length} target(s).`);
|
|
143
153
|
}
|
|
144
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
|
+
|
|
145
200
|
async function resolveGhRepo(
|
|
146
201
|
cfg: ConfigFile,
|
|
147
202
|
flags: Record<string, string>,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@muthuishere/vsync",
|
|
3
|
-
"version": "0.
|
|
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,15 +1,23 @@
|
|
|
1
1
|
// Parse a `.env.<ENV>` file into push-ready secret tasks.
|
|
2
2
|
//
|
|
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
|
+
//
|
|
3
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
|
-
// -
|
|
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.
|
|
8
16
|
// - placeholder expansion in every value: `${VAULT_ROOT}`, `${HOME}`,
|
|
9
17
|
// leading `~/`. `VAULT_ROOT` = the directory the env file lives in.
|
|
18
|
+
// Disable with `opts.expandPlaceholders === false`.
|
|
10
19
|
//
|
|
11
|
-
// File-reference convention (
|
|
12
|
-
// under the stripped key name):
|
|
20
|
+
// File-reference convention (when a suffix is configured, e.g. `_PATH`):
|
|
13
21
|
//
|
|
14
22
|
// FOO_PATH=keys/foo -> push as FOO with contents of <vault>/keys/foo
|
|
15
23
|
// FOO_FILE=./keys/foo -> push as FOO with contents of <vault>/keys/foo
|
|
@@ -17,12 +25,9 @@
|
|
|
17
25
|
// Relative paths are always resolved against `VAULT_ROOT` (i.e. the env
|
|
18
26
|
// file's own directory). Absolute paths and `~/` are honored as-is.
|
|
19
27
|
//
|
|
20
|
-
// All-or-none: if any file referenced by
|
|
28
|
+
// All-or-none: if any file referenced by an inline-file-suffix key is missing
|
|
21
29
|
// or unreadable, parseEnvFile throws a single aggregated error and emits no
|
|
22
30
|
// tasks. Sync must not run partially.
|
|
23
|
-
//
|
|
24
|
-
// GITHUB_REPO and GCP_PROJECT_ID are pulled out into `meta` and never pushed
|
|
25
|
-
// as secrets — they're routing config consumed by sync-secrets itself.
|
|
26
31
|
|
|
27
32
|
import { existsSync, readFileSync } from "node:fs";
|
|
28
33
|
import { homedir } from "node:os";
|
|
@@ -30,26 +35,35 @@ import { dirname, isAbsolute, join } from "node:path";
|
|
|
30
35
|
|
|
31
36
|
export type SecretTask = { key: string; value: string };
|
|
32
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
|
+
|
|
33
49
|
export type ParsedEnv = {
|
|
34
50
|
tasks: SecretTask[];
|
|
35
|
-
|
|
51
|
+
skipped: Array<{ key: string; reason: "excluded" }>;
|
|
36
52
|
};
|
|
37
53
|
|
|
38
|
-
|
|
39
|
-
const ROUTING = new Set(["GITHUB_REPO", "GCP_PROJECT_ID"]);
|
|
40
|
-
|
|
41
|
-
const PATH_SUFFIXES = ["_PATH", "_FILE"] as const;
|
|
42
|
-
|
|
43
|
-
export function parseEnvFile(path: string): ParsedEnv {
|
|
54
|
+
export function parseEnvFile(path: string, opts: ParseOptions): ParsedEnv {
|
|
44
55
|
if (!existsSync(path)) {
|
|
45
56
|
throw new Error(`.env file not found: ${path}`);
|
|
46
57
|
}
|
|
47
58
|
const raw = readFileSync(path, "utf8");
|
|
48
59
|
const vaultRoot = dirname(path);
|
|
49
60
|
const tasks: SecretTask[] = [];
|
|
50
|
-
const
|
|
61
|
+
const skipped: ParsedEnv["skipped"] = [];
|
|
51
62
|
const errors: string[] = [];
|
|
52
63
|
|
|
64
|
+
const excludeSet = new Set(opts.excludeProperties);
|
|
65
|
+
const expand = opts.expandPlaceholders !== false;
|
|
66
|
+
|
|
53
67
|
for (const rawLine of raw.split(/\r?\n/)) {
|
|
54
68
|
const line = rawLine.trim();
|
|
55
69
|
if (!line || line.startsWith("#")) continue;
|
|
@@ -60,20 +74,15 @@ export function parseEnvFile(path: string): ParsedEnv {
|
|
|
60
74
|
const key = line.slice(0, eq).trim();
|
|
61
75
|
const rawValue = stripQuotes(line.slice(eq + 1).trim());
|
|
62
76
|
|
|
63
|
-
if (
|
|
64
|
-
|
|
77
|
+
if (excludeSet.has(key)) {
|
|
78
|
+
skipped.push({ key, reason: "excluded" });
|
|
65
79
|
continue;
|
|
66
80
|
}
|
|
67
81
|
|
|
68
|
-
const value = expandPlaceholders(rawValue, vaultRoot);
|
|
69
|
-
|
|
70
|
-
if (ROUTING.has(key)) {
|
|
71
|
-
meta[key as keyof ParsedEnv["meta"]] = value;
|
|
72
|
-
continue;
|
|
73
|
-
}
|
|
82
|
+
const value = expand ? expandPlaceholders(rawValue, vaultRoot) : rawValue;
|
|
74
83
|
|
|
75
|
-
// Generic suffix rule:
|
|
76
|
-
const stripped =
|
|
84
|
+
// Generic suffix rule: caller-supplied suffixes → strip + inline file.
|
|
85
|
+
const stripped = stripFileSuffix(key, opts.inlineFileSuffixes);
|
|
77
86
|
if (stripped) {
|
|
78
87
|
readFileRef(value, vaultRoot, key, errors, (content) => {
|
|
79
88
|
tasks.push({ key: stripped, value: content });
|
|
@@ -91,7 +100,7 @@ export function parseEnvFile(path: string): ParsedEnv {
|
|
|
91
100
|
);
|
|
92
101
|
}
|
|
93
102
|
|
|
94
|
-
return { tasks,
|
|
103
|
+
return { tasks, skipped };
|
|
95
104
|
}
|
|
96
105
|
|
|
97
106
|
function stripQuotes(value: string): string {
|
|
@@ -112,8 +121,9 @@ function expandPlaceholders(value: string, vaultRoot: string): string {
|
|
|
112
121
|
return out;
|
|
113
122
|
}
|
|
114
123
|
|
|
115
|
-
function
|
|
116
|
-
for (const suf of
|
|
124
|
+
function stripFileSuffix(key: string, suffixes: string[]): string | null {
|
|
125
|
+
for (const suf of suffixes) {
|
|
126
|
+
if (!suf) continue;
|
|
117
127
|
if (key.endsWith(suf) && key.length > suf.length) {
|
|
118
128
|
return key.slice(0, -suf.length);
|
|
119
129
|
}
|