@muthuishere/vsync 0.3.0 → 0.5.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/use.ts ADDED
@@ -0,0 +1,175 @@
1
+ #!/usr/bin/env bun
2
+ // Usage: vsync use <env> [--link=<path>] [--repo=<name>]
3
+ // vsync use [--link=<path>] (no env → print current target)
4
+ //
5
+ // Creates a symlink at `<link>` (default `./.env`) pointing at
6
+ // `<vaultFolder>/.env.<env>`, so apps can just `dotenv.config()` and pick up
7
+ // vault contents. Switch envs with `vsync use <other-env>`.
8
+ //
9
+ // Examples:
10
+ // vsync use dev # ./.env → infra/vault/dev/.env.dev
11
+ // vsync use dev --link=.env.dev # ./.env.dev → … (keep .env free)
12
+ // vsync use prod --link=apps/web/.env # ./apps/web/.env → … (monorepo)
13
+ //
14
+ // Safety:
15
+ // - If <link> is a regular file (not a symlink) we NEVER touch it — even
16
+ // with a flag. The user must rename or delete it first.
17
+ // - An existing symlink at <link> is replaced silently (cheap to recreate).
18
+ // - Warns if the link's basename isn't covered by `.gitignore`.
19
+ //
20
+ // Cross-platform: uses POSIX symlinks via `symlink(target, path, "file")`.
21
+ // Works on macOS / Linux / WSL out of the box. On Windows, requires
22
+ // Developer Mode (Settings → Privacy & security → For developers) OR an
23
+ // elevated terminal — Windows file symlinks are a privileged op. Junctions
24
+ // are dir-only and don't apply here.
25
+
26
+ import {
27
+ existsSync,
28
+ lstatSync,
29
+ readFileSync,
30
+ readlinkSync,
31
+ symlinkSync,
32
+ unlinkSync,
33
+ } from "node:fs";
34
+ import { basename, dirname, join, relative, resolve } from "node:path";
35
+ import { parseArgs } from "../src/argv";
36
+ import { getRepoName, getRepoRoot } from "../src/repo";
37
+ import { loadConfigFile } from "../src/repoconfig";
38
+
39
+ export async function main(argv: string[]): Promise<void> {
40
+ const { positional, flags } = parseArgs(argv);
41
+ const env = positional[0];
42
+ const root = await getRepoRoot();
43
+ // All link paths resolve against the repo root — matches every other
44
+ // vsync verb (push, pull, sync, audit). So `vsync use dev --link=.env.dev`
45
+ // lands the symlink at <repoRoot>/.env.dev regardless of cwd.
46
+ const linkPath = flags.link
47
+ ? resolve(root, flags.link)
48
+ : join(root, ".env");
49
+
50
+ // No env → print current target (or absence) and exit.
51
+ if (!env) {
52
+ const st = safeLstat(linkPath);
53
+ if (!st) {
54
+ console.log(`(no ${relativeToRoot(linkPath, root)})`);
55
+ return;
56
+ }
57
+ if (st.isSymbolicLink()) {
58
+ console.log(
59
+ `${relativeToRoot(linkPath, root)} → ${readlinkSync(linkPath)}`,
60
+ );
61
+ } else {
62
+ console.log(
63
+ `${relativeToRoot(linkPath, root)} (regular file — not a vsync symlink, untouched)`,
64
+ );
65
+ }
66
+ return;
67
+ }
68
+
69
+ const repo = await getRepoName({ override: flags.repo });
70
+ const cfg = await loadConfigFile(repo, env);
71
+ if (!cfg) {
72
+ console.error(
73
+ `no config for ${repo}/${env}. Run 'vsync init ${env}' or 'vsync import ${env} <share-file>' first.`,
74
+ );
75
+ process.exit(1);
76
+ }
77
+
78
+ const vaultFolder =
79
+ cfg.files?.vaultFolder ?? `infra/vault/${env.toLowerCase()}`;
80
+ const target = join(root, vaultFolder, `.env.${env}`);
81
+ if (!existsSync(target)) {
82
+ console.error(
83
+ `expected ${target} to exist — run 'vsync pull ${env}' first.`,
84
+ );
85
+ process.exit(1);
86
+ }
87
+
88
+ if (!existsSync(dirname(linkPath))) {
89
+ console.error(
90
+ `directory ${dirname(linkPath)} does not exist — create it or pick a different --link path.`,
91
+ );
92
+ process.exit(1);
93
+ }
94
+
95
+ const existing = safeLstat(linkPath);
96
+ if (existing) {
97
+ if (!existing.isSymbolicLink()) {
98
+ const rel = relativeToRoot(linkPath, root);
99
+ console.error(
100
+ `${rel} exists as a regular file — refusing to touch it (no --force, by design).\n` +
101
+ `Move or delete it first if you want vsync to manage it:\n` +
102
+ ` mv ${rel} ${rel}.local.bak # or: rm ${rel}\n` +
103
+ `then re-run 'vsync use ${env}${flags.link ? ` --link=${flags.link}` : ""}'.`,
104
+ );
105
+ process.exit(1);
106
+ }
107
+ unlinkSync(linkPath);
108
+ }
109
+
110
+ const symlinkTarget = relative(dirname(linkPath), target);
111
+ try {
112
+ // Pass "file" type for Windows; ignored on POSIX.
113
+ symlinkSync(symlinkTarget, linkPath, "file");
114
+ } catch (e: any) {
115
+ if (process.platform === "win32" && (e?.code === "EPERM" || e?.code === "EACCES")) {
116
+ console.error(
117
+ `Windows symlinks require Developer Mode or admin privileges.\n` +
118
+ `Enable Developer Mode: Settings → Privacy & security → For developers,\n` +
119
+ `or run this command in an elevated terminal.`,
120
+ );
121
+ process.exit(1);
122
+ }
123
+ throw e;
124
+ }
125
+ console.log(`✅ ${relativeToRoot(linkPath, root)} → ${symlinkTarget}`);
126
+
127
+ // Gitignore hint — only meaningful when the link sits inside the repo.
128
+ const linkRel = relative(root, linkPath);
129
+ if (linkRel.startsWith("..") || resolve(root, linkRel) !== linkPath) {
130
+ return; // link is outside the repo; nothing to gitignore
131
+ }
132
+ const linkBase = basename(linkPath);
133
+ const giPath = join(root, ".gitignore");
134
+ let gitignored = false;
135
+ if (existsSync(giPath)) {
136
+ const lines = readFileSync(giPath, "utf8")
137
+ .split(/\r?\n/)
138
+ .map((l) => l.trim());
139
+ gitignored = lines.some((l) => coversBasename(l, linkBase));
140
+ }
141
+ if (!gitignored) {
142
+ console.error(
143
+ `⚠ ${linkRel} is not covered by .gitignore — add a rule for it before committing`,
144
+ );
145
+ }
146
+ }
147
+
148
+ function safeLstat(p: string) {
149
+ try {
150
+ return lstatSync(p);
151
+ } catch {
152
+ return null;
153
+ }
154
+ }
155
+
156
+ function relativeToRoot(p: string, root: string): string {
157
+ const r = relative(root, p);
158
+ return r.startsWith("..") ? p : `./${r}`;
159
+ }
160
+
161
+ function coversBasename(pattern: string, name: string): boolean {
162
+ if (!pattern || pattern.startsWith("#")) return false;
163
+ const p = pattern.replace(/^\//, "").replace(/\/$/, "");
164
+ if (p === name) return true;
165
+ // Common glob patterns we recognise without a full gitignore parser.
166
+ if (p === ".env*" && name.startsWith(".env")) return true;
167
+ if (p === ".env.*" && name.startsWith(".env.")) return true;
168
+ if (p === "*.env" && name.endsWith(".env")) return true;
169
+ if (p === "*" || p === "**") return true;
170
+ return false;
171
+ }
172
+
173
+ if (import.meta.main) {
174
+ await main(process.argv.slice(2));
175
+ }
package/bin/vsync.ts CHANGED
@@ -13,10 +13,12 @@ const SUBCOMMANDS = [
13
13
  "init",
14
14
  "export",
15
15
  "import",
16
+ "use",
16
17
  "push",
17
18
  "pull",
18
19
  "versions",
19
20
  "sync",
21
+ "audit",
20
22
  "docs",
21
23
  ] as const;
22
24
  type Subcommand = (typeof SUBCOMMANDS)[number];
@@ -32,6 +34,10 @@ function usage(code = 0): never {
32
34
  out(" export <env> [--out=path] write a passphrase-encrypted .share file");
33
35
  out(" import <env> <share-file> restore a .share file into local config + keychain");
34
36
  out("");
37
+ out("environment switch");
38
+ out(" use <env> [--link=<path>] symlink <path> (default ./.env) → <vaultFolder>/.env.<env>");
39
+ out(" use print current ./.env (or --link=<path>) target");
40
+ out("");
35
41
  out("day-to-day");
36
42
  out(" push <env> encrypt + upload local vault folder to s3://<bucket>/<repo>/<env>/");
37
43
  out(" pull <env> download from s3://<bucket>/<repo>/<env>/ + decrypt + unpack");
@@ -40,6 +46,9 @@ function usage(code = 0): never {
40
46
  out("external fanout");
41
47
  out(" sync <env> <gh|gcp|all> push <vaultFolder>/.env.<env> KVs to GH/GCP secret stores");
42
48
  out("");
49
+ out("visibility");
50
+ out(" audit <env> [--limit=N|--all|--csv] show the append-only S3 audit log for this (repo, env)");
51
+ out("");
43
52
  out("docs");
44
53
  out(" docs print the onboarding reference to stdout");
45
54
  out("");
@@ -74,6 +83,11 @@ switch (subcommand as Subcommand) {
74
83
  await main(subArgv);
75
84
  break;
76
85
  }
86
+ case "use": {
87
+ const { main } = await import("./use");
88
+ await main(subArgv);
89
+ break;
90
+ }
77
91
  case "push": {
78
92
  const { main } = await import("./push");
79
93
  await main(subArgv);
@@ -94,6 +108,11 @@ switch (subcommand as Subcommand) {
94
108
  await main(subArgv);
95
109
  break;
96
110
  }
111
+ case "audit": {
112
+ const { main } = await import("./audit");
113
+ await main(subArgv);
114
+ break;
115
+ }
97
116
  case "docs": {
98
117
  const { main } = await import("./docs");
99
118
  await main(subArgv);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@muthuishere/vsync",
3
- "version": "0.3.0",
3
+ "version": "0.5.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/argv.ts CHANGED
@@ -1,14 +1,20 @@
1
1
  // Minimal argv parser: positional args + --key=value (or --key) flags.
2
2
  // Stops parsing at `--` (everything after is positional).
3
+ //
4
+ // `flags` records the **last** value for each --key (back-compat with every
5
+ // existing caller). `lists` records **every** value, so callers that want
6
+ // repeatable flags (e.g. `--meta k=v --meta k2=v2`) can read them from there.
3
7
 
4
8
  export type ParsedArgs = {
5
9
  positional: string[];
6
10
  flags: Record<string, string>;
11
+ lists: Record<string, string[]>;
7
12
  };
8
13
 
9
14
  export function parseArgs(argv: string[]): ParsedArgs {
10
15
  const positional: string[] = [];
11
16
  const flags: Record<string, string> = {};
17
+ const lists: Record<string, string[]> = {};
12
18
  let passthrough = false;
13
19
  for (const arg of argv) {
14
20
  if (passthrough) {
@@ -21,14 +27,20 @@ export function parseArgs(argv: string[]): ParsedArgs {
21
27
  }
22
28
  if (arg.startsWith("--")) {
23
29
  const eq = arg.indexOf("=");
30
+ let key: string;
31
+ let value: string;
24
32
  if (eq === -1) {
25
- flags[arg.slice(2)] = "true";
33
+ key = arg.slice(2);
34
+ value = "true";
26
35
  } else {
27
- flags[arg.slice(2, eq)] = arg.slice(eq + 1);
36
+ key = arg.slice(2, eq);
37
+ value = arg.slice(eq + 1);
28
38
  }
39
+ flags[key] = value;
40
+ (lists[key] ??= []).push(value);
29
41
  } else {
30
42
  positional.push(arg);
31
43
  }
32
44
  }
33
- return { positional, flags };
45
+ return { positional, flags, lists };
34
46
  }