@muthuishere/vsync 0.3.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/src/prompt.ts ADDED
@@ -0,0 +1,133 @@
1
+ // prompt.ts — tiny TTY input helpers used by every CLI subcommand so the
2
+ // same verb can be driven entirely by flags ("--bucket=foo --region=…")
3
+ // for scripting, OR by interactive prompts when flags are missing.
4
+ //
5
+ // Three helpers:
6
+ // askText / askBool / askSecret — promise-returning readline calls
7
+ // resolveOrAsk(value, question) — uses the provided value or prompts
8
+ // confirmYes(question) — small y/n with a default
9
+ //
10
+ // We deliberately avoid third-party deps. Bun ships with a global
11
+ // `prompt()` (Web standard) for plain text. For hidden input we toggle
12
+ // raw mode on process.stdin and read char-by-char so passphrases don't
13
+ // echo to the screen.
14
+
15
+ import { stdin, stdout } from "node:process";
16
+
17
+ /** True when stdin is a real TTY (so prompts make sense). */
18
+ export function isTty(): boolean {
19
+ return Boolean((stdin as any).isTTY);
20
+ }
21
+
22
+ /** Bun's global prompt(), with a default-value fallback when offered. */
23
+ export function askText(label: string, defaultValue?: string): string {
24
+ const promptStr =
25
+ defaultValue !== undefined ? `${label} [${defaultValue}]: ` : `${label}: `;
26
+ const v = (globalThis as any).prompt(promptStr);
27
+ if (v === null || v === undefined) {
28
+ throw new Error("aborted (no input)");
29
+ }
30
+ const trimmed = String(v).trim();
31
+ return trimmed || defaultValue || "";
32
+ }
33
+
34
+ /** y/n prompt; honours a default if user just hits enter. */
35
+ export function askBool(label: string, defaultValue: boolean): boolean {
36
+ const def = defaultValue ? "Y/n" : "y/N";
37
+ const v = (globalThis as any).prompt(`${label} [${def}]: `);
38
+ if (v === null || v === undefined) return defaultValue;
39
+ const t = String(v).trim().toLowerCase();
40
+ if (!t) return defaultValue;
41
+ return t.startsWith("y");
42
+ }
43
+
44
+ /** Hidden input — toggles raw mode on TTY and masks each keystroke. */
45
+ export async function askSecret(label: string): Promise<string> {
46
+ if (!isTty()) {
47
+ // Non-TTY (piped stdin) — just read a line.
48
+ return await readLineFromStdin(label);
49
+ }
50
+ stdout.write(label + ": ");
51
+ return await new Promise<string>((resolve, reject) => {
52
+ const buf: string[] = [];
53
+ (stdin as any).setRawMode?.(true);
54
+ (stdin as any).resume?.();
55
+ stdin.setEncoding("utf8");
56
+ const onData = (chunk: string) => {
57
+ for (const ch of chunk) {
58
+ if (ch === "\n" || ch === "\r" || ch === "") {
59
+ (stdin as any).setRawMode?.(false);
60
+ stdin.off("data", onData);
61
+ stdin.pause();
62
+ stdout.write("\n");
63
+ resolve(buf.join("").trim());
64
+ return;
65
+ }
66
+ if (ch === "") {
67
+ (stdin as any).setRawMode?.(false);
68
+ stdin.off("data", onData);
69
+ stdin.pause();
70
+ stdout.write("\n");
71
+ reject(new Error("aborted"));
72
+ return;
73
+ }
74
+ if (ch === "" || ch === "\b") {
75
+ if (buf.length) {
76
+ buf.pop();
77
+ stdout.write("\b \b");
78
+ }
79
+ continue;
80
+ }
81
+ buf.push(ch);
82
+ stdout.write("*");
83
+ }
84
+ };
85
+ stdin.on("data", onData);
86
+ });
87
+ }
88
+
89
+ async function readLineFromStdin(label: string): Promise<string> {
90
+ if (label) stdout.write(label + ": ");
91
+ return await new Promise<string>((resolve) => {
92
+ let buf = "";
93
+ stdin.setEncoding("utf8");
94
+ const onData = (chunk: string) => {
95
+ buf += chunk;
96
+ const nl = buf.indexOf("\n");
97
+ if (nl >= 0) {
98
+ stdin.off("data", onData);
99
+ resolve(buf.slice(0, nl).trim());
100
+ }
101
+ };
102
+ stdin.once("end", () => resolve(buf.trim()));
103
+ stdin.on("data", onData);
104
+ });
105
+ }
106
+
107
+ /** Use the provided value if defined+non-empty, otherwise prompt. Throws
108
+ * if not a TTY and no value was given (can't prompt non-interactively). */
109
+ export function resolveOrAsk(
110
+ value: string | undefined,
111
+ label: string,
112
+ defaultValue?: string,
113
+ ): string {
114
+ if (value !== undefined && value !== "") return value;
115
+ if (!isTty()) {
116
+ if (defaultValue !== undefined) return defaultValue;
117
+ throw new Error(
118
+ `missing ${label} and stdin is not a TTY — pass --${label.toLowerCase().replace(/\s+/g, "-")}=… on the command line`,
119
+ );
120
+ }
121
+ return askText(label, defaultValue);
122
+ }
123
+
124
+ /** Yes/no confirmation. If --yes flag pre-set, returns true silently. */
125
+ export function confirmYes(label: string, preApproved: boolean): boolean {
126
+ if (preApproved) return true;
127
+ if (!isTty()) {
128
+ throw new Error(
129
+ `${label} requires interactive confirmation — pass --yes to bypass`,
130
+ );
131
+ }
132
+ return askBool(label, false);
133
+ }
package/src/repo.ts ADDED
@@ -0,0 +1,89 @@
1
+ // repo.ts — utilities for working out which repo we're running in.
2
+ //
3
+ // Two things consumers need:
4
+ // - getRepoRoot() — the filesystem path of the repo top-level
5
+ // - getRepoName() — a short identifier used as the namespace for the
6
+ // config file and keychain entry
7
+ //
8
+ // Repo-name precedence (first match wins):
9
+ // 1. Explicit override (e.g. `--repo=foo` flag passed through)
10
+ // 2. SECRETS_SYNC_REPO env var
11
+ // 3. `name` from package.json at the repo root, with any leading scope
12
+ // stripped (e.g. "@muthuishere/vsync" → "vsync")
13
+ // 4. basename of the git remote / git toplevel (e.g. "reqsume")
14
+ // 5. basename of process.cwd() as a last resort
15
+ //
16
+ // All four file-paths and the keychain account are derived from the
17
+ // resulting name, so it should be stable across machines for the same
18
+ // repo.
19
+
20
+ import * as fs from "node:fs/promises";
21
+ import * as path from "node:path";
22
+
23
+ /** Filesystem path of the repo root (git toplevel, else cwd). */
24
+ export async function getRepoRoot(): Promise<string> {
25
+ const proc = Bun.spawn(["git", "rev-parse", "--show-toplevel"], {
26
+ stderr: "pipe",
27
+ stdout: "pipe",
28
+ });
29
+ const code = await proc.exited;
30
+ if (code === 0) {
31
+ return (await new Response(proc.stdout).text()).trim();
32
+ }
33
+ return process.cwd();
34
+ }
35
+
36
+ export type RepoNameOptions = {
37
+ /** Caller-supplied override (e.g. parsed from --repo=… flag). */
38
+ override?: string;
39
+ /** Repo root override (mostly for tests). Defaults to getRepoRoot(). */
40
+ root?: string;
41
+ };
42
+
43
+ /**
44
+ * Resolve the canonical repo name used by config file paths and keychain
45
+ * entries. See the precedence list at the top of this file.
46
+ */
47
+ export async function getRepoName(
48
+ opts: RepoNameOptions = {},
49
+ ): Promise<string> {
50
+ const fromOverride = sanitize(opts.override);
51
+ if (fromOverride) return fromOverride;
52
+
53
+ const fromEnv = sanitize(process.env.SECRETS_SYNC_REPO);
54
+ if (fromEnv) return fromEnv;
55
+
56
+ const root = opts.root ?? (await getRepoRoot());
57
+
58
+ const fromPkg = sanitize(await readPackageName(root));
59
+ if (fromPkg) return fromPkg;
60
+
61
+ const fromGit = sanitize(path.basename(root));
62
+ if (fromGit) return fromGit;
63
+
64
+ return sanitize(path.basename(process.cwd())) || "default";
65
+ }
66
+
67
+ async function readPackageName(root: string): Promise<string | null> {
68
+ try {
69
+ const buf = await fs.readFile(path.join(root, "package.json"), "utf8");
70
+ const parsed = JSON.parse(buf);
71
+ const name: unknown = parsed?.name;
72
+ if (typeof name !== "string" || !name) return null;
73
+ // Strip npm scope: "@scope/foo" → "foo"
74
+ const slash = name.indexOf("/");
75
+ return slash >= 0 ? name.slice(slash + 1) : name;
76
+ } catch {
77
+ return null;
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Allow letters, digits, underscore, hyphen, and dot. Strip anything else.
83
+ * Empty result returns null so callers fall through to the next source.
84
+ */
85
+ function sanitize(value: string | null | undefined): string | null {
86
+ if (!value) return null;
87
+ const trimmed = value.trim().replace(/[^A-Za-z0-9._-]/g, "");
88
+ return trimmed || null;
89
+ }
@@ -0,0 +1,163 @@
1
+ // repoconfig.ts — the on-disk per-(repo, env) half of a vsync config.
2
+ // Self-contained: holds everything push / pull / sync need at runtime,
3
+ // minus the encryption key (which lives in the OS keychain — see
4
+ // keychain.ts).
5
+ //
6
+ // Path: ${XDG_CONFIG_HOME:-$HOME/.config}/vsync/<repo>/env_<env>
7
+ //
8
+ // File mode 0600, parent dir 0700. Stored as gzip(JSON) — raw bytes,
9
+ // no base64 wrapper.
10
+
11
+ import { gunzipSync, gzipSync } from "node:zlib";
12
+ import * as fs from "node:fs/promises";
13
+ import * as path from "node:path";
14
+
15
+ import type { S3Credentials } from "./s3";
16
+ import { vsyncBaseDir } from "./defaults";
17
+
18
+ /**
19
+ * On-disk shape. Self-contained — push / pull / sync read this file and
20
+ * the keychain entry, nothing else. `loadEnvConfig` (envconfig.ts) just
21
+ * splices in the keychain key.
22
+ *
23
+ * `files.vaultFolder` overrides the default `infra/vault/<env>` for
24
+ * monorepos. `sync.gh.repo` / `sync.gcp.project` are routing config
25
+ * for the `vsync sync` fanout, written on first invocation.
26
+ */
27
+ export type ConfigFile = {
28
+ version: 1;
29
+ s3: S3Credentials;
30
+ encryption: { salt: string };
31
+ files?: { vaultFolder?: string };
32
+ sync?: {
33
+ gh?: { repo: string };
34
+ gcp?: { project: string };
35
+ };
36
+ };
37
+
38
+ /** Full path for a given (repo, env). env is lowercased; repo is taken as-is. */
39
+ export function configFilePath(repo: string, env: string): string {
40
+ if (!repo) throw new Error("repo is required");
41
+ if (!env) throw new Error("env is required");
42
+ return path.join(vsyncBaseDir(), repo, `env_${env.toLowerCase()}`);
43
+ }
44
+
45
+ /**
46
+ * Persist a ConfigFile. Creates the directory tree if missing. Uses 0700
47
+ * on directories and 0600 on the file so other local users on the
48
+ * machine can't read it.
49
+ */
50
+ export async function saveConfigFile(
51
+ repo: string,
52
+ env: string,
53
+ cfg: ConfigFile,
54
+ ): Promise<string> {
55
+ validateConfigFile(cfg);
56
+ const file = configFilePath(repo, env);
57
+ const dir = path.dirname(file);
58
+
59
+ await fs.mkdir(dir, { recursive: true, mode: 0o700 });
60
+ try {
61
+ await fs.chmod(dir, 0o700);
62
+ } catch {
63
+ // Non-fatal; some filesystems (e.g. Windows) don't honour chmod.
64
+ }
65
+
66
+ const json = JSON.stringify(cfg);
67
+ const gz = gzipSync(Buffer.from(json, "utf8"));
68
+ await fs.writeFile(file, gz, { mode: 0o600 });
69
+ try {
70
+ await fs.chmod(file, 0o600);
71
+ } catch {
72
+ // Same caveat as above.
73
+ }
74
+ return file;
75
+ }
76
+
77
+ /**
78
+ * Read the on-disk config back. Returns null when the file doesn't exist
79
+ * (so callers can produce a "no config for <repo>/<env>" message). Any
80
+ * other error — corrupt gzip, malformed JSON, validation failure — is
81
+ * thrown.
82
+ */
83
+ export async function loadConfigFile(
84
+ repo: string,
85
+ env: string,
86
+ ): Promise<ConfigFile | null> {
87
+ const file = configFilePath(repo, env);
88
+ let buf: Buffer;
89
+ try {
90
+ buf = await fs.readFile(file);
91
+ } catch (err: any) {
92
+ if (err && (err.code === "ENOENT" || err.code === "ENOTDIR")) return null;
93
+ throw err;
94
+ }
95
+ const json = gunzipSync(buf).toString("utf8");
96
+ const parsed = JSON.parse(json);
97
+ validateConfigFile(parsed);
98
+ return parsed;
99
+ }
100
+
101
+ /**
102
+ * Delete the on-disk config (no-op if missing). Doesn't touch the
103
+ * keychain — use keychain.deleteKey for that.
104
+ */
105
+ export async function deleteConfigFile(
106
+ repo: string,
107
+ env: string,
108
+ ): Promise<boolean> {
109
+ const file = configFilePath(repo, env);
110
+ try {
111
+ await fs.unlink(file);
112
+ return true;
113
+ } catch (err: any) {
114
+ if (err && err.code === "ENOENT") return false;
115
+ throw err;
116
+ }
117
+ }
118
+
119
+ /** Defensive shape check; mirrors validate() in envconfig.ts but key-free. */
120
+ export function validateConfigFile(cfg: unknown): asserts cfg is ConfigFile {
121
+ const c = cfg as Partial<ConfigFile> | null;
122
+ if (!c || typeof c !== "object") throw new Error("config: not an object");
123
+ if (c.version !== 1) {
124
+ throw new Error(`config: unsupported version ${c.version} (expected 1)`);
125
+ }
126
+ const s3 = c.s3;
127
+ for (const k of [
128
+ "endpoint",
129
+ "region",
130
+ "accessKeyId",
131
+ "secretAccessKey",
132
+ "bucket",
133
+ ] as const) {
134
+ if (!s3?.[k]) throw new Error(`config: s3.${k} missing`);
135
+ }
136
+ if (typeof s3?.useSsl !== "boolean") {
137
+ throw new Error("config: s3.useSsl missing or not a boolean");
138
+ }
139
+ const enc = c.encryption;
140
+ if (!enc?.salt) throw new Error("config: encryption.salt missing");
141
+ if (c.files !== undefined) {
142
+ if (typeof c.files !== "object" || c.files === null) {
143
+ throw new Error("config: files must be an object if present");
144
+ }
145
+ if (
146
+ c.files.vaultFolder !== undefined &&
147
+ typeof c.files.vaultFolder !== "string"
148
+ ) {
149
+ throw new Error("config: files.vaultFolder must be a string if present");
150
+ }
151
+ }
152
+ if (c.sync !== undefined) {
153
+ if (typeof c.sync !== "object" || c.sync === null) {
154
+ throw new Error("config: sync must be an object if present");
155
+ }
156
+ if (c.sync.gh !== undefined && (!c.sync.gh.repo || typeof c.sync.gh.repo !== "string")) {
157
+ throw new Error("config: sync.gh.repo must be a string if sync.gh is present");
158
+ }
159
+ if (c.sync.gcp !== undefined && (!c.sync.gcp.project || typeof c.sync.gcp.project !== "string")) {
160
+ throw new Error("config: sync.gcp.project must be a string if sync.gcp is present");
161
+ }
162
+ }
163
+ }
package/src/s3.ts ADDED
@@ -0,0 +1,25 @@
1
+ // Bun S3 client wrapper. Credentials come from the decoded VIDEO_AI_ENV_<NAME>
2
+ // blob; see src/envconfig.ts.
3
+
4
+ export type S3Credentials = {
5
+ endpoint: string;
6
+ region: string;
7
+ useSsl: boolean;
8
+ accessKeyId: string;
9
+ secretAccessKey: string;
10
+ bucket: string;
11
+ };
12
+
13
+ export function makeClient(creds: S3Credentials) {
14
+ const protocol = creds.useSsl ? "https://" : "http://";
15
+ const endpoint = creds.endpoint.startsWith("http")
16
+ ? creds.endpoint
17
+ : protocol + creds.endpoint;
18
+ return new Bun.S3Client({
19
+ accessKeyId: creds.accessKeyId,
20
+ secretAccessKey: creds.secretAccessKey,
21
+ region: creds.region,
22
+ bucket: creds.bucket,
23
+ endpoint,
24
+ });
25
+ }
@@ -0,0 +1,100 @@
1
+ // sharefile.ts — build / parse the passphrase-encrypted share file that
2
+ // teammates pass between machines.
3
+ //
4
+ // Wire format (file bytes, written verbatim — no base64 wrapper):
5
+ // bytes 0..3 "SLS1" — magic for the share file itself
6
+ // bytes 4 saltLen (1 byte) — length of the salt that follows
7
+ // bytes 5..5+L salt (L bytes) — base64-string bytes, used by PBKDF2
8
+ // bytes rest — output of src/crypto.ts encrypt()
9
+ // (which has its own RQE1 magic + IV + ct)
10
+ //
11
+ // The encrypted payload is the ExportPayload JSON (config + key + repo + env).
12
+
13
+ import { encrypt, decrypt } from "./crypto";
14
+ import {
15
+ EXPORT_BLOB_VERSION,
16
+ type ExportPayload,
17
+ parseExportBlob,
18
+ buildExportBlob,
19
+ } from "./envconfig";
20
+
21
+ const SHARE_MAGIC = new Uint8Array([0x53, 0x4c, 0x53, 0x31]); // "SLS1"
22
+ const SALT_BYTES = 16;
23
+
24
+ /** Encrypt + frame the payload into a single byte sequence suitable for
25
+ * writing to a `.share` file. */
26
+ export async function buildShareFile(
27
+ payload: ExportPayload,
28
+ passphrase: string,
29
+ ): Promise<Uint8Array> {
30
+ if (payload.version !== EXPORT_BLOB_VERSION) {
31
+ throw new Error(
32
+ `buildShareFile: payload.version ${payload.version} not supported`,
33
+ );
34
+ }
35
+ const saltBytes = crypto.getRandomValues(new Uint8Array(SALT_BYTES));
36
+ const saltStr = Buffer.from(saltBytes).toString("base64");
37
+
38
+ // Re-use envconfig.buildExportBlob so the JSON+gzip+base64 framing
39
+ // matches what was the env-var-era export. The result is one base64
40
+ // ASCII string; we encrypt those bytes.
41
+ const blobStr = buildExportBlob(payload);
42
+ const blobBytes = new TextEncoder().encode(blobStr);
43
+ const encrypted = await encrypt(blobBytes, passphrase, saltStr);
44
+
45
+ const saltAscii = new TextEncoder().encode(saltStr);
46
+ if (saltAscii.length > 0xff) {
47
+ throw new Error("internal: salt too long for 1-byte length prefix");
48
+ }
49
+ const out = new Uint8Array(
50
+ SHARE_MAGIC.length + 1 + saltAscii.length + encrypted.length,
51
+ );
52
+ let offset = 0;
53
+ out.set(SHARE_MAGIC, offset);
54
+ offset += SHARE_MAGIC.length;
55
+ out[offset] = saltAscii.length;
56
+ offset += 1;
57
+ out.set(saltAscii, offset);
58
+ offset += saltAscii.length;
59
+ out.set(encrypted, offset);
60
+ return out;
61
+ }
62
+
63
+ /** Decrypt + unframe — the inverse of buildShareFile. */
64
+ export async function parseShareFile(
65
+ bytes: Uint8Array,
66
+ passphrase: string,
67
+ ): Promise<ExportPayload> {
68
+ if (bytes.length < SHARE_MAGIC.length + 1) {
69
+ throw new Error("share file is too short");
70
+ }
71
+ for (let i = 0; i < SHARE_MAGIC.length; i++) {
72
+ if (bytes[i] !== SHARE_MAGIC[i]) {
73
+ throw new Error(
74
+ "not a vsync share file (magic header missing). " +
75
+ "Check that you passed the file produced by `vsync export`.",
76
+ );
77
+ }
78
+ }
79
+ let offset = SHARE_MAGIC.length;
80
+ const saltLen = bytes[offset]!;
81
+ offset += 1;
82
+ if (bytes.length < offset + saltLen) {
83
+ throw new Error("share file truncated (salt header)");
84
+ }
85
+ const saltStr = new TextDecoder().decode(bytes.subarray(offset, offset + saltLen));
86
+ offset += saltLen;
87
+ const ciphertext = bytes.subarray(offset);
88
+
89
+ let decrypted: Uint8Array;
90
+ try {
91
+ decrypted = await decrypt(ciphertext, passphrase, saltStr);
92
+ } catch (e) {
93
+ throw new Error(
94
+ "failed to decrypt share file — passphrase wrong or file corrupt. " +
95
+ "Ask the sender to re-share both.",
96
+ );
97
+ }
98
+ const blobStr = new TextDecoder().decode(decrypted);
99
+ return parseExportBlob(blobStr);
100
+ }
@@ -0,0 +1,47 @@
1
+ // Bounded worker pool for fan-out secret pushes.
2
+ //
3
+ // Mirrors reqsume/secrets.go:runTasksConcurrently — N workers pull tasks off
4
+ // a shared cursor, failures are collected (don't abort), an overall timeout
5
+ // aborts everything via AbortSignal.
6
+
7
+ import type { SecretTask } from "./envfile";
8
+
9
+ export type SyncResult = { failed: string[]; ok: number };
10
+
11
+ export async function runPool(
12
+ tasks: SecretTask[],
13
+ workers: number,
14
+ timeoutMs: number,
15
+ fn: (t: SecretTask, signal: AbortSignal) => Promise<void>,
16
+ ): Promise<SyncResult> {
17
+ const n = Math.max(1, workers);
18
+ const ctrl = new AbortController();
19
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
20
+
21
+ const failed: string[] = [];
22
+ let ok = 0;
23
+ let cursor = 0;
24
+
25
+ const worker = async () => {
26
+ while (!ctrl.signal.aborted) {
27
+ const idx = cursor++;
28
+ if (idx >= tasks.length) return;
29
+ const t = tasks[idx];
30
+ try {
31
+ await fn(t, ctrl.signal);
32
+ ok++;
33
+ } catch (e) {
34
+ console.error(`WARNING: skipping ${t.key}: ${(e as Error).message}`);
35
+ failed.push(t.key);
36
+ }
37
+ }
38
+ };
39
+
40
+ try {
41
+ await Promise.all(Array.from({ length: n }, () => worker()));
42
+ } finally {
43
+ clearTimeout(timer);
44
+ }
45
+
46
+ return { ok, failed };
47
+ }
@@ -0,0 +1,89 @@
1
+ // Static onboarding reference emitted by `vsync docs`.
2
+ //
3
+ // Lives as a string export so it ships with the binary and stays in
4
+ // sync with the verb set. Pipe to a file if you want to commit it:
5
+ // vsync docs > infra/AGENTS.md
6
+
7
+ export const DOCS_MD = `# vsync — onboarding reference
8
+
9
+ Generated by \`vsync docs\` and intended to be committed at the path of
10
+ your choice (\`infra/AGENTS.md\`, \`infra/CLAUDE.md\`, etc.) so AI agents
11
+ and teammates landing in this repo know where secrets live and how to
12
+ work with them.
13
+
14
+ ## What this repo uses
15
+
16
+ Secrets sync via \`@muthuishere/vsync\`. The canonical store is an
17
+ S3-compatible bucket; per-machine state lives at
18
+ \`~/.config/vsync/\` plus an OS keychain entry under service
19
+ \`tools.vsync\`.
20
+
21
+ ## Where secret content lives
22
+
23
+ \`\`\`
24
+ infra/vault/
25
+ dev/
26
+ .env.dev
27
+ some-secret.json
28
+ ...
29
+ production/
30
+ .env.production
31
+ \`\`\`
32
+
33
+ (\`infra/vault/<env>\` is the default — a per-(repo, env) override may
34
+ point elsewhere; check \`~/.config/vsync/<repo>/env_<env>\` for
35
+ \`files.vaultFolder\`.)
36
+
37
+ Apps point dotenv (or equivalent) at the in-vault path:
38
+
39
+ \`\`\`js
40
+ dotenv.config({ path: \`infra/vault/\${env}/.env.\${env}\` });
41
+ \`\`\`
42
+
43
+ ## Commands
44
+
45
+ | Command | Purpose |
46
+ |---|---|
47
+ | \`vsync init <env>\` | First-time setup: generate AES key, write per-repo config, create vault folder. Re-runnable. |
48
+ | \`vsync export <env>\` | Write a passphrase-encrypted \`.share\` file to send a teammate (file + passphrase on different channels). |
49
+ | \`vsync import <env> <file>\` | Install a teammate's \`.share\` file. No prior init required. |
50
+ | \`vsync push <env>\` | Encrypt the vault folder and upload to S3. |
51
+ | \`vsync pull <env>\` | Download the latest from S3 and unzip into the vault folder (auto-backs up the existing folder first). |
52
+ | \`vsync versions <env>\` | List the available versions on S3 (read-only, no decrypt). |
53
+ | \`vsync sync <env> <gh\\|gcp\\|all>\` | Push the vault's \`.env.<env>\` KVs out to GitHub Repo Secrets / GCP Secret Manager. |
54
+ | \`vsync docs\` | Print this reference. |
55
+
56
+ Every command takes \`--repo=<name>\` to override the auto-detected repo
57
+ name and \`--interactive\` to force prompts. Each command works fully
58
+ via flags or fully via prompts.
59
+
60
+ ## Recovering a local backup
61
+
62
+ Each \`vsync pull\` writes the prior vault folder to
63
+ \`~/.config/vsync/backups/<env>-<ts>.zip.enc\` (two-deep rolling buffer).
64
+ The format is AES-256-GCM with the same per-(repo, env) keychain key
65
+ plus the salt from the per-repo config. To decrypt by hand:
66
+
67
+ 1. Get the keychain key:
68
+ - macOS: \`security find-generic-password -s tools.vsync -a <repo>/<env> -w\`
69
+ - Linux: \`secret-tool lookup service tools.vsync account <repo>/<env>\`
70
+ 2. Get the salt: \`gunzip -c ~/.config/vsync/<repo>/env_<env> | jq -r .encryption.salt\`
71
+ 3. The envelope is \`RQE1\` (4-byte magic) + 12-byte IV + AES-GCM ciphertext.
72
+ Derive \`AES-256-GCM key = PBKDF2-SHA256(keychain-key, salt, 600k)\`.
73
+
74
+ Easier: just \`vsync pull\` again to restore from S3.
75
+
76
+ ## Rules for AI agents working in this repo
77
+
78
+ 1. **Never commit anything under \`infra/vault/\`.** Add it to
79
+ \`.gitignore\` if missing.
80
+ 2. **Never read or edit files under \`~/.config/vsync/\` directly.**
81
+ Use the CLI verbs.
82
+ 3. **Never paste keychain keys or salts into source code, comments,
83
+ PR descriptions, or chat.** They're machine-local secrets.
84
+ 4. **Routing config (sync.gh.repo, sync.gcp.project) is per-(repo, env).**
85
+ Read it from \`~/.config/vsync/<repo>/env_<env>\` if you need to know
86
+ the GH repo or GCP project the team syncs to. Don't hardcode it.
87
+ 5. **If a command asks for a passphrase or share-file path, surface the
88
+ prompt to the human user.** Don't auto-fill or skip.
89
+ `;