@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 +39 -6
- package/bin/sync.ts +6 -3
- package/package.json +1 -1
- package/src/envfile.ts +71 -25
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
|
-
|
|
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
|
-
- `
|
|
206
|
-
- `
|
|
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
|
-
|
|
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 (
|
|
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.
|
|
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
|
-
//
|
|
12
|
-
//
|
|
13
|
-
// -
|
|
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.
|
|
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
|
-
//
|
|
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
|
-
// -
|
|
9
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
93
|
-
|
|
94
|
-
|
|
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
|
}
|