cf-envsync 0.2.0 → 0.3.1

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 CHANGED
@@ -7,6 +7,20 @@
7
7
 
8
8
  <h1 align="center">envsync</h1>
9
9
 
10
+ <p align="center">
11
+ <a href="https://www.npmjs.com/package/cf-envsync"><img src="https://img.shields.io/npm/v/cf-envsync.svg" alt="npm version" /></a>
12
+ <a href="https://www.npmjs.com/package/cf-envsync"><img src="https://img.shields.io/npm/dm/cf-envsync.svg" alt="npm downloads" /></a>
13
+ <a href="https://github.com/hakkokimkr/cf-envsync/blob/main/LICENSE"><img src="https://img.shields.io/npm/l/cf-envsync.svg" alt="license" /></a>
14
+ <a href="https://github.com/hakkokimkr/cf-envsync"><img src="https://img.shields.io/github/stars/hakkokimkr/cf-envsync.svg?style=social" alt="GitHub stars" /></a>
15
+ </p>
16
+
17
+ <p align="center">
18
+ <a href="https://nodejs.org/"><img src="https://img.shields.io/node/v/cf-envsync.svg" alt="node version" /></a>
19
+ <a href="https://github.com/hakkokimkr/cf-envsync"><img src="https://img.shields.io/github/last-commit/hakkokimkr/cf-envsync.svg" alt="last commit" /></a>
20
+ <a href="https://github.com/hakkokimkr/cf-envsync/issues"><img src="https://img.shields.io/github/issues/hakkokimkr/cf-envsync.svg" alt="issues" /></a>
21
+ <img src="https://img.shields.io/badge/TypeScript-100%25-blue.svg" alt="TypeScript" />
22
+ </p>
23
+
10
24
  <p align="center">
11
25
  One <code>.env</code> file. Every Worker. Every environment.<br />
12
26
  No SaaS. No dashboard. Just your repo.
@@ -75,7 +89,136 @@ envsync validate
75
89
 
76
90
  - [Node.js](https://nodejs.org) >= 18 or [Bun](https://bun.sh)
77
91
  - [wrangler](https://developers.cloudflare.com/workers/wrangler/) CLI (peer dependency, for push/pull/diff)
78
- - [dotenvx](https://dotenvx.com) (optional, for encryption)
92
+ - [dotenvx](https://dotenvx.com) (optional, only if using `encryption: "dotenvx"`)
93
+
94
+ ---
95
+
96
+ ## 5-Minute Tutorial
97
+
98
+ A complete walkthrough: project setup → local dev → deploy to staging → validate.
99
+
100
+ ### 1. Initialize your project
101
+
102
+ ```bash
103
+ # In your monorepo root
104
+ npm install -D cf-envsync
105
+ envsync init --monorepo
106
+ ```
107
+
108
+ This scans for `wrangler.jsonc` files, discovers your workers, and generates:
109
+
110
+ ```
111
+ envsync.config.ts ← config with all apps/workers detected
112
+ .env.example ← key reference (committed to git)
113
+ .env ← local shared secrets
114
+ .env.staging ← staging secrets (empty, you'll fill these)
115
+ .env.production ← production secrets (empty)
116
+ .gitignore ← updated with .env.local, .env.password, **/.dev.vars
117
+ ```
118
+
119
+ ### 2. Fill in your secrets
120
+
121
+ ```bash
122
+ # .env — local development values (committed, encrypted)
123
+ DATABASE_URL=postgres://localhost:5432/mydb
124
+ JWT_SECRET=dev_jwt_secret
125
+ AUTH_SECRET=dev_auth_secret
126
+ API_URL=http://localhost:8787
127
+
128
+ # .env.staging — staging values
129
+ DATABASE_URL=postgres://staging-db.example.com/mydb
130
+ JWT_SECRET=staging_jwt_secret_abc
131
+ AUTH_SECRET=staging_auth_secret_xyz
132
+ API_URL=https://api-staging.example.com
133
+
134
+ # .env.local — YOUR dev-specific overrides (gitignored, each dev has their own)
135
+ OAUTH_REDIRECT_URL=https://my-tunnel.ngrok.io/callback
136
+ DEV_TUNNEL_URL=https://my-tunnel.ngrok.io
137
+ ```
138
+
139
+ ### 3. Encrypt before committing (optional)
140
+
141
+ ```bash
142
+ # Set a password
143
+ echo "ENVSYNC_PASSWORD=my-team-password" > .env.password
144
+
145
+ # Encrypt all plain values
146
+ envsync encrypt staging
147
+ envsync encrypt production
148
+
149
+ # Now .env.staging looks like:
150
+ # DATABASE_URL=envsync:v1:base64payload...
151
+ # JWT_SECRET=envsync:v1:base64payload...
152
+ ```
153
+
154
+ ### 4. Local development
155
+
156
+ ```bash
157
+ envsync dev
158
+ ```
159
+
160
+ This reads `.env` + `.env.local`, merges them, and writes `.dev.vars` into each app directory. Start wrangler as usual — it reads `.dev.vars` automatically.
161
+
162
+ ```
163
+ apps/api/.dev.vars ← DATABASE_URL, JWT_SECRET, API_URL, OAUTH_REDIRECT_URL
164
+ apps/web/.dev.vars ← AUTH_SECRET, VITE_API_URL, VITE_OAUTH_REDIRECT_URL
165
+ ```
166
+
167
+ > **Vite / non-wrangler apps:** `.dev.vars` is for wrangler only. If your app runs with `vite dev`, set `devFile` in your config:
168
+ >
169
+ > ```ts
170
+ > apps: {
171
+ > web: {
172
+ > path: "apps/web",
173
+ > devFile: ".env.local", // Vite reads this
174
+ > // or generate both:
175
+ > // devFile: [".dev.vars", ".env.local"],
176
+ > },
177
+ > }
178
+ > ```
179
+
180
+ If you forgot to set a per-dev override, envsync tells you:
181
+
182
+ ```
183
+ ⚠ Missing in .env.local: DEV_TUNNEL_URL (required per-dev override)
184
+ → echo "DEV_TUNNEL_URL=https://your-tunnel.example.com" >> .env.local
185
+ ```
186
+
187
+ ### 5. Push to staging
188
+
189
+ ```bash
190
+ # Preview first
191
+ envsync push staging --dry-run
192
+
193
+ # Push for real
194
+ envsync push staging
195
+ # Push 4 secrets to worker "my-api-staging" (staging)? yes
196
+ # ✓ Pushed 4 secrets to my-api-staging
197
+ # Push 1 secrets to worker "my-web-staging" (staging)? yes
198
+ # ✓ Pushed 1 secrets to my-web-staging
199
+ ```
200
+
201
+ ### 6. Validate before deploying
202
+
203
+ ```bash
204
+ envsync validate
205
+ # Checks every app × every environment against .env.example
206
+ # Exit code 1 if anything is missing → safe for CI
207
+ ```
208
+
209
+ ### 7. CI/CD integration
210
+
211
+ ```yaml
212
+ # GitHub Actions example
213
+ - name: Validate env vars
214
+ run: envsync validate
215
+
216
+ - name: Push secrets to production
217
+ run: envsync push production --force
218
+ env:
219
+ ENVSYNC_PASSWORD: ${{ secrets.ENVSYNC_PASSWORD }}
220
+ CLOUDFLARE_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
221
+ ```
79
222
 
80
223
  ---
81
224
 
@@ -116,6 +259,8 @@ Done!
116
259
 
117
260
  Every key shows exactly where its value came from. Missing per-dev overrides are caught immediately.
118
261
 
262
+ > **Vite / non-wrangler apps:** Set `devFile: ".env.local"` in your app config. See [the tutorial](#4-local-development).
263
+
119
264
  ---
120
265
 
121
266
  ### `envsync push` — Deploy secrets
@@ -270,10 +415,11 @@ envsync init --monorepo # Scans for wrangler.jsonc files
270
415
  ```
271
416
 
272
417
  What it does:
418
+ - Asks for encryption method (`password`, `dotenvx`, or `none`)
273
419
  - Scans `wrangler.jsonc` files to discover workers and environments
274
420
  - Detects shared secrets across apps
275
421
  - Creates `envsync.config.ts`, `.env.example`, and empty `.env.{environment}` files
276
- - Adds `.env.local`, `.env.keys`, `**/.dev.vars` to `.gitignore`
422
+ - Adds `.env.local`, `.env.keys`, `.env.password`, `**/.dev.vars` to `.gitignore`
277
423
  - Registers the custom Git merge driver in `.gitattributes`
278
424
 
279
425
  ---
@@ -289,9 +435,33 @@ envsync normalize .env.staging # Specific file
289
435
 
290
436
  ---
291
437
 
438
+ ### `envsync encrypt` — Encrypt plain values
439
+
440
+ Encrypts plain-text values in a `.env` file using password-based encryption (AES-256-GCM). Only available when `encryption: "password"`.
441
+
442
+ ```bash
443
+ envsync encrypt staging # Encrypt all plain values in .env.staging
444
+ envsync encrypt production # Encrypt .env.production
445
+ envsync encrypt staging --dry-run # Preview without writing
446
+ ```
447
+
448
+ ```
449
+ $ envsync encrypt staging
450
+
451
+ DATABASE_URL: encrypted
452
+ API_KEY: encrypted
453
+ JWT_SECRET: encrypted
454
+
455
+ Encrypted 3 values in .env.staging (0 skipped)
456
+ ```
457
+
458
+ Already-encrypted and empty values are skipped automatically.
459
+
460
+ ---
461
+
292
462
  ### `envsync merge` — Git merge driver
293
463
 
294
- A 3-way merge driver that understands dotenvx encryption. Registered automatically by `envsync init`.
464
+ A 3-way merge driver that understands both dotenvx and password encryption. Registered automatically by `envsync init`.
295
465
 
296
466
  ```
297
467
  # .gitattributes (auto-generated)
@@ -301,10 +471,10 @@ A 3-way merge driver that understands dotenvx encryption. Registered automatical
301
471
 
302
472
  How it works:
303
473
 
304
- 1. Decrypts all three versions (base, ours, theirs)
474
+ 1. Decrypts all three versions (base, ours, theirs) — supports both dotenvx and password encryption
305
475
  2. 3-way merge at the **key level** — not the encrypted ciphertext
306
476
  3. Only real conflicts get conflict markers
307
- 4. Re-encrypts the merged result
477
+ 4. Re-encrypts the merged result (password mode uses `encryptEnvMap`, dotenvx uses `dotenvx encrypt`)
308
478
 
309
479
  No more fake conflicts from identical values with different ciphertext.
310
480
 
@@ -415,11 +585,12 @@ export default {
415
585
  | `envFiles.pattern` | `string` | File naming pattern. `{env}` is replaced. `local` falls back to `.env` |
416
586
  | `envFiles.local` | `string` | Per-developer override file (gitignored) |
417
587
  | `envFiles.perApp` | `boolean` | Allow per-app `.env.{env}` files for app-specific overrides |
418
- | `encryption` | `"dotenvx" \| "none"` | Encryption method for `.env` files |
588
+ | `encryption` | `"password" \| "dotenvx" \| "none"` | Encryption method for `.env` files |
419
589
  | `apps.{name}.path` | `string` | Path to app directory relative to project root |
420
590
  | `apps.{name}.workers` | `Record<string, string>` | Worker name per environment |
421
591
  | `apps.{name}.secrets` | `string[]` | Secret keys pushed via `wrangler secret bulk` |
422
592
  | `apps.{name}.vars` | `string[]` | Non-secret env vars (not pushed as secrets) |
593
+ | `apps.{name}.devFile` | `string \| string[]` | Output file(s) for `envsync dev`. Default: `".dev.vars"`. Use `".env.local"` for Vite apps, or an array for both |
423
594
  | `shared` | `string[]` | Keys with the same value across multiple apps |
424
595
  | `local.overrides` | `string[]` | Keys each developer must set in `.env.local` |
425
596
  | `local.perApp` | `Record<string, string[]>` | Per-app developer override keys |
@@ -428,6 +599,48 @@ export default {
428
599
 
429
600
  Config file search order: `envsync.config.ts` > `.js` > `.mjs` > `envsync.json` > `envsync.jsonc`
430
601
 
602
+ ### Encryption
603
+
604
+ envsync supports three encryption modes:
605
+
606
+ | Mode | How it works | Dependencies |
607
+ |------|-------------|--------------|
608
+ | `"password"` | AES-256-GCM, per-value encryption with a shared password. Values stored as `envsync:v1:{base64}`. | None (uses `node:crypto`) |
609
+ | `"dotenvx"` | ECIES public/private key encryption via dotenvx CLI. | `dotenvx` CLI |
610
+ | `"none"` | No encryption. `.env` files stored in plain text. | None |
611
+
612
+ #### Password encryption
613
+
614
+ Per-value encryption means each key-value pair is encrypted independently — git diffs are readable at the key level, and merges work cleanly.
615
+
616
+ ```
617
+ # .env.staging (committed, encrypted)
618
+ DATABASE_URL=envsync:v1:base64encodedpayload...
619
+ API_KEY=envsync:v1:base64encodedpayload...
620
+ ```
621
+
622
+ **Password source** (checked in order):
623
+
624
+ 1. `ENVSYNC_PASSWORD_{ENV}` env var (e.g. `ENVSYNC_PASSWORD_STAGING`)
625
+ 2. `ENVSYNC_PASSWORD` env var (generic fallback)
626
+ 3. `.env.password` file with `ENVSYNC_PASSWORD_{ENV}=xxx` (env-specific)
627
+ 4. `.env.password` file with `ENVSYNC_PASSWORD=xxx` (generic fallback)
628
+
629
+ ```bash
630
+ # Quick setup
631
+ echo "ENVSYNC_PASSWORD=your-strong-password" > .env.password
632
+
633
+ # Or per-environment passwords
634
+ cat > .env.password << 'EOF'
635
+ ENVSYNC_PASSWORD_STAGING=staging-password
636
+ ENVSYNC_PASSWORD_PRODUCTION=production-password
637
+ EOF
638
+
639
+ # Encrypt plain values
640
+ envsync encrypt staging
641
+ envsync encrypt production
642
+ ```
643
+
431
644
  ### File structure
432
645
 
433
646
  ```
@@ -440,6 +653,7 @@ project/
440
653
  ├── .env.local # Per-developer overrides (gitignored)
441
654
  ├── .env.example # Key reference (committed)
442
655
  ├── .env.keys # dotenvx private keys (gitignored)
656
+ ├── .env.password # Password encryption keys (gitignored)
443
657
 
444
658
  ├── apps/
445
659
  │ ├── api/
@@ -454,7 +668,7 @@ project/
454
668
  │ ├── .dev.vars # ← generated
455
669
  │ └── .env # app-specific secrets (YOUTUBE_API_KEY, etc.)
456
670
 
457
- └── .gitignore # .env.local, .env.keys, **/.dev.vars
671
+ └── .gitignore # .env.local, .env.keys, .env.password, **/.dev.vars
458
672
  ```
459
673
 
460
674
  ### Merge priority
@@ -501,12 +715,39 @@ export default defineConfig({
501
715
  <tr><td><strong>.dev.vars</strong></td><td>Local dev secrets</td><td>Doesn't sync with anything</td></tr>
502
716
  </table>
503
717
 
504
- **envsync** fills the gap: encrypted `.env` files as the single source of truth, synced to every target — Workers secrets, `.dev.vars`, validation — with monorepo and multi-environment support built in.
718
+ **envsync** fills the gap: encrypted `.env` files as the single source of truth, synced to every target — Workers secrets, `.dev.vars`, validation — with monorepo and multi-environment support built in. Choose password encryption (zero dependencies, per-value, merge-friendly) or dotenvx (ECIES key pairs).
505
719
 
506
720
  No SaaS. No dashboard. Just a CLI, your `.env` files, and Cloudflare's API.
507
721
 
508
722
  ---
509
723
 
724
+ ## Testing
725
+
726
+ 150 tests across 20 files covering utils, core modules, and all 10 commands.
727
+
728
+ ```bash
729
+ bun test # Run tests
730
+ bun test --coverage # Run with coverage report
731
+ ```
732
+
733
+ ### Coverage
734
+
735
+ | File | % Funcs | % Lines |
736
+ |------|---------|---------|
737
+ | **All files** | **83.93** | **76.13** |
738
+ | src/core/resolver.ts | 100.00 | 100.00 |
739
+ | src/core/wrangler.ts | 100.00 | 100.00 |
740
+ | src/core/env-file.ts | 100.00 | 98.65 |
741
+ | src/core/encryption.ts | 100.00 | 95.65 |
742
+ | src/utils/fs.ts | 100.00 | 97.44 |
743
+ | src/core/config.ts | 71.43 | 76.23 |
744
+ | src/commands/merge.ts | 75.00 | 28.40 |
745
+ | src/utils/output.ts | 25.00 | 12.70 |
746
+
747
+ > `merge.ts` and `output.ts` line coverage is lower because their command handlers and print functions are tested via CLI integration tests (spawned subprocesses), which bun's coverage instrumentation does not trace into.
748
+
749
+ ---
750
+
510
751
  ## Tech Stack
511
752
 
512
753
  | | |
@@ -515,7 +756,7 @@ No SaaS. No dashboard. Just a CLI, your `.env` files, and Cloudflare's API.
515
756
  | **CLI framework** | [citty](https://github.com/unjs/citty) |
516
757
  | **Output** | [consola](https://github.com/unjs/consola) |
517
758
  | **Config loading** | [jiti](https://github.com/unjs/jiti) |
518
- | **Encryption** | [@dotenvx/dotenvx](https://dotenvx.com) |
759
+ | **Encryption** | `node:crypto` AES-256-GCM (password mode) / [@dotenvx/dotenvx](https://dotenvx.com) (dotenvx mode) |
519
760
  | **CF Secrets** | [wrangler](https://developers.cloudflare.com/workers/wrangler/) CLI (shell out) |
520
761
 
521
762
  ---
package/dist/index.js CHANGED
@@ -2315,11 +2315,13 @@ function resolveConfig(config, cwd) {
2315
2315
  allKeys.push(key);
2316
2316
  }
2317
2317
  }
2318
+ const devFiles = app.devFile ? Array.isArray(app.devFile) ? app.devFile : [app.devFile] : [".dev.vars"];
2318
2319
  apps[name] = {
2319
2320
  ...app,
2320
2321
  name,
2321
2322
  absolutePath: resolve2(projectRoot, app.path),
2322
- allKeys
2323
+ allKeys,
2324
+ devFiles
2323
2325
  };
2324
2326
  }
2325
2327
  return {
@@ -2333,15 +2335,23 @@ function resolveApps(config, appNames) {
2333
2335
  if (!appNames || appNames.length === 0) {
2334
2336
  return Object.values(config.apps);
2335
2337
  }
2338
+ const available = Object.keys(config.apps);
2336
2339
  const resolved = [];
2340
+ const unknown = [];
2337
2341
  for (const name of appNames) {
2338
2342
  const app = config.apps[name];
2339
2343
  if (!app) {
2340
- consola.warn(`Unknown app: "${name}". Skipping.`);
2344
+ unknown.push(name);
2341
2345
  continue;
2342
2346
  }
2343
2347
  resolved.push(app);
2344
2348
  }
2349
+ if (unknown.length > 0) {
2350
+ for (const name of unknown) {
2351
+ consola.error(`Unknown app: "${name}". Available: ${available.join(", ")}`);
2352
+ }
2353
+ process.exit(1);
2354
+ }
2345
2355
  return resolved;
2346
2356
  }
2347
2357
  function getWorkerName(app, environment) {
@@ -13007,6 +13017,9 @@ var require_main2 = __commonJS((exports, module) => {
13007
13017
  });
13008
13018
 
13009
13019
  // src/core/encryption.ts
13020
+ import { readFileSync } from "node:fs";
13021
+ import { join as join2 } from "node:path";
13022
+ import { scryptSync, randomBytes, createCipheriv, createDecipheriv } from "node:crypto";
13010
13023
  function decryptEnvContent(content, privateKey) {
13011
13024
  if (privateKey) {
13012
13025
  const prev = process.env.DOTENV_PRIVATE_KEY;
@@ -13023,27 +13036,164 @@ function decryptEnvContent(content, privateKey) {
13023
13036
  }
13024
13037
  return import_dotenvx.parse(content);
13025
13038
  }
13026
- function findPrivateKey(env2) {
13039
+ function loadEnvKeysFileSync(filePath) {
13040
+ try {
13041
+ const content = readFileSync(filePath, "utf-8");
13042
+ const result = {};
13043
+ for (const line of content.split(`
13044
+ `)) {
13045
+ const trimmed = line.trim();
13046
+ if (trimmed === "" || trimmed.startsWith("#"))
13047
+ continue;
13048
+ const eqIdx = trimmed.indexOf("=");
13049
+ if (eqIdx === -1)
13050
+ continue;
13051
+ const key = trimmed.slice(0, eqIdx).trim();
13052
+ let value = trimmed.slice(eqIdx + 1).trim();
13053
+ if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
13054
+ value = value.slice(1, -1);
13055
+ }
13056
+ result[key] = value;
13057
+ }
13058
+ return result;
13059
+ } catch {
13060
+ return {};
13061
+ }
13062
+ }
13063
+ function findPrivateKey(env2, projectRoot) {
13027
13064
  if (env2) {
13028
13065
  const envKey = `DOTENV_PRIVATE_KEY_${env2.toUpperCase()}`;
13029
13066
  if (process.env[envKey])
13030
13067
  return process.env[envKey];
13031
13068
  }
13032
- return process.env.DOTENV_PRIVATE_KEY;
13069
+ if (process.env.DOTENV_PRIVATE_KEY)
13070
+ return process.env.DOTENV_PRIVATE_KEY;
13071
+ if (projectRoot) {
13072
+ const keysFile = loadEnvKeysFileSync(join2(projectRoot, ".env.keys"));
13073
+ if (env2) {
13074
+ const envKey = `DOTENV_PRIVATE_KEY_${env2.toUpperCase()}`;
13075
+ if (keysFile[envKey])
13076
+ return keysFile[envKey];
13077
+ }
13078
+ if (keysFile.DOTENV_PRIVATE_KEY)
13079
+ return keysFile.DOTENV_PRIVATE_KEY;
13080
+ }
13081
+ return;
13082
+ }
13083
+ function isEnvsyncEncrypted(value) {
13084
+ return value.startsWith(ENVSYNC_PREFIX);
13085
+ }
13086
+ function encryptValue(plaintext, password) {
13087
+ const salt = randomBytes(16);
13088
+ const iv = randomBytes(12);
13089
+ const key = scryptSync(password, salt, 32);
13090
+ const cipher = createCipheriv("aes-256-gcm", key, iv);
13091
+ const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
13092
+ const tag = cipher.getAuthTag();
13093
+ const payload = Buffer.concat([salt, iv, encrypted, tag]);
13094
+ return ENVSYNC_PREFIX + payload.toString("base64");
13095
+ }
13096
+ function decryptValue(token, password) {
13097
+ if (!token.startsWith(ENVSYNC_PREFIX)) {
13098
+ throw new Error("Not an envsync-encrypted value");
13099
+ }
13100
+ const payload = Buffer.from(token.slice(ENVSYNC_PREFIX.length), "base64");
13101
+ const salt = payload.subarray(0, 16);
13102
+ const iv = payload.subarray(16, 28);
13103
+ const tag = payload.subarray(payload.length - 16);
13104
+ const encrypted = payload.subarray(28, payload.length - 16);
13105
+ const key = scryptSync(password, salt, 32);
13106
+ const decipher = createDecipheriv("aes-256-gcm", key, iv);
13107
+ decipher.setAuthTag(tag);
13108
+ return decipher.update(encrypted) + decipher.final("utf8");
13109
+ }
13110
+ function decryptEnvMap(envMap, password) {
13111
+ const result = {};
13112
+ for (const [key, value] of Object.entries(envMap)) {
13113
+ result[key] = isEnvsyncEncrypted(value) ? decryptValue(value, password) : value;
13114
+ }
13115
+ return result;
13116
+ }
13117
+ function encryptEnvMap(envMap, password) {
13118
+ const result = {};
13119
+ for (const [key, value] of Object.entries(envMap)) {
13120
+ if (isEnvsyncEncrypted(value) || value === "") {
13121
+ result[key] = value;
13122
+ } else {
13123
+ result[key] = encryptValue(value, password);
13124
+ }
13125
+ }
13126
+ return result;
13127
+ }
13128
+ function findPassword(env2, projectRoot) {
13129
+ if (env2) {
13130
+ const envKey = `ENVSYNC_PASSWORD_${env2.toUpperCase()}`;
13131
+ if (process.env[envKey])
13132
+ return process.env[envKey];
13133
+ }
13134
+ if (process.env.ENVSYNC_PASSWORD)
13135
+ return process.env.ENVSYNC_PASSWORD;
13136
+ if (projectRoot) {
13137
+ const passwordFile = loadEnvKeysFileSync(join2(projectRoot, ".env.password"));
13138
+ if (env2) {
13139
+ const envKey = `ENVSYNC_PASSWORD_${env2.toUpperCase()}`;
13140
+ if (passwordFile[envKey])
13141
+ return passwordFile[envKey];
13142
+ }
13143
+ if (passwordFile.ENVSYNC_PASSWORD)
13144
+ return passwordFile.ENVSYNC_PASSWORD;
13145
+ }
13146
+ return;
13033
13147
  }
13034
- var import_dotenvx;
13148
+ var import_dotenvx, ENVSYNC_PREFIX = "envsync:v1:";
13035
13149
  var init_encryption = __esm(() => {
13036
13150
  import_dotenvx = __toESM(require_main2(), 1);
13037
13151
  });
13038
13152
 
13039
13153
  // src/core/env-file.ts
13040
- import { join as join2 } from "node:path";
13041
- async function loadEnvFile(filePath, env2) {
13154
+ import { join as join3 } from "node:path";
13155
+ function parsePlainEnv(content) {
13156
+ const result = {};
13157
+ for (const line of content.split(`
13158
+ `)) {
13159
+ const trimmed = line.trim();
13160
+ if (trimmed === "" || trimmed.startsWith("#"))
13161
+ continue;
13162
+ const eqIdx = trimmed.indexOf("=");
13163
+ if (eqIdx === -1)
13164
+ continue;
13165
+ const key = trimmed.slice(0, eqIdx).trim();
13166
+ let value = trimmed.slice(eqIdx + 1).trim();
13167
+ if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
13168
+ value = value.slice(1, -1);
13169
+ }
13170
+ result[key] = value;
13171
+ }
13172
+ return result;
13173
+ }
13174
+ async function loadEnvFile(filePath, env2, projectRoot, encryption) {
13042
13175
  if (!fileExists(filePath)) {
13043
13176
  return {};
13044
13177
  }
13045
13178
  const content = await readFile(filePath);
13046
- const privateKey = findPrivateKey(env2);
13179
+ if (encryption) {
13180
+ const hasEnvsyncValues = content.includes("envsync:v1:");
13181
+ const hasDotenvxValues = content.includes("encrypted:");
13182
+ if (encryption === "dotenvx" && hasEnvsyncValues) {
13183
+ consola.warn(`${filePath}: config uses encryption: "dotenvx" but file contains "envsync:v1:" (password-encrypted) values. ` + `Check your encryption setting.`);
13184
+ }
13185
+ if (encryption === "password" && hasDotenvxValues && !hasEnvsyncValues) {
13186
+ consola.warn(`${filePath}: config uses encryption: "password" but file contains dotenvx-encrypted values. ` + `Check your encryption setting.`);
13187
+ }
13188
+ }
13189
+ if (encryption === "password") {
13190
+ const envMap = parsePlainEnv(content);
13191
+ const password = findPassword(env2, projectRoot);
13192
+ if (!password)
13193
+ return envMap;
13194
+ return decryptEnvMap(envMap, password);
13195
+ }
13196
+ const privateKey = findPrivateKey(env2, projectRoot);
13047
13197
  return decryptEnvContent(content, privateKey);
13048
13198
  }
13049
13199
  async function writeEnvFile(filePath, envMap) {
@@ -13087,34 +13237,41 @@ function filterForApp(envMap, app) {
13087
13237
  function getRootEnvPath(config, environment) {
13088
13238
  const pattern = config.raw.envFiles.pattern;
13089
13239
  const relativePath = resolveEnvFilePath(pattern, environment);
13090
- return join2(config.projectRoot, relativePath);
13240
+ return join3(config.projectRoot, relativePath);
13091
13241
  }
13092
13242
  function getAppEnvPath(config, app, environment) {
13093
13243
  const pattern = config.raw.envFiles.pattern;
13094
13244
  const relativePath = resolveEnvFilePath(pattern, environment);
13095
- return join2(app.absolutePath, relativePath);
13245
+ return join3(app.absolutePath, relativePath);
13096
13246
  }
13097
13247
  function getLocalOverridePath(config) {
13098
- return join2(config.projectRoot, config.raw.envFiles.local);
13248
+ return join3(config.projectRoot, config.raw.envFiles.local);
13099
13249
  }
13100
13250
  var init_env_file = __esm(() => {
13251
+ init_dist2();
13101
13252
  init_encryption();
13102
13253
  init_config();
13103
13254
  init_fs();
13104
13255
  });
13105
13256
 
13106
13257
  // src/core/resolver.ts
13258
+ import { normalize, relative } from "node:path";
13107
13259
  async function resolveAppEnv(config, app, environment) {
13108
13260
  const layers = [];
13261
+ const encryption = config.raw.encryption;
13109
13262
  const rootEnvPath = getRootEnvPath(config, environment);
13110
- const rootEnv = await loadEnvFile(rootEnvPath, environment);
13263
+ if (!fileExists(rootEnvPath) && environment !== "local" && !warnedMissingFiles.has(rootEnvPath)) {
13264
+ warnedMissingFiles.add(rootEnvPath);
13265
+ consola.warn(`Missing env file: ${relative(config.projectRoot, rootEnvPath)} (create it or run \`envsync init\`)`);
13266
+ }
13267
+ const rootEnv = await loadEnvFile(rootEnvPath, environment, config.projectRoot, encryption);
13111
13268
  if (Object.keys(rootEnv).length > 0) {
13112
13269
  layers.push({ source: rootEnvPath, map: rootEnv });
13113
13270
  }
13114
13271
  if (config.raw.envFiles.perApp) {
13115
13272
  const appEnvPath = getAppEnvPath(config, app, environment);
13116
- if (appEnvPath !== rootEnvPath) {
13117
- const appEnv = await loadEnvFile(appEnvPath, environment);
13273
+ if (normalize(appEnvPath) !== normalize(rootEnvPath)) {
13274
+ const appEnv = await loadEnvFile(appEnvPath, environment, config.projectRoot, encryption);
13118
13275
  if (Object.keys(appEnv).length > 0) {
13119
13276
  layers.push({ source: appEnvPath, map: appEnv });
13120
13277
  }
@@ -13149,8 +13306,12 @@ function findMissingOverrides(config, app, localEnv) {
13149
13306
  }
13150
13307
  return missing;
13151
13308
  }
13309
+ var warnedMissingFiles;
13152
13310
  var init_resolver = __esm(() => {
13311
+ init_dist2();
13153
13312
  init_env_file();
13313
+ init_fs();
13314
+ warnedMissingFiles = new Set;
13154
13315
  });
13155
13316
 
13156
13317
  // src/commands/dev.ts
@@ -13158,13 +13319,13 @@ var exports_dev = {};
13158
13319
  __export(exports_dev, {
13159
13320
  default: () => dev_default
13160
13321
  });
13161
- import { join as join3, relative } from "node:path";
13322
+ import { join as join4, relative as relative2 } from "node:path";
13162
13323
  function parseAppNames(args) {
13163
13324
  const rest = args._;
13164
13325
  return rest?.length ? rest : undefined;
13165
13326
  }
13166
13327
  function formatSource(source, key, projectRoot, sharedKeys, localOverrideKeys) {
13167
- const rel = relative(projectRoot, source);
13328
+ const rel = relative2(projectRoot, source);
13168
13329
  if (localOverrideKeys.has(key))
13169
13330
  return `${rel} (per-dev override)`;
13170
13331
  if (sharedKeys.has(key))
@@ -13220,26 +13381,21 @@ var init_dev = __esm(() => {
13220
13381
  const localEnv = await loadEnvFile(localOverridePath);
13221
13382
  for (const app of apps) {
13222
13383
  const resolved = await resolveAppEnv(config, app, environment);
13223
- const devVarsPath = join3(app.absolutePath, ".dev.vars");
13224
- const relDevVars = relative(config.projectRoot, devVarsPath);
13225
13384
  if (Object.keys(resolved.map).length === 0) {
13226
13385
  consola.warn(` No env vars resolved for ${app.name}. Skipping.`);
13227
13386
  continue;
13228
13387
  }
13229
- if (args["dry-run"]) {
13230
- consola.log(`
13231
- ${relDevVars}`);
13232
- for (let i2 = 0;i2 < resolved.entries.length; i2++) {
13233
- const entry = resolved.entries[i2];
13234
- const isLast = i2 === resolved.entries.length - 1;
13235
- const prefix = isLast ? "└" : "├";
13236
- const src2 = formatSource(entry.source, entry.key, config.projectRoot, sharedKeys, localOverrideKeys);
13237
- consola.log(` ${prefix} ${entry.key.padEnd(24)} ← ${src2}`);
13388
+ for (const devFileName of app.devFiles) {
13389
+ const devFilePath = join4(app.absolutePath, devFileName);
13390
+ const relDevFile = relative2(config.projectRoot, devFilePath);
13391
+ if (args["dry-run"]) {
13392
+ consola.log(`
13393
+ ${relDevFile}`);
13394
+ } else {
13395
+ await writeEnvFile(devFilePath, resolved.map);
13396
+ consola.log(`
13397
+ ${relDevFile}`);
13238
13398
  }
13239
- } else {
13240
- await writeEnvFile(devVarsPath, resolved.map);
13241
- consola.log(`
13242
- ${relDevVars}`);
13243
13399
  for (let i2 = 0;i2 < resolved.entries.length; i2++) {
13244
13400
  const entry = resolved.entries[i2];
13245
13401
  const isLast = i2 === resolved.entries.length - 1;
@@ -13253,12 +13409,18 @@ var init_dev = __esm(() => {
13253
13409
  if (missing.length > 0) {
13254
13410
  for (const key of missing) {
13255
13411
  consola.warn(`
13256
- ⚠ Missing in ${relative(config.projectRoot, localOverridePath)}: ${key} (required per-dev override)`);
13257
- consola.log(` → echo "${key}=<your-value>" >> ${relative(config.projectRoot, localOverridePath)}`);
13412
+ ⚠ Missing in ${relative2(config.projectRoot, localOverridePath)}: ${key} (required per-dev override)`);
13413
+ consola.log(` → echo "${key}=<your-value>" >> ${relative2(config.projectRoot, localOverridePath)}`);
13258
13414
  }
13259
13415
  }
13260
13416
  }
13261
13417
  }
13418
+ if (environment !== "local") {
13419
+ const hasOverrides = (config.raw.local?.overrides?.length ?? 0) > 0 || Object.keys(config.raw.local?.perApp ?? {}).length > 0;
13420
+ if (hasOverrides) {
13421
+ consola.info(`Per-dev overrides are only applied in "local" environment (current: ${environment}).`);
13422
+ }
13423
+ }
13262
13424
  consola.success(`
13263
13425
  Done!`);
13264
13426
  }
@@ -13440,14 +13602,33 @@ var init_push = __esm(() => {
13440
13602
  consola.warn("No apps to process.");
13441
13603
  return;
13442
13604
  }
13605
+ if (args.force && !args["dry-run"]) {
13606
+ const targets = apps.map((app) => {
13607
+ const w2 = getWorkerName(app, environment);
13608
+ return w2 ? `${app.name} → ${w2}` : null;
13609
+ }).filter(Boolean);
13610
+ if (targets.length > 0) {
13611
+ consola.warn(`Force-pushing to ${environment}: ${targets.join(", ")}`);
13612
+ }
13613
+ }
13443
13614
  const sharedKeys = new Set(config.raw.shared ?? []);
13444
- for (const app of apps) {
13615
+ let hasFailure = false;
13616
+ if (args.shared) {
13617
+ if (sharedKeys.size === 0) {
13618
+ consola.error("No shared keys defined in config. Nothing to push with --shared.");
13619
+ process.exit(1);
13620
+ }
13621
+ consola.info(`--shared: pushing only shared keys (${[...sharedKeys].join(", ")})`);
13622
+ }
13623
+ for (let i2 = 0;i2 < apps.length; i2++) {
13624
+ const app = apps[i2];
13625
+ const progress = apps.length > 1 ? `[${i2 + 1}/${apps.length}] ` : "";
13445
13626
  const workerName = getWorkerName(app, environment);
13446
13627
  if (!workerName) {
13447
- consola.warn(` No worker defined for ${app.name} in ${environment}. Skipping.`);
13628
+ consola.warn(`${progress}No worker defined for ${app.name} in ${environment}. Skipping.`);
13448
13629
  continue;
13449
13630
  }
13450
- consola.start(`Pushing secrets for ${app.name} → ${workerName} (${environment})...`);
13631
+ consola.start(`${progress}Pushing secrets for ${app.name} → ${workerName} (${environment})...`);
13451
13632
  const resolved = await resolveAppEnv(config, app, environment);
13452
13633
  let secretsToPush;
13453
13634
  if (args.shared) {
@@ -13468,7 +13649,8 @@ var init_push = __esm(() => {
13468
13649
  }
13469
13650
  const keyCount = Object.keys(secretsToPush).length;
13470
13651
  if (keyCount === 0) {
13471
- consola.warn(` No secrets to push for ${app.name}. Skipping.`);
13652
+ const reason = args.shared ? " (no shared keys for this app)" : "";
13653
+ consola.warn(` No secrets to push for ${app.name}${reason}. Skipping.`);
13472
13654
  continue;
13473
13655
  }
13474
13656
  if (args["dry-run"]) {
@@ -13491,8 +13673,13 @@ var init_push = __esm(() => {
13491
13673
  consola.success(` Pushed ${keyCount} secrets to ${workerName}`);
13492
13674
  } else {
13493
13675
  consola.error(` Failed to push secrets to ${workerName}`);
13676
+ hasFailure = true;
13494
13677
  }
13495
13678
  }
13679
+ if (hasFailure) {
13680
+ consola.error("Some pushes failed.");
13681
+ process.exit(1);
13682
+ }
13496
13683
  consola.success("Done!");
13497
13684
  }
13498
13685
  });
@@ -13565,7 +13752,7 @@ var init_pull = __esm(() => {
13565
13752
  }
13566
13753
  consola.info(` Found ${remoteKeys.length} remote keys`);
13567
13754
  const envFilePath = getRootEnvPath(config, environment);
13568
- const localEnv = await loadEnvFile(envFilePath, environment);
13755
+ const localEnv = await loadEnvFile(envFilePath, environment, config.projectRoot, config.raw.encryption);
13569
13756
  const localKeys = new Set(Object.keys(localEnv));
13570
13757
  const missingKeys = remoteKeys.filter((k2) => !localKeys.has(k2));
13571
13758
  if (missingKeys.length === 0) {
@@ -13590,7 +13777,7 @@ var exports_validate = {};
13590
13777
  __export(exports_validate, {
13591
13778
  default: () => validate_default
13592
13779
  });
13593
- import { join as join4 } from "node:path";
13780
+ import { join as join5 } from "node:path";
13594
13781
  function parseAppNames4(args, skip = 1) {
13595
13782
  const rest = args._?.slice(skip);
13596
13783
  return rest?.length ? rest : undefined;
@@ -13628,17 +13815,24 @@ var init_validate = __esm(() => {
13628
13815
  let environments;
13629
13816
  let appNames;
13630
13817
  if (envArg && config.environments.includes(envArg)) {
13818
+ if (envArg in config.apps) {
13819
+ consola.error(`"${envArg}" is both an environment and an app name. This is ambiguous.`);
13820
+ consola.info(` To validate environment: envsync validate ${envArg} --
13821
+ ` + ` To validate app: envsync validate -- ${envArg}`);
13822
+ process.exit(1);
13823
+ }
13631
13824
  environments = [envArg];
13632
13825
  appNames = parseAppNames4(args);
13633
13826
  } else if (envArg) {
13634
13827
  environments = config.environments;
13635
13828
  appNames = parseAppNames4(args, 0);
13829
+ consola.info(`"${envArg}" is not an environment. Treating as app name. Validating all environments.`);
13636
13830
  } else {
13637
13831
  environments = config.environments;
13638
13832
  appNames = undefined;
13639
13833
  }
13640
13834
  const apps = resolveApps(config, appNames);
13641
- const examplePath = join4(config.projectRoot, ".env.example");
13835
+ const examplePath = join5(config.projectRoot, ".env.example");
13642
13836
  if (!fileExists(examplePath)) {
13643
13837
  consola.error("No .env.example found at project root.");
13644
13838
  process.exit(1);
@@ -13651,10 +13845,12 @@ var init_validate = __esm(() => {
13651
13845
  ...config.raw.local?.overrides ?? [],
13652
13846
  ...Object.values(config.raw.local?.perApp ?? {}).flat()
13653
13847
  ]);
13848
+ const totalChecks = environments.length * apps.length;
13654
13849
  consola.log(`
13655
- Checking against .env.example...`);
13850
+ Checking against .env.example... (${environments.length} env × ${apps.length} app${apps.length > 1 ? "s" : ""})`);
13656
13851
  const results = [];
13657
13852
  let hasIssues = false;
13853
+ let checkIdx = 0;
13658
13854
  for (const environment of environments) {
13659
13855
  consola.log(`
13660
13856
  ${environment}`);
@@ -13736,10 +13932,10 @@ function printDiff(entries) {
13736
13932
  for (const entry of entries) {
13737
13933
  switch (entry.status) {
13738
13934
  case "added":
13739
- consola.log(` + ${entry.key} = ${maskValue(entry.remoteValue)}`);
13935
+ consola.log(` + ${entry.key} = ${maskValue(entry.localValue)}`);
13740
13936
  break;
13741
13937
  case "removed":
13742
- consola.log(` - ${entry.key} = ${maskValue(entry.localValue)}`);
13938
+ consola.log(` - ${entry.key}`);
13743
13939
  break;
13744
13940
  case "changed":
13745
13941
  consola.log(` ~ ${entry.key}`);
@@ -13819,8 +14015,15 @@ var init_diff = __esm(() => {
13819
14015
  process.exit(1);
13820
14016
  }
13821
14017
  const target = args.target;
13822
- const isEnvVsEnv = target && config.environments.includes(target);
13823
- if (isEnvVsEnv) {
14018
+ const isEnv = target && config.environments.includes(target);
14019
+ const isApp = target && target in config.apps;
14020
+ if (isEnv && isApp) {
14021
+ consola.error(`"${target}" is both an environment and an app name. This is ambiguous.`);
14022
+ consola.info(` To compare environments: envsync diff ${env1} ${target} --
14023
+ ` + ` To diff local vs remote: envsync diff ${env1} -- ${target}`);
14024
+ process.exit(1);
14025
+ }
14026
+ if (isEnv) {
13824
14027
  const env2 = target;
13825
14028
  const appNames = parseAppNames5(args, 2);
13826
14029
  const apps = resolveApps(config, appNames);
@@ -13917,7 +14120,7 @@ var exports_init = {};
13917
14120
  __export(exports_init, {
13918
14121
  default: () => init_default
13919
14122
  });
13920
- import { join as join5, relative as relative2, basename } from "node:path";
14123
+ import { join as join6, relative as relative3, basename } from "node:path";
13921
14124
  function generateConfigTS(config) {
13922
14125
  const lines = [
13923
14126
  `import { defineConfig } from "cf-envsync";`,
@@ -13998,10 +14201,19 @@ var init_init = __esm(() => {
13998
14201
  },
13999
14202
  async run({ args }) {
14000
14203
  const cwd = process.cwd();
14001
- const existingConfig = CONFIG_FILES.find((f3) => fileExists(join5(cwd, f3)));
14204
+ const existingConfig = CONFIG_FILES.find((f3) => fileExists(join6(cwd, f3)));
14002
14205
  if (existingConfig) {
14003
14206
  consola.warn(`${existingConfig} already exists.`);
14004
- const overwrite = await consola.prompt("Overwrite?", {
14207
+ const existingContent = await readFile(join6(cwd, existingConfig));
14208
+ const lines = existingContent.split(`
14209
+ `);
14210
+ const preview = lines.length > 20 ? [...lines.slice(0, 20), ` ... (${lines.length - 20} more lines)`].join(`
14211
+ `) : existingContent;
14212
+ consola.log(`
14213
+ Current config:
14214
+ ${preview}
14215
+ `);
14216
+ const overwrite = await consola.prompt("Overwrite with new config?", {
14005
14217
  type: "confirm"
14006
14218
  });
14007
14219
  if (!overwrite) {
@@ -14011,7 +14223,7 @@ var init_init = __esm(() => {
14011
14223
  }
14012
14224
  const encryption = await consola.prompt("Encryption method:", {
14013
14225
  type: "select",
14014
- options: ["dotenvx", "none"]
14226
+ options: ["password", "dotenvx", "none"]
14015
14227
  });
14016
14228
  const defaultEnvs = "local, staging, production";
14017
14229
  const envsInput = await consola.prompt("Environments (comma-separated):", {
@@ -14028,12 +14240,13 @@ var init_init = __esm(() => {
14028
14240
  return (name === "wrangler.json" || name === "wrangler.jsonc") && !f3.includes("node_modules");
14029
14241
  });
14030
14242
  if (wranglerFiles.length === 0) {
14031
- consola.warn("No wrangler config files found. Creating manually.");
14243
+ consola.warn(`No wrangler.json or wrangler.jsonc files found (searched all subdirectories, excluding node_modules).
14244
+ ` + " Falling back to manual configuration.");
14032
14245
  }
14033
14246
  for (const wranglerFile of wranglerFiles.sort()) {
14034
- const fullPath = join5(cwd, wranglerFile);
14035
- const appDir = join5(cwd, wranglerFile, "..");
14036
- const appPath = relative2(cwd, appDir);
14247
+ const fullPath = join6(cwd, wranglerFile);
14248
+ const appDir = join6(cwd, wranglerFile, "..");
14249
+ const appPath = relative3(cwd, appDir);
14037
14250
  const appName = basename(appDir);
14038
14251
  consola.info(` Found ${wranglerFile}`);
14039
14252
  let wranglerConfig = {};
@@ -14133,10 +14346,10 @@ var init_init = __esm(() => {
14133
14346
  apps,
14134
14347
  ...shared.length > 0 ? { shared } : {}
14135
14348
  };
14136
- const configPath = join5(cwd, "envsync.config.ts");
14349
+ const configPath = join6(cwd, "envsync.config.ts");
14137
14350
  const tsContent = generateConfigTS(config);
14138
14351
  if (existingConfig && existingConfig !== "envsync.config.ts") {
14139
- const oldPath = join5(cwd, existingConfig);
14352
+ const oldPath = join6(cwd, existingConfig);
14140
14353
  if (fileExists(oldPath)) {
14141
14354
  const { unlink } = await import("node:fs/promises");
14142
14355
  await unlink(oldPath);
@@ -14144,7 +14357,7 @@ var init_init = __esm(() => {
14144
14357
  }
14145
14358
  await writeFile(configPath, tsContent);
14146
14359
  consola.success("Created envsync.config.ts");
14147
- const examplePath = join5(cwd, ".env.example");
14360
+ const examplePath = join6(cwd, ".env.example");
14148
14361
  if (!fileExists(examplePath)) {
14149
14362
  const allKeys = new Set;
14150
14363
  for (const app of Object.values(apps)) {
@@ -14167,21 +14380,21 @@ var init_init = __esm(() => {
14167
14380
  for (const env2 of environments) {
14168
14381
  if (env2 === "local")
14169
14382
  continue;
14170
- const envFile = join5(cwd, `.env.${env2}`);
14383
+ const envFile = join6(cwd, `.env.${env2}`);
14171
14384
  if (!fileExists(envFile)) {
14172
14385
  await writeFile(envFile, `# ${env2} environment variables
14173
14386
  `);
14174
14387
  consola.success(`Created .env.${env2}`);
14175
14388
  }
14176
14389
  }
14177
- const rootEnv = join5(cwd, ".env");
14390
+ const rootEnv = join6(cwd, ".env");
14178
14391
  if (!fileExists(rootEnv)) {
14179
14392
  await writeFile(rootEnv, `# Local environment variables
14180
14393
  `);
14181
14394
  consola.success("Created .env");
14182
14395
  }
14183
- const gitignorePath = join5(cwd, ".gitignore");
14184
- const gitignoreEntries = [".env.local", ".env.keys", "**/.dev.vars"];
14396
+ const gitignorePath = join6(cwd, ".gitignore");
14397
+ const gitignoreEntries = [".env.local", ".env.keys", ".env.password", "**/.dev.vars"];
14185
14398
  if (fileExists(gitignorePath)) {
14186
14399
  const existing = await readFile(gitignorePath);
14187
14400
  const toAdd = gitignoreEntries.filter((e2) => !existing.includes(e2));
@@ -14201,7 +14414,7 @@ var init_init = __esm(() => {
14201
14414
  `);
14202
14415
  consola.success(`Created .gitignore`);
14203
14416
  }
14204
- const gitattrsPath = join5(cwd, ".gitattributes");
14417
+ const gitattrsPath = join6(cwd, ".gitattributes");
14205
14418
  const mergeDriverLines = `.env merge=envsync
14206
14419
  .env.* merge=envsync
14207
14420
  `;
@@ -14217,7 +14430,7 @@ var init_init = __esm(() => {
14217
14430
  await writeFile(gitattrsPath, mergeDriverLines);
14218
14431
  consola.success("Created .gitattributes with merge driver");
14219
14432
  }
14220
- const gitConfigPath = join5(cwd, ".git", "config");
14433
+ const gitConfigPath = join6(cwd, ".git", "config");
14221
14434
  if (fileExists(gitConfigPath)) {
14222
14435
  const gitConfig = await readFile(gitConfigPath);
14223
14436
  if (!gitConfig.includes('[merge "envsync"]')) {
@@ -14232,14 +14445,18 @@ var init_init = __esm(() => {
14232
14445
  }
14233
14446
  consola.info(`
14234
14447
  Next steps:`);
14235
- if (encryption === "dotenvx") {
14448
+ if (encryption === "password") {
14449
+ consola.info(' 1. Set a password: echo "ENVSYNC_PASSWORD=your-secret" > .env.password');
14450
+ consola.info(" 2. Add values to your .env files (plain KEY=VALUE)");
14451
+ consola.info(" 3. envsync encrypt staging (encrypts plain values in .env.staging)");
14452
+ } else if (encryption === "dotenvx") {
14236
14453
  consola.info(' 1. dotenvx set DATABASE_URL "value" -f .env');
14237
14454
  for (const env2 of environments.filter((e2) => e2 !== "local")) {
14238
14455
  consola.info(` 2. dotenvx set DATABASE_URL "${env2}_value" -f .env.${env2}`);
14239
14456
  }
14240
14457
  }
14241
- consola.info(` 3. echo "OAUTH_REDIRECT_URL=https://your-tunnel/callback" >> .env.local`);
14242
- consola.info(" 4. envsync dev");
14458
+ consola.info(` ${encryption === "password" ? "4" : "3"}. echo "OAUTH_REDIRECT_URL=https://your-tunnel/callback" >> .env.local`);
14459
+ consola.info(` ${encryption === "password" ? "5" : "4"}. envsync dev`);
14243
14460
  }
14244
14461
  });
14245
14462
  });
@@ -14333,6 +14550,10 @@ var init_normalize = __esm(() => {
14333
14550
  // src/commands/merge.ts
14334
14551
  var exports_merge = {};
14335
14552
  __export(exports_merge, {
14553
+ parseEnvLines: () => parseEnvLines,
14554
+ isPasswordEncrypted: () => isPasswordEncrypted,
14555
+ isEncrypted: () => isEncrypted,
14556
+ isDotenvxEncrypted: () => isDotenvxEncrypted,
14336
14557
  default: () => merge_default
14337
14558
  });
14338
14559
  function parseEnvLines(content) {
@@ -14353,9 +14574,15 @@ function parseEnvLines(content) {
14353
14574
  };
14354
14575
  });
14355
14576
  }
14356
- function isEncrypted(content) {
14577
+ function isDotenvxEncrypted(content) {
14357
14578
  return content.includes("encrypted:");
14358
14579
  }
14580
+ function isPasswordEncrypted(content) {
14581
+ return content.includes("envsync:v1:");
14582
+ }
14583
+ function isEncrypted(content) {
14584
+ return isDotenvxEncrypted(content) || isPasswordEncrypted(content);
14585
+ }
14359
14586
  var merge_default;
14360
14587
  var init_merge = __esm(() => {
14361
14588
  init_dist();
@@ -14391,11 +14618,24 @@ var init_merge = __esm(() => {
14391
14618
  readFile(args.ours),
14392
14619
  readFile(args.theirs)
14393
14620
  ]);
14394
- const wasEncrypted = isEncrypted(oursContent);
14395
- const privateKey = findPrivateKey();
14396
- const baseParsed = isEncrypted(baseContent) ? decryptEnvContent(baseContent, privateKey) : Object.fromEntries(parseEnvLines(baseContent).filter((e2) => e2.key).map((e2) => [e2.key, e2.value ?? ""]));
14397
- const oursParsed = isEncrypted(oursContent) ? decryptEnvContent(oursContent, privateKey) : Object.fromEntries(parseEnvLines(oursContent).filter((e2) => e2.key).map((e2) => [e2.key, e2.value ?? ""]));
14398
- const theirsParsed = isEncrypted(theirsContent) ? decryptEnvContent(theirsContent, privateKey) : Object.fromEntries(parseEnvLines(theirsContent).filter((e2) => e2.key).map((e2) => [e2.key, e2.value ?? ""]));
14621
+ const wasDotenvx = isDotenvxEncrypted(oursContent);
14622
+ const wasPassword = isPasswordEncrypted(oursContent);
14623
+ const wasEncrypted = wasDotenvx || wasPassword;
14624
+ const privateKey = findPrivateKey(undefined, process.cwd());
14625
+ const password = findPassword(undefined, process.cwd());
14626
+ function decryptContent(content) {
14627
+ if (isPasswordEncrypted(content)) {
14628
+ const plain = Object.fromEntries(parseEnvLines(content).filter((e2) => e2.key).map((e2) => [e2.key, e2.value ?? ""]));
14629
+ return password ? decryptEnvMap(plain, password) : plain;
14630
+ }
14631
+ if (isDotenvxEncrypted(content)) {
14632
+ return decryptEnvContent(content, privateKey);
14633
+ }
14634
+ return Object.fromEntries(parseEnvLines(content).filter((e2) => e2.key).map((e2) => [e2.key, e2.value ?? ""]));
14635
+ }
14636
+ const baseParsed = decryptContent(baseContent);
14637
+ const oursParsed = decryptContent(oursContent);
14638
+ const theirsParsed = decryptContent(theirsContent);
14399
14639
  const baseMap = new Map(Object.entries(baseParsed));
14400
14640
  const oursMap = new Map(Object.entries(oursParsed));
14401
14641
  const theirsMap = new Map(Object.entries(theirsParsed));
@@ -14452,10 +14692,19 @@ var init_merge = __esm(() => {
14452
14692
  `) + `
14453
14693
  `;
14454
14694
  if (wasEncrypted && !hasConflicts) {
14455
- await writeFile(args.ours, mergedContent);
14456
- const result = await exec(["dotenvx", "encrypt", "-f", args.ours]);
14457
- if (!result.success) {
14458
- consola.warn("Could not re-encrypt merged file:", result.stderr);
14695
+ if (wasPassword && password) {
14696
+ const plainMap = Object.fromEntries(parseEnvLines(mergedContent).filter((e2) => e2.key).map((e2) => [e2.key, e2.value ?? ""]));
14697
+ const encrypted = encryptEnvMap(plainMap, password);
14698
+ const encLines = Object.entries(encrypted).map(([k2, v2]) => `${k2}=${v2}`);
14699
+ await writeFile(args.ours, encLines.join(`
14700
+ `) + `
14701
+ `);
14702
+ } else {
14703
+ await writeFile(args.ours, mergedContent);
14704
+ const result = await exec(["dotenvx", "encrypt", "-f", args.ours]);
14705
+ if (!result.success) {
14706
+ consola.warn("Could not re-encrypt merged file:", result.stderr);
14707
+ }
14459
14708
  }
14460
14709
  } else {
14461
14710
  await writeFile(args.ours, mergedContent);
@@ -14612,6 +14861,124 @@ var init_list = __esm(() => {
14612
14861
  });
14613
14862
  });
14614
14863
 
14864
+ // src/commands/encrypt.ts
14865
+ var exports_encrypt = {};
14866
+ __export(exports_encrypt, {
14867
+ default: () => encrypt_default
14868
+ });
14869
+ function parseLines(content) {
14870
+ return content.split(`
14871
+ `).map((line) => {
14872
+ const trimmed = line.trim();
14873
+ if (trimmed === "" || trimmed.startsWith("#")) {
14874
+ return { raw: line };
14875
+ }
14876
+ const eqIdx = line.indexOf("=");
14877
+ if (eqIdx === -1) {
14878
+ return { raw: line };
14879
+ }
14880
+ return {
14881
+ key: line.slice(0, eqIdx).trim(),
14882
+ value: line.slice(eqIdx + 1),
14883
+ raw: line
14884
+ };
14885
+ });
14886
+ }
14887
+ var encrypt_default;
14888
+ var init_encrypt = __esm(() => {
14889
+ init_dist();
14890
+ init_dist2();
14891
+ init_config();
14892
+ init_env_file();
14893
+ init_fs();
14894
+ init_encryption();
14895
+ encrypt_default = defineCommand({
14896
+ meta: {
14897
+ name: "encrypt",
14898
+ description: "Encrypt plain .env values with password encryption"
14899
+ },
14900
+ args: {
14901
+ env: {
14902
+ type: "positional",
14903
+ description: "Target environment (e.g. staging, production)",
14904
+ required: true
14905
+ },
14906
+ "dry-run": {
14907
+ type: "boolean",
14908
+ description: "Preview changes without writing",
14909
+ default: false
14910
+ }
14911
+ },
14912
+ async run({ args }) {
14913
+ const environment = args.env;
14914
+ const rawConfig = await loadConfig();
14915
+ const errors = validateConfig(rawConfig);
14916
+ if (errors.length > 0) {
14917
+ for (const err of errors)
14918
+ consola.error(err);
14919
+ process.exit(1);
14920
+ }
14921
+ const config = resolveConfig(rawConfig);
14922
+ if (config.raw.encryption !== "password") {
14923
+ consola.error('encrypt command requires encryption: "password" in config.');
14924
+ process.exit(1);
14925
+ }
14926
+ if (!config.environments.includes(environment)) {
14927
+ consola.error(`Unknown environment: "${environment}". Available: ${config.environments.join(", ")}`);
14928
+ process.exit(1);
14929
+ }
14930
+ const password = findPassword(environment, config.projectRoot);
14931
+ if (!password) {
14932
+ consola.error(`No password found. Set ENVSYNC_PASSWORD or ENVSYNC_PASSWORD_${environment.toUpperCase()}, or create .env.password`);
14933
+ process.exit(1);
14934
+ }
14935
+ const envFilePath = getRootEnvPath(config, environment);
14936
+ if (!fileExists(envFilePath)) {
14937
+ consola.error(`File not found: ${envFilePath}`);
14938
+ process.exit(1);
14939
+ }
14940
+ const content = await readFile(envFilePath);
14941
+ const lines = parseLines(content);
14942
+ let encryptedCount = 0;
14943
+ let skippedCount = 0;
14944
+ const outputLines = [];
14945
+ for (const line of lines) {
14946
+ if (!line.key || line.value === undefined) {
14947
+ outputLines.push(line.raw);
14948
+ continue;
14949
+ }
14950
+ const value = line.value.trim();
14951
+ if (isEnvsyncEncrypted(value) || value === "") {
14952
+ outputLines.push(line.raw);
14953
+ skippedCount++;
14954
+ continue;
14955
+ }
14956
+ let plainValue = value;
14957
+ if (plainValue.startsWith('"') && plainValue.endsWith('"') || plainValue.startsWith("'") && plainValue.endsWith("'")) {
14958
+ plainValue = plainValue.slice(1, -1);
14959
+ }
14960
+ const encrypted = encryptValue(plainValue, password);
14961
+ outputLines.push(`${line.key}=${encrypted}`);
14962
+ encryptedCount++;
14963
+ consola.log(` ${line.key}: encrypted`);
14964
+ }
14965
+ if (encryptedCount === 0) {
14966
+ consola.info("No values to encrypt.");
14967
+ return;
14968
+ }
14969
+ const dryRun = args["dry-run"];
14970
+ if (dryRun) {
14971
+ consola.info(`[dry-run] Would encrypt ${encryptedCount} values in ${envFilePath}`);
14972
+ return;
14973
+ }
14974
+ await writeFile(envFilePath, outputLines.join(`
14975
+ `) + `
14976
+ `);
14977
+ consola.success(`Encrypted ${encryptedCount} values in ${envFilePath} (${skippedCount} skipped)`);
14978
+ }
14979
+ });
14980
+ });
14981
+
14615
14982
  // src/index.ts
14616
14983
  init_dist();
14617
14984
  var main = defineCommand({
@@ -14629,7 +14996,8 @@ var main = defineCommand({
14629
14996
  init: () => Promise.resolve().then(() => (init_init(), exports_init)).then((m2) => m2.default),
14630
14997
  normalize: () => Promise.resolve().then(() => (init_normalize(), exports_normalize)).then((m2) => m2.default),
14631
14998
  merge: () => Promise.resolve().then(() => (init_merge(), exports_merge)).then((m2) => m2.default),
14632
- list: () => Promise.resolve().then(() => (init_list(), exports_list)).then((m2) => m2.default)
14999
+ list: () => Promise.resolve().then(() => (init_list(), exports_list)).then((m2) => m2.default),
15000
+ encrypt: () => Promise.resolve().then(() => (init_encrypt(), exports_encrypt)).then((m2) => m2.default)
14633
15001
  }
14634
15002
  });
14635
15003
  runMain(main);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cf-envsync",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Sync .env files to Cloudflare Workers secrets, .dev.vars, and more",
5
5
  "type": "module",
6
6
  "exports": {
@@ -18,7 +18,8 @@
18
18
  "dev": "bun run src/index.ts",
19
19
  "build": "bun build src/index.ts --outdir dist --target node --external jiti",
20
20
  "prepublishOnly": "bun run build",
21
- "test": "bun test"
21
+ "test": "bun test",
22
+ "test:coverage": "bun test --coverage"
22
23
  },
23
24
  "peerDependencies": {
24
25
  "wrangler": ">=3"
@@ -14,7 +14,7 @@ export interface EnvSyncConfig {
14
14
  };
15
15
 
16
16
  /** Encryption method */
17
- encryption: "dotenvx" | "none";
17
+ encryption: "dotenvx" | "password" | "none";
18
18
 
19
19
  /** App definitions */
20
20
  apps: Record<string, AppConfig>;
@@ -41,6 +41,9 @@ export interface AppConfig {
41
41
  secrets?: string[];
42
42
  /** Var keys for this app (non-secret env vars) */
43
43
  vars?: string[];
44
+ /** Output file(s) for `envsync dev`. Defaults to ".dev.vars".
45
+ * Use an array to generate multiple files, e.g. [".dev.vars", ".env.local"] */
46
+ devFile?: string | string[];
44
47
  }
45
48
 
46
49
  /** Resolved config after defaults and path resolution */
@@ -62,4 +65,6 @@ export interface ResolvedAppConfig extends AppConfig {
62
65
  absolutePath: string;
63
66
  /** All keys this app needs (secrets + vars + local overrides) */
64
67
  allKeys: string[];
68
+ /** Normalized output file names for `envsync dev` (always an array) */
69
+ devFiles: string[];
65
70
  }