@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/bin/init.ts ADDED
@@ -0,0 +1,259 @@
1
+ #!/usr/bin/env bun
2
+ // Usage:
3
+ // vsync init <env> [flags] [--interactive]
4
+ //
5
+ // Sets up a new (repo, env) pair locally:
6
+ // 1. Collects S3 bucket creds (via flags, prompts, or both — pre-fills
7
+ // from ~/.config/vsync/defaults if a previous init wrote it).
8
+ // 2. Generates a fresh AES-256 key.
9
+ // 3. Writes the self-contained per-repo file to
10
+ // ~/.config/vsync/<repo>/env_<env>.
11
+ // 4. Saves the key to the OS keychain via Bun.secrets.
12
+ // 5. On first-ever init: writes ~/.config/vsync/defaults from the
13
+ // supplied values so later inits are zero-prompt.
14
+ // 6. Creates the resolved vault folder
15
+ // (infra/vault/<env>, or whatever --vault-folder set).
16
+ // 7. If a root .env.<env> exists and the new vault folder doesn't have
17
+ // one, prompts to mv it.
18
+ // 8. Warns if `infra/vault/` (or the vault folder's parent) isn't in
19
+ // .gitignore.
20
+ // 9. Prints the dotenv snippet so the consuming app can find the .env.
21
+ //
22
+ // Flags (any can be passed to skip its prompt):
23
+ // --repo=<name> Override auto-detected repo name
24
+ // --bucket=<name> S3 bucket
25
+ // --endpoint=<url> S3 endpoint
26
+ // --region=<name> S3 region
27
+ // --access-key=<id> S3 access key ID
28
+ // --secret-key=<secret> S3 secret access key
29
+ // --use-ssl=<true|false> Force TLS (default true)
30
+ // --vault-folder=<path> Override default infra/vault/<env> for monorepos
31
+ // --migrate-from=<path> Use a non-default source for the .env relocation
32
+ // --no-migrate Skip the root .env.<env> migration prompt entirely
33
+ // --interactive Prompt even for fields already provided via flags
34
+
35
+ import { existsSync, mkdirSync, renameSync, readFileSync } from "node:fs";
36
+ import { join, dirname } from "node:path";
37
+ import { parseArgs } from "../src/argv";
38
+ import { getRepoName, getRepoRoot } from "../src/repo";
39
+ import {
40
+ saveConfigFile,
41
+ configFilePath,
42
+ type ConfigFile,
43
+ } from "../src/repoconfig";
44
+ import { setKey, generateKey } from "../src/keychain";
45
+ import {
46
+ loadDefaults,
47
+ saveDefaults,
48
+ defaultsFilePath,
49
+ type Defaults,
50
+ } from "../src/defaults";
51
+ import { askText, askBool, isTty } from "../src/prompt";
52
+
53
+ function envFromArg(env?: string): string {
54
+ if (!env) {
55
+ console.error("usage: vsync init <env> [flags]");
56
+ console.error(
57
+ " e.g. vsync init dev --bucket=my-bucket --endpoint=https://s3.example.com",
58
+ );
59
+ process.exit(1);
60
+ }
61
+ if (!/^[a-z][a-z0-9_-]*$/.test(env)) {
62
+ console.error(
63
+ `env must be lowercase letters/digits/underscore/hyphen (got "${env}")`,
64
+ );
65
+ process.exit(1);
66
+ }
67
+ return env;
68
+ }
69
+
70
+ function randomSalt(): string {
71
+ const bytes = new Uint8Array(18);
72
+ crypto.getRandomValues(bytes);
73
+ return Buffer.from(bytes).toString("base64").replace(/=+$/, "");
74
+ }
75
+
76
+ export async function main(argv: string[]): Promise<void> {
77
+ const { positional, flags } = parseArgs(argv);
78
+ const env = envFromArg(positional[0]);
79
+ const interactive = flags.interactive === "true";
80
+
81
+ const repo = await getRepoName({ override: flags.repo });
82
+ const root = await getRepoRoot();
83
+
84
+ const existingDefaults = await loadDefaults();
85
+ const defaultS3 = existingDefaults?.s3 ?? {};
86
+
87
+ // (flag value | prompt with default | hard default)
88
+ const get = (
89
+ flagKey: string,
90
+ label: string,
91
+ fallback?: string,
92
+ ): string => {
93
+ const v = flags[flagKey];
94
+ if (v !== undefined && v !== "" && !interactive) return v;
95
+ const prefilled = v ?? fallback;
96
+ if (!isTty()) {
97
+ if (prefilled !== undefined && prefilled !== "") return prefilled;
98
+ throw new Error(
99
+ `missing ${label} (no TTY for prompts — pass --${flagKey}=…)`,
100
+ );
101
+ }
102
+ return askText(label, prefilled);
103
+ };
104
+
105
+ console.log(`Setting up ${repo} / ${env}\n`);
106
+ console.log(`Repo: ${repo} (override with --repo=<name>)`);
107
+ console.log(`Env: ${env}`);
108
+ if (existingDefaults) {
109
+ console.log(`Defaults: ~/.config/vsync/defaults (pre-filling prompts)\n`);
110
+ } else {
111
+ console.log("Press Ctrl-C to abort. Defaults shown in [brackets].\n");
112
+ }
113
+
114
+ const endpoint = get("endpoint", "S3 endpoint URL", defaultS3.endpoint);
115
+ const region = get("region", "S3 region", defaultS3.region);
116
+ const bucket = get("bucket", "S3 bucket name", defaultS3.bucket);
117
+ const accessKeyId = get("access-key", "S3 access key ID", defaultS3.accessKeyId);
118
+ const secretAccessKey = get(
119
+ "secret-key",
120
+ "S3 secret access key",
121
+ defaultS3.secretAccessKey,
122
+ );
123
+ const useSslRaw = flags["use-ssl"];
124
+ const useSsl =
125
+ useSslRaw !== undefined && !interactive
126
+ ? useSslRaw !== "false"
127
+ : isTty()
128
+ ? askBool("Use TLS for S3?", useSslRaw !== "false" && (defaultS3.useSsl ?? true))
129
+ : defaultS3.useSsl ?? true;
130
+
131
+ const vaultFolderOverride = flags["vault-folder"];
132
+ const defaultVaultFolder = `infra/vault/${env}`;
133
+ const vaultFolder = vaultFolderOverride ?? defaultVaultFolder;
134
+ const hasVaultOverride = !!vaultFolderOverride && vaultFolderOverride !== defaultVaultFolder;
135
+
136
+ const cfg: ConfigFile = {
137
+ version: 1,
138
+ s3: { endpoint, region, bucket, accessKeyId, secretAccessKey, useSsl },
139
+ encryption: { salt: randomSalt() },
140
+ ...(hasVaultOverride ? { files: { vaultFolder } } : {}),
141
+ };
142
+
143
+ const filePath = await saveConfigFile(repo, env, cfg);
144
+ const key = generateKey();
145
+ await setKey(repo, env, key);
146
+
147
+ // First-ever init writes defaults so subsequent inits pre-fill.
148
+ if (!existingDefaults) {
149
+ const defaults: Defaults = {
150
+ version: 1,
151
+ s3: { endpoint, region, bucket, accessKeyId, secretAccessKey, useSsl },
152
+ };
153
+ await saveDefaults(defaults);
154
+ console.log(` defaults: wrote ${defaultsFilePath()}`);
155
+ }
156
+
157
+ // Ensure the vault folder exists.
158
+ const absVault = join(root, vaultFolder);
159
+ mkdirSync(absVault, { recursive: true });
160
+
161
+ // Migrate any pre-existing root .env.<env> into the vault folder.
162
+ await maybeMigrate(root, env, vaultFolder, flags);
163
+
164
+ // Warn if the vault folder's parent isn't in .gitignore.
165
+ warnIfNotGitignored(root, vaultFolder);
166
+
167
+ console.log("\n─────────────────────────────────────────────────────────────");
168
+ console.log("✅ Setup complete");
169
+ console.log("─────────────────────────────────────────────────────────────\n");
170
+ console.log(` config file: ${filePath} (0600)`);
171
+ console.log(
172
+ ` key: OS keychain (service=tools.vsync, account=${repo}/${env})`,
173
+ );
174
+ console.log(` vault: ${absVault}\n`);
175
+ console.log("In your app, point dotenv (or equivalent) at the vault:");
176
+ console.log(` dotenv.config({ path: \`${vaultFolder}/.env.\${env}\` });\n`);
177
+ console.log("Next steps:");
178
+ console.log(` 1. Put your secrets into ${vaultFolder}/.env.${env} (and any vault files alongside).`);
179
+ console.log(` 2. Push to S3:`);
180
+ console.log(` vsync push ${env}`);
181
+ console.log(` 3. Share with a teammate (one file + one passphrase, sent on different channels):`);
182
+ console.log(` vsync export ${env}`);
183
+ console.log(` They'll run:`);
184
+ console.log(` vsync import ${env} <share-file>`);
185
+ }
186
+
187
+ async function maybeMigrate(
188
+ root: string,
189
+ env: string,
190
+ vaultFolder: string,
191
+ flags: Record<string, string>,
192
+ ): Promise<void> {
193
+ if (flags["no-migrate"] === "true") return;
194
+
195
+ const sourceRel =
196
+ flags["migrate-from"] && flags["migrate-from"] !== ""
197
+ ? flags["migrate-from"]
198
+ : `.env.${env}`;
199
+ const sourceAbs = join(root, sourceRel);
200
+ const targetAbs = join(root, vaultFolder, `.env.${env}`);
201
+
202
+ if (!existsSync(sourceAbs)) return;
203
+ if (existsSync(targetAbs)) {
204
+ console.log(
205
+ ` migrate: ${sourceRel} exists but ${vaultFolder}/.env.${env} also exists — leaving both alone.`,
206
+ );
207
+ return;
208
+ }
209
+
210
+ let approved: boolean;
211
+ if (!isTty()) {
212
+ // Non-interactive without --no-migrate → don't silently move user data.
213
+ console.log(
214
+ ` migrate: found ${sourceRel} but no TTY for confirmation; leaving in place. Pass --migrate-from=${sourceRel} interactively or move it manually.`,
215
+ );
216
+ return;
217
+ } else {
218
+ approved = askBool(`Move existing ${sourceRel} to ${vaultFolder}/.env.${env}?`, true);
219
+ }
220
+
221
+ if (approved) {
222
+ renameSync(sourceAbs, targetAbs);
223
+ console.log(` migrate: moved ${sourceRel} → ${vaultFolder}/.env.${env}`);
224
+ } else {
225
+ console.log(
226
+ ` migrate: left ${sourceRel} in place — vsync push will not include it. Move it manually when ready.`,
227
+ );
228
+ }
229
+ }
230
+
231
+ function warnIfNotGitignored(root: string, vaultFolder: string): void {
232
+ const gitignorePath = join(root, ".gitignore");
233
+ if (!existsSync(gitignorePath)) {
234
+ console.log(
235
+ `\n⚠ .gitignore not found at repo root. Add ${dirname(vaultFolder)}/ to keep secrets out of git.`,
236
+ );
237
+ return;
238
+ }
239
+ const content = readFileSync(gitignorePath, "utf8");
240
+ const parent = dirname(vaultFolder);
241
+ const candidates = [
242
+ parent,
243
+ `${parent}/`,
244
+ vaultFolder,
245
+ `${vaultFolder}/`,
246
+ ];
247
+ const covered = candidates.some((c) =>
248
+ content.split(/\r?\n/).some((line) => line.trim() === c),
249
+ );
250
+ if (!covered) {
251
+ console.log(
252
+ `\n⚠ ${parent}/ is not in .gitignore. Add it before committing — secrets in ${vaultFolder} would otherwise be tracked.`,
253
+ );
254
+ }
255
+ }
256
+
257
+ if (import.meta.main) {
258
+ await main(process.argv.slice(2));
259
+ }
package/bin/pull.ts ADDED
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env bun
2
+ // Usage: vsync pull <env> [--repo=<name>]
3
+ //
4
+ // Reads the per-(repo, env) config + keychain key, backs up the current
5
+ // vault folder if any, downloads the latest encrypted bundle from S3,
6
+ // verifies the embedded manifest timestamp matches the `latest` pointer,
7
+ // decrypts, and unpacks into the resolved vault folder.
8
+
9
+ import { existsSync, unlinkSync } from "node:fs";
10
+ import { tmpdir } from "node:os";
11
+ import { join } from "node:path";
12
+ import { parseArgs } from "../src/argv";
13
+ import { getRepoName, getRepoRoot } from "../src/repo";
14
+ import { loadEnvConfig, resolveVaultFolder } from "../src/envconfig";
15
+ import { unzipTo } from "../src/archive";
16
+ import { decrypt } from "../src/crypto";
17
+ import { unwrap } from "../src/manifest";
18
+ import { makeClient } from "../src/s3";
19
+ import { makeBackup } from "../src/backup";
20
+
21
+ export async function main(argv: string[]): Promise<void> {
22
+ const { positional, flags } = parseArgs(argv);
23
+ const env = positional[0];
24
+ if (!env) {
25
+ console.error("usage: vsync pull <env> [--repo=<name>]");
26
+ process.exit(1);
27
+ }
28
+ const repo = await getRepoName({ override: flags.repo });
29
+
30
+ let cfg;
31
+ try {
32
+ cfg = await loadEnvConfig(repo, env);
33
+ } catch (e) {
34
+ console.error((e as Error).message);
35
+ process.exit(1);
36
+ }
37
+ const root = await getRepoRoot();
38
+ const vaultFolder = resolveVaultFolder(cfg, env);
39
+
40
+ const prefixKey = `${repo}/${env.toLowerCase()}/`;
41
+ const pointerKey = `${prefixKey}latest`;
42
+ const client = makeClient(cfg.s3);
43
+
44
+ console.log(`[1/6] backing up local ${vaultFolder}/ (if any)`);
45
+ const backup = await makeBackup(env, root, [vaultFolder], cfg.encryption);
46
+ if (backup) {
47
+ console.log(` → ${backup}`);
48
+ } else {
49
+ console.log(` (no local files yet, skipping)`);
50
+ }
51
+
52
+ console.log(`[2/6] reading pointer s3://${cfg.s3.bucket}/${pointerKey}`);
53
+ const remoteTs = (await client.file(pointerKey).text()).trim();
54
+ if (!remoteTs) {
55
+ console.error(
56
+ `pointer is empty — vsync push ${env} first to seed s3://${cfg.s3.bucket}/${prefixKey}`,
57
+ );
58
+ process.exit(1);
59
+ }
60
+
61
+ const versionKey = `${prefixKey}versions/${remoteTs}.enc`;
62
+ console.log(`[3/6] downloading version ${remoteTs} (${versionKey})`);
63
+ const encrypted = await client.file(versionKey).bytes();
64
+
65
+ console.log(`[4/6] decrypting`);
66
+ let wrapped: Uint8Array;
67
+ try {
68
+ wrapped = await decrypt(encrypted, cfg.encryption.key, cfg.encryption.salt);
69
+ } catch (e) {
70
+ console.error(
71
+ `failed to decrypt s3://${cfg.s3.bucket}/${versionKey} — the keychain key for ${repo}/${env} doesn't match the bundle's seal.\n` +
72
+ `Either the bundle was sealed by a different key (rotated since), or the bucket layout is wrong.\n` +
73
+ `(${(e as Error).message ?? e})`,
74
+ );
75
+ process.exit(1);
76
+ }
77
+
78
+ console.log(`[5/6] verifying manifest ts`);
79
+ const { ts: embeddedTs, payload: zipBytes } = unwrap(wrapped);
80
+ if (embeddedTs !== remoteTs) {
81
+ console.error(
82
+ `pointer claims ${remoteTs} but bundle was sealed as ${embeddedTs} — refusing. Possible bucket tampering.`,
83
+ );
84
+ process.exit(1);
85
+ }
86
+
87
+ const tmpZip = join(
88
+ tmpdir(),
89
+ `pull-${remoteTs}-${Math.random().toString(36).slice(2)}.zip`,
90
+ );
91
+ try {
92
+ await Bun.write(tmpZip, zipBytes);
93
+ console.log(`[6/6] unzipping into ${root}`);
94
+ await unzipTo(tmpZip, root);
95
+ console.log(`✅ pulled ${repo}/${env} version ${remoteTs}`);
96
+ } finally {
97
+ if (existsSync(tmpZip)) unlinkSync(tmpZip);
98
+ }
99
+ }
100
+
101
+ if (import.meta.main) {
102
+ await main(process.argv.slice(2));
103
+ }
package/bin/push.ts ADDED
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env bun
2
+ // Usage: vsync push <env> [--repo=<name>]
3
+ //
4
+ // Reads the per-(repo, env) config + keychain key, zips the resolved
5
+ // vault folder (cfg.files.vaultFolder ?? infra/vault/<env>), encrypts
6
+ // with the keychain-stored AES key, and uploads versioned + pointer-
7
+ // sealed bundles to S3. See pull.ts for the inverse.
8
+
9
+ import { existsSync, statSync, unlinkSync } from "node:fs";
10
+ import { tmpdir } from "node:os";
11
+ import { join } from "node:path";
12
+ import { parseArgs } from "../src/argv";
13
+ import { getRepoName, getRepoRoot } from "../src/repo";
14
+ import { loadEnvConfig, resolveVaultFolder } from "../src/envconfig";
15
+ import { zipPaths } from "../src/archive";
16
+ import { encrypt } from "../src/crypto";
17
+ import { wrap } from "../src/manifest";
18
+ import { makeClient } from "../src/s3";
19
+ import { timestamp } from "../src/backup";
20
+
21
+ export async function main(argv: string[]): Promise<void> {
22
+ const { positional, flags } = parseArgs(argv);
23
+ const env = positional[0];
24
+ if (!env) {
25
+ console.error("usage: vsync push <env> [--repo=<name>]");
26
+ process.exit(1);
27
+ }
28
+ const repo = await getRepoName({ override: flags.repo });
29
+
30
+ let cfg;
31
+ try {
32
+ cfg = await loadEnvConfig(repo, env);
33
+ } catch (e) {
34
+ console.error((e as Error).message);
35
+ process.exit(1);
36
+ }
37
+ const root = await getRepoRoot();
38
+
39
+ const vaultFolder = resolveVaultFolder(cfg, env);
40
+ const absVault = join(root, vaultFolder);
41
+ if (!existsSync(absVault) || !statSync(absVault).isDirectory()) {
42
+ console.error(
43
+ `vault folder not found: ${absVault}\n` +
44
+ `Create it and put your secrets inside (e.g. ${vaultFolder}/.env.${env}).`,
45
+ );
46
+ process.exit(1);
47
+ }
48
+
49
+ const ts = timestamp();
50
+ const prefixKey = `${repo}/${env.toLowerCase()}/`;
51
+ const versionKey = `${prefixKey}versions/${ts}.enc`;
52
+ const pointerKey = `${prefixKey}latest`;
53
+
54
+ const tmpZip = join(
55
+ tmpdir(),
56
+ `push-${ts}-${Math.random().toString(36).slice(2)}.zip`,
57
+ );
58
+
59
+ try {
60
+ console.log(`[1/5] zipping ${vaultFolder}/`);
61
+ await zipPaths(root, [vaultFolder], tmpZip);
62
+
63
+ console.log(`[2/5] sealing manifest ts=${ts}`);
64
+ const zipBytes = await Bun.file(tmpZip).bytes();
65
+ const wrapped = wrap(ts, zipBytes);
66
+
67
+ console.log(`[3/5] encrypting`);
68
+ const encrypted = await encrypt(wrapped, cfg.encryption.key, cfg.encryption.salt);
69
+
70
+ console.log(`[4/5] uploading ${encrypted.byteLength} bytes → s3://${cfg.s3.bucket}/${versionKey}`);
71
+ const client = makeClient(cfg.s3);
72
+ await client.file(versionKey).write(encrypted);
73
+
74
+ console.log(`[5/5] updating pointer → s3://${cfg.s3.bucket}/${pointerKey}`);
75
+ await client.file(pointerKey).write(ts);
76
+
77
+ console.log(`✅ pushed ${repo}/${env} (version: ${ts})`);
78
+ } finally {
79
+ if (existsSync(tmpZip)) unlinkSync(tmpZip);
80
+ }
81
+ }
82
+
83
+ if (import.meta.main) {
84
+ await main(process.argv.slice(2));
85
+ }