@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/README.md ADDED
@@ -0,0 +1,260 @@
1
+ # vsync
2
+
3
+ Encrypted secret-sync CLI for small teams.
4
+
5
+ - **One canonical store on S3** — your `infra/vault/<env>/` folder, sealed with AES-256-GCM and a manifest pointer that prevents silent rollback.
6
+ - **Per-machine encryption key** in the OS keychain (`Bun.secrets` — macOS Keychain, Linux libsecret, Windows Credential Manager).
7
+ - **Fanout** to GitHub Repo Secrets and GCP Secret Manager from the same source of truth.
8
+ - **Share file** for onboarding teammates with one passphrase-protected `.share` and one passphrase, sent on different channels.
9
+
10
+ ```bash
11
+ bunx @muthuishere/vsync --help
12
+ ```
13
+
14
+ No shell-rc edits. No giant base64 blob in `~/.zshrc`. Run via `bunx`; nothing to install.
15
+
16
+ ---
17
+
18
+ ## Mental model
19
+
20
+ Two persistent halves per (repo, env). Both required to push or pull:
21
+
22
+ ```
23
+ ┌──────────────────────────────────────────────────────────────────┐
24
+ │ Disk (chmod 0600) │
25
+ │ ~/.config/vsync/<repo>/env_<env> self-contained config │
26
+ │ ├── s3.{endpoint, region, bucket, …} required │
27
+ │ ├── encryption.salt random per init │
28
+ │ ├── files.vaultFolder optional override │
29
+ │ │ (default infra/vault/<env>)│
30
+ │ └── sync.{gh.repo, gcp.project} set by `vsync sync` │
31
+ │ ~/.config/vsync/defaults pre-fills `init` only │
32
+ └──────────────────────────────────────────────────────────────────┘
33
+
34
+ ┌──────────────────────────────────────────────────────────────────┐
35
+ │ OS keychain (Bun.secrets) │
36
+ │ service: tools.vsync │
37
+ │ account: <repo>/<env> │
38
+ │ value: <base64 32-byte AES-256 key> │
39
+ └──────────────────────────────────────────────────────────────────┘
40
+ ```
41
+
42
+ Anyone with **(S3 read access to the bucket)** AND **(the encryption key in their keychain)** can pull. Either alone is useless: the disk file gets you bucket access but no decrypt; the key gets you decrypt but no bucket location.
43
+
44
+ The per-repo file is self-contained — `push`/`pull`/`sync` never read a second config. `~/.config/vsync/defaults` is consulted *only* by `init` to pre-fill prompts on subsequent setups.
45
+
46
+ In your repo, all secret content lives in one place. Default layout:
47
+
48
+ ```
49
+ infra/vault/
50
+ dev/
51
+ .env.dev
52
+ some-secret.json
53
+ ...
54
+ production/
55
+ .env.production
56
+ ```
57
+
58
+ Apps point dotenv (or equivalent) at the path:
59
+
60
+ ```js
61
+ dotenv.config({ path: `infra/vault/${env}/.env.${env}` });
62
+ ```
63
+
64
+ `vsync init` prints the dotenv snippet so you copy it once.
65
+
66
+ **Monorepos:** override the vault folder per (repo, env) at init time — `vsync init dev --vault-folder=apps/foo/infra/vault/dev`. The override is stored per-repo, used by every subsequent `push`/`pull`/`sync`, and carried in the `.share` file so teammates inherit it.
67
+
68
+ ---
69
+
70
+ ## Install
71
+
72
+ You don't. Run via `bunx`:
73
+
74
+ ```bash
75
+ bunx @muthuishere/vsync <subcommand>
76
+ ```
77
+
78
+ Requires Bun ≥ 1.2.21 (for `Bun.secrets`). For local development of vsync itself:
79
+
80
+ ```bash
81
+ git clone git@github.com:muthuishere/vsync.git
82
+ cd vsync
83
+ bun install
84
+ bun test
85
+ ```
86
+
87
+ ---
88
+
89
+ ## Quickstart — owner (first time on a project)
90
+
91
+ ```bash
92
+ # 1. Generate the per-(repo, env) key + config. First-ever invocation prompts
93
+ # for S3 creds; subsequent inits pre-fill from ~/.config/vsync/defaults.
94
+ bunx @muthuishere/vsync init dev
95
+
96
+ # 2. Put your secrets under infra/vault/dev/ and push.
97
+ echo "DATABASE_URL=postgres://..." > infra/vault/dev/.env.dev
98
+ bunx @muthuishere/vsync push dev
99
+
100
+ # 3. Hand the team a share file + passphrase (different channels).
101
+ bunx @muthuishere/vsync export dev
102
+ ```
103
+
104
+ For an onboarding cheat sheet to drop into your repo (so teammates and AI agents know vsync exists), run `vsync docs > infra/AGENTS.md`. Plain stdout — pipe it wherever you want.
105
+
106
+ ## Quickstart — teammate (joining the project)
107
+
108
+ ```bash
109
+ cd <cloned-repo>
110
+
111
+ # 1. Import the share file your teammate sent (carries S3 creds + key).
112
+ # No prior `init` required on this machine.
113
+ bunx @muthuishere/vsync import dev ./reqsume-dev.share
114
+ # Passphrase: <paste>
115
+
116
+ # 2. Pull the encrypted bundle.
117
+ bunx @muthuishere/vsync pull dev
118
+ ```
119
+
120
+ After step 2, `infra/vault/dev/` is populated and the encryption key is in your keychain.
121
+
122
+ ## Daily flow
123
+
124
+ ```bash
125
+ # I edited infra/vault/dev/.env.dev locally:
126
+ bunx @muthuishere/vsync push dev
127
+
128
+ # Get the latest from S3:
129
+ bunx @muthuishere/vsync pull dev
130
+
131
+ # See what versions exist on S3:
132
+ bunx @muthuishere/vsync versions dev
133
+
134
+ # Push secrets out to GitHub / GCP:
135
+ bunx @muthuishere/vsync sync dev gh
136
+ bunx @muthuishere/vsync sync dev gcp
137
+ bunx @muthuishere/vsync sync dev all
138
+ ```
139
+
140
+ `pull` makes a local backup at `~/.config/vsync/backups/<env>-<ts>.zip.enc` before overwriting (two-deep rolling buffer). See "Recovering a local backup" below if you ever need one.
141
+
142
+ ---
143
+
144
+ ## Subcommand reference
145
+
146
+ All commands accept `--repo=<name>` (override auto-detected repo name) and `--interactive` (force prompts even when every flag is provided). Auto-detected repo precedence: `$SECRETS_SYNC_REPO` → `package.json::name` (scope-stripped) → git toplevel basename → cwd basename.
147
+
148
+ Every command works fully via flags or fully via prompts.
149
+
150
+ | Cmd | Purpose |
151
+ |---|---|
152
+ | `init <env>` | Generate AES key (→ keychain), write self-contained per-repo config, create the resolved vault folder, relocate an existing root `.env.<env>` if found (with a prompt). First-ever run on a machine also writes `~/.config/vsync/defaults` from the supplied values; subsequent runs pre-fill from defaults. Flags: `--bucket --endpoint --region --access-key --secret-key --use-ssl --vault-folder=<path> --migrate-from=<path> --no-migrate`. |
153
+ | `export <env>` | Write a passphrase-encrypted `.share` file containing the full per-repo config + key. Flags: `--out=<path>` (default `./<repo>-<env>.share`), `--passphrase=<p>` (default: auto-generated readable passphrase). |
154
+ | `import <env> <file>` | Decrypt a `.share` file with its passphrase; write the per-repo config + save key to keychain. Idempotent — re-importing overwrites. Flags: `--passphrase=<p>`, `--file=<path>` (alt to positional). |
155
+ | `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`. |
156
+ | `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. |
157
+ | `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. |
158
+ | `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>`. |
159
+ | `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`. |
160
+
161
+ ### `sync` env-file parsing
162
+
163
+ Two special-case keys (path → file content inlining):
164
+
165
+ - `GCP_SA_KEY_FILE_PATH=<path>` → reads the file, pushes the contents as `GCP_SA_KEY` (must look like JSON).
166
+ - `SSH_KEY_PATH=<path>` → reads the file, pushes as `SSH_PRIVATE_KEY`.
167
+
168
+ Two local-only keys (skipped — used by `gh` / `gcloud` on the local machine, not pushed):
169
+
170
+ - `GITHUB_TOKEN`
171
+ - `GOOGLE_APPLICATION_CREDENTIALS`
172
+
173
+ Everything else is pushed verbatim.
174
+
175
+ ---
176
+
177
+ ## How sync works (gh + gcp)
178
+
179
+ Auth is **outside vsync's scope** — the lib trusts whatever `gh` and `gcloud` are doing on your machine.
180
+
181
+ **`vsync sync <env> gh`:**
182
+ 1. Resolves `sync.gh.repo` from per-repo config (or `--gh-repo` flag, or first-run prompt).
183
+ 2. Parses `<vaultFolder>/.env.<env>` into push-ready KVs (after special-case + skip rules).
184
+ 3. For each KV in a 6-worker pool: `gh secret set <KEY> --env <env> --repo <sync.gh.repo>` with the value on stdin.
185
+ 4. Requires `gh` CLI installed and `gh auth login` already done.
186
+
187
+ **`vsync sync <env> gcp`:**
188
+ 1. Resolves `sync.gcp.project` similarly.
189
+ 2. Same parse step.
190
+ 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=-`.
191
+ 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.
192
+
193
+ **`vsync sync <env> all`** runs both in sequence. Failures don't abort siblings; final summary lists what failed.
194
+
195
+ ---
196
+
197
+ ## Security model
198
+
199
+ | Threat | Defence |
200
+ |---|---|
201
+ | Attacker reads disk config only | Gets bucket creds + routing. Cannot decrypt any S3 bundle. |
202
+ | Attacker reads keychain only | Gets the AES key. No bucket location. No reach. |
203
+ | Attacker reads both | Compromises the (repo, env). Rotate immediately. |
204
+ | Attacker intercepts the `.share` file | Cannot decrypt without the passphrase. Mitigation: send file + passphrase on different channels. |
205
+ | Attacker tampers with an S3 object | Pull-time manifest-pointer check (`embeddedTs === remoteTs`) rejects renamed-old-bundles. AES-GCM auth tag rejects byte-level tampering. |
206
+ | Local user on shared machine | `chmod 0600` on the file + `0700` on the dir = POSIX denies other users. macOS Keychain ACLs deny other login sessions. |
207
+
208
+ **Crypto:** AES-256-GCM with a per-encryption 12-byte random IV. Envelope magic `RQE1`. PBKDF2-SHA256 (600k iters) over (keychain-key, per-repo salt) for the S3 envelope, and over (user passphrase, share-file salt) for the share-file wrapper. Manifest pointer-seal magic `RQEM0001`. Share file outer frame magic `SLS1`.
209
+
210
+ **Offboarding:** there's no per-user revoke. When someone leaves: revoke their bucket access at the cloud provider (separate axis), then rotate the encryption key by re-`init`-ing the (repo, env) and re-`export`-ing for surviving teammates. Per-user audit and a built-in `rotate-key` are explicitly out of scope.
211
+
212
+ **Inspecting / removing the keychain entry** is done with your OS tools — Keychain Access.app on macOS, `secret-tool` / `seahorse` on Linux, Credential Manager on Windows. vsync doesn't ship verbs to wrap those.
213
+
214
+ ---
215
+
216
+ ## Recovering a local backup
217
+
218
+ Before each `pull`, vsync writes the existing vault folder to `~/.config/vsync/backups/<env>-<ts>.zip.enc` (two-deep rolling buffer). The format is AES-256-GCM with the same per-(repo, env) keychain key + salt. To decrypt one by hand:
219
+
220
+ 1. Get the key — on macOS: `security find-generic-password -s tools.vsync -a <repo>/<env> -w`. On Linux: `secret-tool lookup service tools.vsync account <repo>/<env>`.
221
+ 2. Get the salt: `gunzip -c ~/.config/vsync/<repo>/env_<env> | jq -r .encryption.salt`.
222
+ 3. The envelope is `RQE1` (4-byte magic) + 12-byte IV + AES-GCM ciphertext. Derive: `AES-GCM key = PBKDF2-SHA256(keychain-key, salt, 600k)`.
223
+
224
+ In practice, just don't lose the keychain entry. `pull` itself is the recovery path 99% of the time.
225
+
226
+ ---
227
+
228
+ ## Troubleshooting
229
+
230
+ **"no config file for `<repo>/<env>`"** — the per-repo file isn't on disk. Run `vsync init <env>` to create one, or `vsync import <env> <share-file>` if a teammate sent you one.
231
+
232
+ **"encryption key for `<repo>/<env>` not found in keychain"** — the file exists but the keychain entry is gone. Re-run `import` (carries both halves), or re-`init` if you don't have a `.share` (generates a fresh key, so any prior S3 bundle becomes inaccessible to you — re-`push` from local).
233
+
234
+ **"failed to decrypt share file — passphrase wrong or file corrupt"** — double-check the passphrase. If still failing, ask the sender to re-export.
235
+
236
+ **"pointer claims X but bundle was sealed as Y" during pull** — defensive anti-rollback check failed. Someone with bucket-write access pointed `latest` at a renamed older bundle, but the embedded manifest timestamp doesn't match. Refuse + report to ops.
237
+
238
+ **`gh` / `gcloud` not found on PATH** — install and authenticate them locally. vsync shells out; it doesn't manage external CLI auth.
239
+
240
+ ---
241
+
242
+ ## Versioning
243
+
244
+ This is **0.3.0** — a clean break from `@muthuishere/secret-lib` 0.2.x. New package name, new bin (`vsync`), new keychain service (`tools.vsync`), new config root (`~/.config/vsync/`), new vault layout (`infra/vault/<env>/.env.<env>`). The crypto envelope (`RQE1`) is unchanged.
245
+
246
+ 0.3.x does not auto-migrate from 0.2.x. The supported upgrade path is to re-`init` from scratch:
247
+
248
+ ```bash
249
+ vsync init dev # auto-relocates root .env.dev if it exists
250
+ vsync push dev
251
+ vsync export dev # re-share with team
252
+ ```
253
+
254
+ Any leftover 0.2.x on-disk config tree and keychain entries can be deleted; nothing in 0.3.x reads them. `@muthuishere/secret-lib` 0.2.x stays on npm for users who can't migrate.
255
+
256
+ ---
257
+
258
+ ## License
259
+
260
+ MIT.
package/bin/docs.ts ADDED
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env bun
2
+ // Usage: vsync docs
3
+ //
4
+ // Prints a short onboarding reference (commands, vault layout, backup
5
+ // recovery procedure, agent rules) to stdout. Pipe wherever you want:
6
+ // vsync docs > infra/AGENTS.md
7
+ //
8
+ // No flags, no prompts. Content is a static string in src/templates/
9
+ // docs.md.ts so it ships with the binary and stays in sync with the
10
+ // verb set.
11
+
12
+ import { DOCS_MD } from "../src/templates/docs.md";
13
+
14
+ export async function main(_argv: string[]): Promise<void> {
15
+ process.stdout.write(DOCS_MD);
16
+ }
17
+
18
+ if (import.meta.main) {
19
+ await main(process.argv.slice(2));
20
+ }
package/bin/export.ts ADDED
@@ -0,0 +1,92 @@
1
+ #!/usr/bin/env bun
2
+ // Usage: vsync export <env> [--repo=<name>] [--out=<path>] [--passphrase=<pp>] [--interactive]
3
+ //
4
+ // Bundles the on-disk per-repo config + the keychain-stored AES key into a
5
+ // passphrase-encrypted .share file that's safe to send via any channel
6
+ // (Slack DM, AirDrop, email — same envelope used by S3 pushes). The
7
+ // passphrase is auto-generated if not supplied and printed to stdout for
8
+ // the user to copy.
9
+ //
10
+ // Default output path: ./<repo>-<env>.share
11
+
12
+ import { parseArgs } from "../src/argv";
13
+ import { getRepoName } from "../src/repo";
14
+ import { loadConfigFile, configFilePath } from "../src/repoconfig";
15
+ import { getKey } from "../src/keychain";
16
+ import { EXPORT_BLOB_VERSION, type ExportPayload } from "../src/envconfig";
17
+ import { buildShareFile } from "../src/sharefile";
18
+ import { generatePassphrase } from "../src/passphrase";
19
+ import { askText, isTty } from "../src/prompt";
20
+ import { writeFile } from "node:fs/promises";
21
+ import { resolve } from "node:path";
22
+
23
+ export async function main(argv: string[]): Promise<void> {
24
+ const { positional, flags } = parseArgs(argv);
25
+ const env = positional[0];
26
+ if (!env) {
27
+ console.error("usage: vsync export <env> [--repo=<name>] [--out=<path>] [--passphrase=<pp>]");
28
+ process.exit(1);
29
+ }
30
+ const interactive = flags.interactive === "true";
31
+ const repo = await getRepoName({ override: flags.repo });
32
+
33
+ const cfg = await loadConfigFile(repo, env);
34
+ if (!cfg) {
35
+ console.error(
36
+ `no config file for ${repo}/${env} at ${configFilePath(repo, env)}.\n` +
37
+ `Run 'vsync init ${env}' first, or 'vsync import ${env} <share-file>' if a teammate sent you one.`,
38
+ );
39
+ process.exit(1);
40
+ }
41
+ const key = await getKey(repo, env);
42
+ if (!key) {
43
+ console.error(
44
+ `encryption key for ${repo}/${env} not found in OS keychain.\n` +
45
+ `Re-run 'vsync init ${env}' to generate a fresh one (will not match prior S3 bundles).`,
46
+ );
47
+ process.exit(1);
48
+ }
49
+
50
+ let passphrase = flags.passphrase;
51
+ if ((!passphrase || interactive) && isTty()) {
52
+ const generated = generatePassphrase();
53
+ if (interactive) {
54
+ const custom = askText(
55
+ `Passphrase to encrypt the share file (blank → use generated "${generated}")`,
56
+ );
57
+ passphrase = custom || generated;
58
+ } else {
59
+ passphrase = generated;
60
+ }
61
+ }
62
+ if (!passphrase) {
63
+ passphrase = generatePassphrase();
64
+ }
65
+
66
+ const out = resolve(flags.out ?? `./${repo}-${env}.share`);
67
+
68
+ const payload: ExportPayload = {
69
+ version: EXPORT_BLOB_VERSION,
70
+ repo,
71
+ env,
72
+ config: cfg,
73
+ key,
74
+ };
75
+
76
+ const bytes = await buildShareFile(payload, passphrase);
77
+ await writeFile(out, bytes, { mode: 0o600 });
78
+
79
+ console.log("\n─────────────────────────────────────────────────────────────");
80
+ console.log("✅ Share file written");
81
+ console.log("─────────────────────────────────────────────────────────────\n");
82
+ console.log(` file: ${out}`);
83
+ console.log(` passphrase: ${passphrase}\n`);
84
+ console.log("Send the file and the passphrase to your teammate on TWO different channels");
85
+ console.log("(e.g. file via Slack DM, passphrase via SMS).\n");
86
+ console.log("They will run:");
87
+ console.log(` vsync import ${env} ${out.split("/").pop()}`);
88
+ }
89
+
90
+ if (import.meta.main) {
91
+ await main(process.argv.slice(2));
92
+ }
package/bin/import.ts ADDED
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env bun
2
+ // Usage: vsync import <env> [<share-file>] [--repo=<name>] [--passphrase=<pp>] [--interactive]
3
+ //
4
+ // Reads the encrypted share file produced by `vsync export`, prompts
5
+ // for (or accepts via flag) the passphrase, decrypts, and installs:
6
+ // - the per-repo config to ~/.config/vsync/<repo>/env_<env>
7
+ // - the encryption key into the OS keychain (tools.vsync / <repo>/<env>)
8
+ //
9
+ // After import you're ready to `vsync pull <env>` / `push <env>`.
10
+
11
+ import { parseArgs } from "../src/argv";
12
+ import { getRepoName } from "../src/repo";
13
+ import { saveConfigFile, configFilePath } from "../src/repoconfig";
14
+ import { setKey } from "../src/keychain";
15
+ import { parseShareFile } from "../src/sharefile";
16
+ import { askText, askSecret, isTty } from "../src/prompt";
17
+ import { readFile } from "node:fs/promises";
18
+ import { resolve } from "node:path";
19
+
20
+ export async function main(argv: string[]): Promise<void> {
21
+ const { positional, flags } = parseArgs(argv);
22
+ const env = positional[0];
23
+ let filePath = positional[1] ?? flags.file;
24
+ if (!env) {
25
+ console.error(
26
+ "usage: vsync import <env> [<share-file>] [--repo=<name>] [--passphrase=<pp>]",
27
+ );
28
+ process.exit(1);
29
+ }
30
+
31
+ // CLI override for repo *during import*. Whichever the file embedded for
32
+ // `repo` is treated as truth unless the user passes --repo to remap.
33
+ const repoOverride = flags.repo;
34
+
35
+ if (!filePath) {
36
+ if (!isTty()) {
37
+ console.error("missing share-file path (positional or --file=…) and stdin is not a TTY");
38
+ process.exit(1);
39
+ }
40
+ filePath = askText("Path to the .share file received");
41
+ }
42
+ const absPath = resolve(filePath);
43
+ let bytes: Uint8Array;
44
+ try {
45
+ bytes = await readFile(absPath);
46
+ } catch (e: any) {
47
+ console.error(`failed to read ${absPath}: ${e?.message ?? e}`);
48
+ process.exit(1);
49
+ }
50
+
51
+ let passphrase = flags.passphrase;
52
+ if (!passphrase) {
53
+ if (!isTty()) {
54
+ console.error(
55
+ "missing --passphrase=… and stdin is not a TTY — can't prompt for it",
56
+ );
57
+ process.exit(1);
58
+ }
59
+ passphrase = await askSecret("Passphrase");
60
+ }
61
+
62
+ let payload;
63
+ try {
64
+ payload = await parseShareFile(bytes, passphrase);
65
+ } catch (e: any) {
66
+ console.error((e as Error).message);
67
+ process.exit(1);
68
+ }
69
+
70
+ if (repoOverride && repoOverride !== payload.repo) {
71
+ console.log(
72
+ `[notice] --repo=${repoOverride} overrides repo embedded in share file (${payload.repo})`,
73
+ );
74
+ }
75
+ const repo = repoOverride || payload.repo;
76
+ const finalEnv = payload.env || env;
77
+ if (payload.env !== env) {
78
+ console.log(
79
+ `[notice] share file is for env=${payload.env}; importing under requested env=${env}`,
80
+ );
81
+ }
82
+
83
+ const saved = await saveConfigFile(repo, env, payload.config);
84
+ await setKey(repo, env, payload.key);
85
+
86
+ console.log("\n─────────────────────────────────────────────────────────────");
87
+ console.log("✅ Import complete");
88
+ console.log("─────────────────────────────────────────────────────────────\n");
89
+ console.log(` config file: ${saved}`);
90
+ console.log(
91
+ ` key: OS keychain (service=tools.vsync, account=${repo}/${env})\n`,
92
+ );
93
+ console.log("Next step:");
94
+ console.log(` vsync pull ${env} # download the latest vault folder from S3`);
95
+ console.log("");
96
+ console.log("You can safely delete the .share file now — its contents are installed.");
97
+ // Silence "unused var" linters from finalEnv assignment kept for clarity.
98
+ void finalEnv;
99
+ }
100
+
101
+ if (import.meta.main) {
102
+ await main(process.argv.slice(2));
103
+ }