cf-envsync 0.2.0 → 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 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,7 @@ 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"`)
79
93
 
80
94
  ---
81
95
 
@@ -270,10 +284,11 @@ envsync init --monorepo # Scans for wrangler.jsonc files
270
284
  ```
271
285
 
272
286
  What it does:
287
+ - Asks for encryption method (`password`, `dotenvx`, or `none`)
273
288
  - Scans `wrangler.jsonc` files to discover workers and environments
274
289
  - Detects shared secrets across apps
275
290
  - Creates `envsync.config.ts`, `.env.example`, and empty `.env.{environment}` files
276
- - Adds `.env.local`, `.env.keys`, `**/.dev.vars` to `.gitignore`
291
+ - Adds `.env.local`, `.env.keys`, `.env.password`, `**/.dev.vars` to `.gitignore`
277
292
  - Registers the custom Git merge driver in `.gitattributes`
278
293
 
279
294
  ---
@@ -289,9 +304,33 @@ envsync normalize .env.staging # Specific file
289
304
 
290
305
  ---
291
306
 
307
+ ### `envsync encrypt` — Encrypt plain values
308
+
309
+ Encrypts plain-text values in a `.env` file using password-based encryption (AES-256-GCM). Only available when `encryption: "password"`.
310
+
311
+ ```bash
312
+ envsync encrypt staging # Encrypt all plain values in .env.staging
313
+ envsync encrypt production # Encrypt .env.production
314
+ envsync encrypt staging --dry-run # Preview without writing
315
+ ```
316
+
317
+ ```
318
+ $ envsync encrypt staging
319
+
320
+ DATABASE_URL: encrypted
321
+ API_KEY: encrypted
322
+ JWT_SECRET: encrypted
323
+
324
+ Encrypted 3 values in .env.staging (0 skipped)
325
+ ```
326
+
327
+ Already-encrypted and empty values are skipped automatically.
328
+
329
+ ---
330
+
292
331
  ### `envsync merge` — Git merge driver
293
332
 
294
- A 3-way merge driver that understands dotenvx encryption. Registered automatically by `envsync init`.
333
+ A 3-way merge driver that understands both dotenvx and password encryption. Registered automatically by `envsync init`.
295
334
 
296
335
  ```
297
336
  # .gitattributes (auto-generated)
@@ -301,10 +340,10 @@ A 3-way merge driver that understands dotenvx encryption. Registered automatical
301
340
 
302
341
  How it works:
303
342
 
304
- 1. Decrypts all three versions (base, ours, theirs)
343
+ 1. Decrypts all three versions (base, ours, theirs) — supports both dotenvx and password encryption
305
344
  2. 3-way merge at the **key level** — not the encrypted ciphertext
306
345
  3. Only real conflicts get conflict markers
307
- 4. Re-encrypts the merged result
346
+ 4. Re-encrypts the merged result (password mode uses `encryptEnvMap`, dotenvx uses `dotenvx encrypt`)
308
347
 
309
348
  No more fake conflicts from identical values with different ciphertext.
310
349
 
@@ -415,7 +454,7 @@ export default {
415
454
  | `envFiles.pattern` | `string` | File naming pattern. `{env}` is replaced. `local` falls back to `.env` |
416
455
  | `envFiles.local` | `string` | Per-developer override file (gitignored) |
417
456
  | `envFiles.perApp` | `boolean` | Allow per-app `.env.{env}` files for app-specific overrides |
418
- | `encryption` | `"dotenvx" \| "none"` | Encryption method for `.env` files |
457
+ | `encryption` | `"password" \| "dotenvx" \| "none"` | Encryption method for `.env` files |
419
458
  | `apps.{name}.path` | `string` | Path to app directory relative to project root |
420
459
  | `apps.{name}.workers` | `Record<string, string>` | Worker name per environment |
421
460
  | `apps.{name}.secrets` | `string[]` | Secret keys pushed via `wrangler secret bulk` |
@@ -428,6 +467,48 @@ export default {
428
467
 
429
468
  Config file search order: `envsync.config.ts` > `.js` > `.mjs` > `envsync.json` > `envsync.jsonc`
430
469
 
470
+ ### Encryption
471
+
472
+ envsync supports three encryption modes:
473
+
474
+ | Mode | How it works | Dependencies |
475
+ |------|-------------|--------------|
476
+ | `"password"` | AES-256-GCM, per-value encryption with a shared password. Values stored as `envsync:v1:{base64}`. | None (uses `node:crypto`) |
477
+ | `"dotenvx"` | ECIES public/private key encryption via dotenvx CLI. | `dotenvx` CLI |
478
+ | `"none"` | No encryption. `.env` files stored in plain text. | None |
479
+
480
+ #### Password encryption
481
+
482
+ Per-value encryption means each key-value pair is encrypted independently — git diffs are readable at the key level, and merges work cleanly.
483
+
484
+ ```
485
+ # .env.staging (committed, encrypted)
486
+ DATABASE_URL=envsync:v1:base64encodedpayload...
487
+ API_KEY=envsync:v1:base64encodedpayload...
488
+ ```
489
+
490
+ **Password source** (checked in order):
491
+
492
+ 1. `ENVSYNC_PASSWORD_{ENV}` env var (e.g. `ENVSYNC_PASSWORD_STAGING`)
493
+ 2. `ENVSYNC_PASSWORD` env var (generic fallback)
494
+ 3. `.env.password` file with `ENVSYNC_PASSWORD_{ENV}=xxx` (env-specific)
495
+ 4. `.env.password` file with `ENVSYNC_PASSWORD=xxx` (generic fallback)
496
+
497
+ ```bash
498
+ # Quick setup
499
+ echo "ENVSYNC_PASSWORD=your-strong-password" > .env.password
500
+
501
+ # Or per-environment passwords
502
+ cat > .env.password << 'EOF'
503
+ ENVSYNC_PASSWORD_STAGING=staging-password
504
+ ENVSYNC_PASSWORD_PRODUCTION=production-password
505
+ EOF
506
+
507
+ # Encrypt plain values
508
+ envsync encrypt staging
509
+ envsync encrypt production
510
+ ```
511
+
431
512
  ### File structure
432
513
 
433
514
  ```
@@ -440,6 +521,7 @@ project/
440
521
  ├── .env.local # Per-developer overrides (gitignored)
441
522
  ├── .env.example # Key reference (committed)
442
523
  ├── .env.keys # dotenvx private keys (gitignored)
524
+ ├── .env.password # Password encryption keys (gitignored)
443
525
 
444
526
  ├── apps/
445
527
  │ ├── api/
@@ -454,7 +536,7 @@ project/
454
536
  │ ├── .dev.vars # ← generated
455
537
  │ └── .env # app-specific secrets (YOUTUBE_API_KEY, etc.)
456
538
 
457
- └── .gitignore # .env.local, .env.keys, **/.dev.vars
539
+ └── .gitignore # .env.local, .env.keys, .env.password, **/.dev.vars
458
540
  ```
459
541
 
460
542
  ### Merge priority
@@ -501,12 +583,39 @@ export default defineConfig({
501
583
  <tr><td><strong>.dev.vars</strong></td><td>Local dev secrets</td><td>Doesn't sync with anything</td></tr>
502
584
  </table>
503
585
 
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.
586
+ **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
587
 
506
588
  No SaaS. No dashboard. Just a CLI, your `.env` files, and Cloudflare's API.
507
589
 
508
590
  ---
509
591
 
592
+ ## Testing
593
+
594
+ 150 tests across 20 files covering utils, core modules, and all 10 commands.
595
+
596
+ ```bash
597
+ bun test # Run tests
598
+ bun test --coverage # Run with coverage report
599
+ ```
600
+
601
+ ### Coverage
602
+
603
+ | File | % Funcs | % Lines |
604
+ |------|---------|---------|
605
+ | **All files** | **83.93** | **76.13** |
606
+ | src/core/resolver.ts | 100.00 | 100.00 |
607
+ | src/core/wrangler.ts | 100.00 | 100.00 |
608
+ | src/core/env-file.ts | 100.00 | 98.65 |
609
+ | src/core/encryption.ts | 100.00 | 95.65 |
610
+ | src/utils/fs.ts | 100.00 | 97.44 |
611
+ | src/core/config.ts | 71.43 | 76.23 |
612
+ | src/commands/merge.ts | 75.00 | 28.40 |
613
+ | src/utils/output.ts | 25.00 | 12.70 |
614
+
615
+ > `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.
616
+
617
+ ---
618
+
510
619
  ## Tech Stack
511
620
 
512
621
  | | |
@@ -515,7 +624,7 @@ No SaaS. No dashboard. Just a CLI, your `.env` files, and Cloudflare's API.
515
624
  | **CLI framework** | [citty](https://github.com/unjs/citty) |
516
625
  | **Output** | [consola](https://github.com/unjs/consola) |
517
626
  | **Config loading** | [jiti](https://github.com/unjs/jiti) |
518
- | **Encryption** | [@dotenvx/dotenvx](https://dotenvx.com) |
627
+ | **Encryption** | `node:crypto` AES-256-GCM (password mode) / [@dotenvx/dotenvx](https://dotenvx.com) (dotenvx mode) |
519
628
  | **CF Secrets** | [wrangler](https://developers.cloudflare.com/workers/wrangler/) CLI (shell out) |
520
629
 
521
630
  ---
package/dist/index.js CHANGED
@@ -13007,6 +13007,9 @@ var require_main2 = __commonJS((exports, module) => {
13007
13007
  });
13008
13008
 
13009
13009
  // src/core/encryption.ts
13010
+ import { readFileSync } from "node:fs";
13011
+ import { join as join2 } from "node:path";
13012
+ import { scryptSync, randomBytes, createCipheriv, createDecipheriv } from "node:crypto";
13010
13013
  function decryptEnvContent(content, privateKey) {
13011
13014
  if (privateKey) {
13012
13015
  const prev = process.env.DOTENV_PRIVATE_KEY;
@@ -13023,27 +13026,154 @@ function decryptEnvContent(content, privateKey) {
13023
13026
  }
13024
13027
  return import_dotenvx.parse(content);
13025
13028
  }
13026
- function findPrivateKey(env2) {
13029
+ function loadEnvKeysFileSync(filePath) {
13030
+ try {
13031
+ const content = readFileSync(filePath, "utf-8");
13032
+ const result = {};
13033
+ for (const line of content.split(`
13034
+ `)) {
13035
+ const trimmed = line.trim();
13036
+ if (trimmed === "" || trimmed.startsWith("#"))
13037
+ continue;
13038
+ const eqIdx = trimmed.indexOf("=");
13039
+ if (eqIdx === -1)
13040
+ continue;
13041
+ const key = trimmed.slice(0, eqIdx).trim();
13042
+ let value = trimmed.slice(eqIdx + 1).trim();
13043
+ if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
13044
+ value = value.slice(1, -1);
13045
+ }
13046
+ result[key] = value;
13047
+ }
13048
+ return result;
13049
+ } catch {
13050
+ return {};
13051
+ }
13052
+ }
13053
+ function findPrivateKey(env2, projectRoot) {
13027
13054
  if (env2) {
13028
13055
  const envKey = `DOTENV_PRIVATE_KEY_${env2.toUpperCase()}`;
13029
13056
  if (process.env[envKey])
13030
13057
  return process.env[envKey];
13031
13058
  }
13032
- return process.env.DOTENV_PRIVATE_KEY;
13059
+ if (process.env.DOTENV_PRIVATE_KEY)
13060
+ return process.env.DOTENV_PRIVATE_KEY;
13061
+ if (projectRoot) {
13062
+ const keysFile = loadEnvKeysFileSync(join2(projectRoot, ".env.keys"));
13063
+ if (env2) {
13064
+ const envKey = `DOTENV_PRIVATE_KEY_${env2.toUpperCase()}`;
13065
+ if (keysFile[envKey])
13066
+ return keysFile[envKey];
13067
+ }
13068
+ if (keysFile.DOTENV_PRIVATE_KEY)
13069
+ return keysFile.DOTENV_PRIVATE_KEY;
13070
+ }
13071
+ return;
13072
+ }
13073
+ function isEnvsyncEncrypted(value) {
13074
+ return value.startsWith(ENVSYNC_PREFIX);
13075
+ }
13076
+ function encryptValue(plaintext, password) {
13077
+ const salt = randomBytes(16);
13078
+ const iv = randomBytes(12);
13079
+ const key = scryptSync(password, salt, 32);
13080
+ const cipher = createCipheriv("aes-256-gcm", key, iv);
13081
+ const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
13082
+ const tag = cipher.getAuthTag();
13083
+ const payload = Buffer.concat([salt, iv, encrypted, tag]);
13084
+ return ENVSYNC_PREFIX + payload.toString("base64");
13085
+ }
13086
+ function decryptValue(token, password) {
13087
+ if (!token.startsWith(ENVSYNC_PREFIX)) {
13088
+ throw new Error("Not an envsync-encrypted value");
13089
+ }
13090
+ const payload = Buffer.from(token.slice(ENVSYNC_PREFIX.length), "base64");
13091
+ const salt = payload.subarray(0, 16);
13092
+ const iv = payload.subarray(16, 28);
13093
+ const tag = payload.subarray(payload.length - 16);
13094
+ const encrypted = payload.subarray(28, payload.length - 16);
13095
+ const key = scryptSync(password, salt, 32);
13096
+ const decipher = createDecipheriv("aes-256-gcm", key, iv);
13097
+ decipher.setAuthTag(tag);
13098
+ return decipher.update(encrypted) + decipher.final("utf8");
13099
+ }
13100
+ function decryptEnvMap(envMap, password) {
13101
+ const result = {};
13102
+ for (const [key, value] of Object.entries(envMap)) {
13103
+ result[key] = isEnvsyncEncrypted(value) ? decryptValue(value, password) : value;
13104
+ }
13105
+ return result;
13106
+ }
13107
+ function encryptEnvMap(envMap, password) {
13108
+ const result = {};
13109
+ for (const [key, value] of Object.entries(envMap)) {
13110
+ if (isEnvsyncEncrypted(value) || value === "") {
13111
+ result[key] = value;
13112
+ } else {
13113
+ result[key] = encryptValue(value, password);
13114
+ }
13115
+ }
13116
+ return result;
13117
+ }
13118
+ function findPassword(env2, projectRoot) {
13119
+ if (env2) {
13120
+ const envKey = `ENVSYNC_PASSWORD_${env2.toUpperCase()}`;
13121
+ if (process.env[envKey])
13122
+ return process.env[envKey];
13123
+ }
13124
+ if (process.env.ENVSYNC_PASSWORD)
13125
+ return process.env.ENVSYNC_PASSWORD;
13126
+ if (projectRoot) {
13127
+ const passwordFile = loadEnvKeysFileSync(join2(projectRoot, ".env.password"));
13128
+ if (env2) {
13129
+ const envKey = `ENVSYNC_PASSWORD_${env2.toUpperCase()}`;
13130
+ if (passwordFile[envKey])
13131
+ return passwordFile[envKey];
13132
+ }
13133
+ if (passwordFile.ENVSYNC_PASSWORD)
13134
+ return passwordFile.ENVSYNC_PASSWORD;
13135
+ }
13136
+ return;
13033
13137
  }
13034
- var import_dotenvx;
13138
+ var import_dotenvx, ENVSYNC_PREFIX = "envsync:v1:";
13035
13139
  var init_encryption = __esm(() => {
13036
13140
  import_dotenvx = __toESM(require_main2(), 1);
13037
13141
  });
13038
13142
 
13039
13143
  // src/core/env-file.ts
13040
- import { join as join2 } from "node:path";
13041
- async function loadEnvFile(filePath, env2) {
13144
+ import { join as join3 } from "node:path";
13145
+ function parsePlainEnv(content) {
13146
+ const result = {};
13147
+ for (const line of content.split(`
13148
+ `)) {
13149
+ const trimmed = line.trim();
13150
+ if (trimmed === "" || trimmed.startsWith("#"))
13151
+ continue;
13152
+ const eqIdx = trimmed.indexOf("=");
13153
+ if (eqIdx === -1)
13154
+ continue;
13155
+ const key = trimmed.slice(0, eqIdx).trim();
13156
+ let value = trimmed.slice(eqIdx + 1).trim();
13157
+ if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
13158
+ value = value.slice(1, -1);
13159
+ }
13160
+ result[key] = value;
13161
+ }
13162
+ return result;
13163
+ }
13164
+ async function loadEnvFile(filePath, env2, projectRoot, encryption) {
13042
13165
  if (!fileExists(filePath)) {
13043
13166
  return {};
13044
13167
  }
13045
13168
  const content = await readFile(filePath);
13046
- const privateKey = findPrivateKey(env2);
13169
+ if (encryption === "password") {
13170
+ const envMap = parsePlainEnv(content);
13171
+ const password = findPassword(env2, projectRoot);
13172
+ if (!password)
13173
+ return envMap;
13174
+ return decryptEnvMap(envMap, password);
13175
+ }
13176
+ const privateKey = findPrivateKey(env2, projectRoot);
13047
13177
  return decryptEnvContent(content, privateKey);
13048
13178
  }
13049
13179
  async function writeEnvFile(filePath, envMap) {
@@ -13087,15 +13217,15 @@ function filterForApp(envMap, app) {
13087
13217
  function getRootEnvPath(config, environment) {
13088
13218
  const pattern = config.raw.envFiles.pattern;
13089
13219
  const relativePath = resolveEnvFilePath(pattern, environment);
13090
- return join2(config.projectRoot, relativePath);
13220
+ return join3(config.projectRoot, relativePath);
13091
13221
  }
13092
13222
  function getAppEnvPath(config, app, environment) {
13093
13223
  const pattern = config.raw.envFiles.pattern;
13094
13224
  const relativePath = resolveEnvFilePath(pattern, environment);
13095
- return join2(app.absolutePath, relativePath);
13225
+ return join3(app.absolutePath, relativePath);
13096
13226
  }
13097
13227
  function getLocalOverridePath(config) {
13098
- return join2(config.projectRoot, config.raw.envFiles.local);
13228
+ return join3(config.projectRoot, config.raw.envFiles.local);
13099
13229
  }
13100
13230
  var init_env_file = __esm(() => {
13101
13231
  init_encryption();
@@ -13104,17 +13234,19 @@ var init_env_file = __esm(() => {
13104
13234
  });
13105
13235
 
13106
13236
  // src/core/resolver.ts
13237
+ import { normalize } from "node:path";
13107
13238
  async function resolveAppEnv(config, app, environment) {
13108
13239
  const layers = [];
13240
+ const encryption = config.raw.encryption;
13109
13241
  const rootEnvPath = getRootEnvPath(config, environment);
13110
- const rootEnv = await loadEnvFile(rootEnvPath, environment);
13242
+ const rootEnv = await loadEnvFile(rootEnvPath, environment, config.projectRoot, encryption);
13111
13243
  if (Object.keys(rootEnv).length > 0) {
13112
13244
  layers.push({ source: rootEnvPath, map: rootEnv });
13113
13245
  }
13114
13246
  if (config.raw.envFiles.perApp) {
13115
13247
  const appEnvPath = getAppEnvPath(config, app, environment);
13116
- if (appEnvPath !== rootEnvPath) {
13117
- const appEnv = await loadEnvFile(appEnvPath, environment);
13248
+ if (normalize(appEnvPath) !== normalize(rootEnvPath)) {
13249
+ const appEnv = await loadEnvFile(appEnvPath, environment, config.projectRoot, encryption);
13118
13250
  if (Object.keys(appEnv).length > 0) {
13119
13251
  layers.push({ source: appEnvPath, map: appEnv });
13120
13252
  }
@@ -13158,7 +13290,7 @@ var exports_dev = {};
13158
13290
  __export(exports_dev, {
13159
13291
  default: () => dev_default
13160
13292
  });
13161
- import { join as join3, relative } from "node:path";
13293
+ import { join as join4, relative } from "node:path";
13162
13294
  function parseAppNames(args) {
13163
13295
  const rest = args._;
13164
13296
  return rest?.length ? rest : undefined;
@@ -13220,7 +13352,7 @@ var init_dev = __esm(() => {
13220
13352
  const localEnv = await loadEnvFile(localOverridePath);
13221
13353
  for (const app of apps) {
13222
13354
  const resolved = await resolveAppEnv(config, app, environment);
13223
- const devVarsPath = join3(app.absolutePath, ".dev.vars");
13355
+ const devVarsPath = join4(app.absolutePath, ".dev.vars");
13224
13356
  const relDevVars = relative(config.projectRoot, devVarsPath);
13225
13357
  if (Object.keys(resolved.map).length === 0) {
13226
13358
  consola.warn(` No env vars resolved for ${app.name}. Skipping.`);
@@ -13565,7 +13697,7 @@ var init_pull = __esm(() => {
13565
13697
  }
13566
13698
  consola.info(` Found ${remoteKeys.length} remote keys`);
13567
13699
  const envFilePath = getRootEnvPath(config, environment);
13568
- const localEnv = await loadEnvFile(envFilePath, environment);
13700
+ const localEnv = await loadEnvFile(envFilePath, environment, config.projectRoot, config.raw.encryption);
13569
13701
  const localKeys = new Set(Object.keys(localEnv));
13570
13702
  const missingKeys = remoteKeys.filter((k2) => !localKeys.has(k2));
13571
13703
  if (missingKeys.length === 0) {
@@ -13590,7 +13722,7 @@ var exports_validate = {};
13590
13722
  __export(exports_validate, {
13591
13723
  default: () => validate_default
13592
13724
  });
13593
- import { join as join4 } from "node:path";
13725
+ import { join as join5 } from "node:path";
13594
13726
  function parseAppNames4(args, skip = 1) {
13595
13727
  const rest = args._?.slice(skip);
13596
13728
  return rest?.length ? rest : undefined;
@@ -13638,7 +13770,7 @@ var init_validate = __esm(() => {
13638
13770
  appNames = undefined;
13639
13771
  }
13640
13772
  const apps = resolveApps(config, appNames);
13641
- const examplePath = join4(config.projectRoot, ".env.example");
13773
+ const examplePath = join5(config.projectRoot, ".env.example");
13642
13774
  if (!fileExists(examplePath)) {
13643
13775
  consola.error("No .env.example found at project root.");
13644
13776
  process.exit(1);
@@ -13736,10 +13868,10 @@ function printDiff(entries) {
13736
13868
  for (const entry of entries) {
13737
13869
  switch (entry.status) {
13738
13870
  case "added":
13739
- consola.log(` + ${entry.key} = ${maskValue(entry.remoteValue)}`);
13871
+ consola.log(` + ${entry.key} = ${maskValue(entry.localValue)}`);
13740
13872
  break;
13741
13873
  case "removed":
13742
- consola.log(` - ${entry.key} = ${maskValue(entry.localValue)}`);
13874
+ consola.log(` - ${entry.key}`);
13743
13875
  break;
13744
13876
  case "changed":
13745
13877
  consola.log(` ~ ${entry.key}`);
@@ -13917,7 +14049,7 @@ var exports_init = {};
13917
14049
  __export(exports_init, {
13918
14050
  default: () => init_default
13919
14051
  });
13920
- import { join as join5, relative as relative2, basename } from "node:path";
14052
+ import { join as join6, relative as relative2, basename } from "node:path";
13921
14053
  function generateConfigTS(config) {
13922
14054
  const lines = [
13923
14055
  `import { defineConfig } from "cf-envsync";`,
@@ -13998,7 +14130,7 @@ var init_init = __esm(() => {
13998
14130
  },
13999
14131
  async run({ args }) {
14000
14132
  const cwd = process.cwd();
14001
- const existingConfig = CONFIG_FILES.find((f3) => fileExists(join5(cwd, f3)));
14133
+ const existingConfig = CONFIG_FILES.find((f3) => fileExists(join6(cwd, f3)));
14002
14134
  if (existingConfig) {
14003
14135
  consola.warn(`${existingConfig} already exists.`);
14004
14136
  const overwrite = await consola.prompt("Overwrite?", {
@@ -14011,7 +14143,7 @@ var init_init = __esm(() => {
14011
14143
  }
14012
14144
  const encryption = await consola.prompt("Encryption method:", {
14013
14145
  type: "select",
14014
- options: ["dotenvx", "none"]
14146
+ options: ["password", "dotenvx", "none"]
14015
14147
  });
14016
14148
  const defaultEnvs = "local, staging, production";
14017
14149
  const envsInput = await consola.prompt("Environments (comma-separated):", {
@@ -14031,8 +14163,8 @@ var init_init = __esm(() => {
14031
14163
  consola.warn("No wrangler config files found. Creating manually.");
14032
14164
  }
14033
14165
  for (const wranglerFile of wranglerFiles.sort()) {
14034
- const fullPath = join5(cwd, wranglerFile);
14035
- const appDir = join5(cwd, wranglerFile, "..");
14166
+ const fullPath = join6(cwd, wranglerFile);
14167
+ const appDir = join6(cwd, wranglerFile, "..");
14036
14168
  const appPath = relative2(cwd, appDir);
14037
14169
  const appName = basename(appDir);
14038
14170
  consola.info(` Found ${wranglerFile}`);
@@ -14133,10 +14265,10 @@ var init_init = __esm(() => {
14133
14265
  apps,
14134
14266
  ...shared.length > 0 ? { shared } : {}
14135
14267
  };
14136
- const configPath = join5(cwd, "envsync.config.ts");
14268
+ const configPath = join6(cwd, "envsync.config.ts");
14137
14269
  const tsContent = generateConfigTS(config);
14138
14270
  if (existingConfig && existingConfig !== "envsync.config.ts") {
14139
- const oldPath = join5(cwd, existingConfig);
14271
+ const oldPath = join6(cwd, existingConfig);
14140
14272
  if (fileExists(oldPath)) {
14141
14273
  const { unlink } = await import("node:fs/promises");
14142
14274
  await unlink(oldPath);
@@ -14144,7 +14276,7 @@ var init_init = __esm(() => {
14144
14276
  }
14145
14277
  await writeFile(configPath, tsContent);
14146
14278
  consola.success("Created envsync.config.ts");
14147
- const examplePath = join5(cwd, ".env.example");
14279
+ const examplePath = join6(cwd, ".env.example");
14148
14280
  if (!fileExists(examplePath)) {
14149
14281
  const allKeys = new Set;
14150
14282
  for (const app of Object.values(apps)) {
@@ -14167,21 +14299,21 @@ var init_init = __esm(() => {
14167
14299
  for (const env2 of environments) {
14168
14300
  if (env2 === "local")
14169
14301
  continue;
14170
- const envFile = join5(cwd, `.env.${env2}`);
14302
+ const envFile = join6(cwd, `.env.${env2}`);
14171
14303
  if (!fileExists(envFile)) {
14172
14304
  await writeFile(envFile, `# ${env2} environment variables
14173
14305
  `);
14174
14306
  consola.success(`Created .env.${env2}`);
14175
14307
  }
14176
14308
  }
14177
- const rootEnv = join5(cwd, ".env");
14309
+ const rootEnv = join6(cwd, ".env");
14178
14310
  if (!fileExists(rootEnv)) {
14179
14311
  await writeFile(rootEnv, `# Local environment variables
14180
14312
  `);
14181
14313
  consola.success("Created .env");
14182
14314
  }
14183
- const gitignorePath = join5(cwd, ".gitignore");
14184
- const gitignoreEntries = [".env.local", ".env.keys", "**/.dev.vars"];
14315
+ const gitignorePath = join6(cwd, ".gitignore");
14316
+ const gitignoreEntries = [".env.local", ".env.keys", ".env.password", "**/.dev.vars"];
14185
14317
  if (fileExists(gitignorePath)) {
14186
14318
  const existing = await readFile(gitignorePath);
14187
14319
  const toAdd = gitignoreEntries.filter((e2) => !existing.includes(e2));
@@ -14201,7 +14333,7 @@ var init_init = __esm(() => {
14201
14333
  `);
14202
14334
  consola.success(`Created .gitignore`);
14203
14335
  }
14204
- const gitattrsPath = join5(cwd, ".gitattributes");
14336
+ const gitattrsPath = join6(cwd, ".gitattributes");
14205
14337
  const mergeDriverLines = `.env merge=envsync
14206
14338
  .env.* merge=envsync
14207
14339
  `;
@@ -14217,7 +14349,7 @@ var init_init = __esm(() => {
14217
14349
  await writeFile(gitattrsPath, mergeDriverLines);
14218
14350
  consola.success("Created .gitattributes with merge driver");
14219
14351
  }
14220
- const gitConfigPath = join5(cwd, ".git", "config");
14352
+ const gitConfigPath = join6(cwd, ".git", "config");
14221
14353
  if (fileExists(gitConfigPath)) {
14222
14354
  const gitConfig = await readFile(gitConfigPath);
14223
14355
  if (!gitConfig.includes('[merge "envsync"]')) {
@@ -14232,14 +14364,18 @@ var init_init = __esm(() => {
14232
14364
  }
14233
14365
  consola.info(`
14234
14366
  Next steps:`);
14235
- if (encryption === "dotenvx") {
14367
+ if (encryption === "password") {
14368
+ consola.info(' 1. Set a password: echo "ENVSYNC_PASSWORD=your-secret" > .env.password');
14369
+ consola.info(" 2. Add values to your .env files (plain KEY=VALUE)");
14370
+ consola.info(" 3. envsync encrypt staging (encrypts plain values in .env.staging)");
14371
+ } else if (encryption === "dotenvx") {
14236
14372
  consola.info(' 1. dotenvx set DATABASE_URL "value" -f .env');
14237
14373
  for (const env2 of environments.filter((e2) => e2 !== "local")) {
14238
14374
  consola.info(` 2. dotenvx set DATABASE_URL "${env2}_value" -f .env.${env2}`);
14239
14375
  }
14240
14376
  }
14241
- consola.info(` 3. echo "OAUTH_REDIRECT_URL=https://your-tunnel/callback" >> .env.local`);
14242
- consola.info(" 4. envsync dev");
14377
+ consola.info(` ${encryption === "password" ? "4" : "3"}. echo "OAUTH_REDIRECT_URL=https://your-tunnel/callback" >> .env.local`);
14378
+ consola.info(` ${encryption === "password" ? "5" : "4"}. envsync dev`);
14243
14379
  }
14244
14380
  });
14245
14381
  });
@@ -14333,6 +14469,10 @@ var init_normalize = __esm(() => {
14333
14469
  // src/commands/merge.ts
14334
14470
  var exports_merge = {};
14335
14471
  __export(exports_merge, {
14472
+ parseEnvLines: () => parseEnvLines,
14473
+ isPasswordEncrypted: () => isPasswordEncrypted,
14474
+ isEncrypted: () => isEncrypted,
14475
+ isDotenvxEncrypted: () => isDotenvxEncrypted,
14336
14476
  default: () => merge_default
14337
14477
  });
14338
14478
  function parseEnvLines(content) {
@@ -14353,9 +14493,15 @@ function parseEnvLines(content) {
14353
14493
  };
14354
14494
  });
14355
14495
  }
14356
- function isEncrypted(content) {
14496
+ function isDotenvxEncrypted(content) {
14357
14497
  return content.includes("encrypted:");
14358
14498
  }
14499
+ function isPasswordEncrypted(content) {
14500
+ return content.includes("envsync:v1:");
14501
+ }
14502
+ function isEncrypted(content) {
14503
+ return isDotenvxEncrypted(content) || isPasswordEncrypted(content);
14504
+ }
14359
14505
  var merge_default;
14360
14506
  var init_merge = __esm(() => {
14361
14507
  init_dist();
@@ -14391,11 +14537,24 @@ var init_merge = __esm(() => {
14391
14537
  readFile(args.ours),
14392
14538
  readFile(args.theirs)
14393
14539
  ]);
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 ?? ""]));
14540
+ const wasDotenvx = isDotenvxEncrypted(oursContent);
14541
+ const wasPassword = isPasswordEncrypted(oursContent);
14542
+ const wasEncrypted = wasDotenvx || wasPassword;
14543
+ const privateKey = findPrivateKey(undefined, process.cwd());
14544
+ const password = findPassword(undefined, process.cwd());
14545
+ function decryptContent(content) {
14546
+ if (isPasswordEncrypted(content)) {
14547
+ const plain = Object.fromEntries(parseEnvLines(content).filter((e2) => e2.key).map((e2) => [e2.key, e2.value ?? ""]));
14548
+ return password ? decryptEnvMap(plain, password) : plain;
14549
+ }
14550
+ if (isDotenvxEncrypted(content)) {
14551
+ return decryptEnvContent(content, privateKey);
14552
+ }
14553
+ return Object.fromEntries(parseEnvLines(content).filter((e2) => e2.key).map((e2) => [e2.key, e2.value ?? ""]));
14554
+ }
14555
+ const baseParsed = decryptContent(baseContent);
14556
+ const oursParsed = decryptContent(oursContent);
14557
+ const theirsParsed = decryptContent(theirsContent);
14399
14558
  const baseMap = new Map(Object.entries(baseParsed));
14400
14559
  const oursMap = new Map(Object.entries(oursParsed));
14401
14560
  const theirsMap = new Map(Object.entries(theirsParsed));
@@ -14452,10 +14611,19 @@ var init_merge = __esm(() => {
14452
14611
  `) + `
14453
14612
  `;
14454
14613
  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);
14614
+ if (wasPassword && password) {
14615
+ const plainMap = Object.fromEntries(parseEnvLines(mergedContent).filter((e2) => e2.key).map((e2) => [e2.key, e2.value ?? ""]));
14616
+ const encrypted = encryptEnvMap(plainMap, password);
14617
+ const encLines = Object.entries(encrypted).map(([k2, v2]) => `${k2}=${v2}`);
14618
+ await writeFile(args.ours, encLines.join(`
14619
+ `) + `
14620
+ `);
14621
+ } else {
14622
+ await writeFile(args.ours, mergedContent);
14623
+ const result = await exec(["dotenvx", "encrypt", "-f", args.ours]);
14624
+ if (!result.success) {
14625
+ consola.warn("Could not re-encrypt merged file:", result.stderr);
14626
+ }
14459
14627
  }
14460
14628
  } else {
14461
14629
  await writeFile(args.ours, mergedContent);
@@ -14612,6 +14780,124 @@ var init_list = __esm(() => {
14612
14780
  });
14613
14781
  });
14614
14782
 
14783
+ // src/commands/encrypt.ts
14784
+ var exports_encrypt = {};
14785
+ __export(exports_encrypt, {
14786
+ default: () => encrypt_default
14787
+ });
14788
+ function parseLines(content) {
14789
+ return content.split(`
14790
+ `).map((line) => {
14791
+ const trimmed = line.trim();
14792
+ if (trimmed === "" || trimmed.startsWith("#")) {
14793
+ return { raw: line };
14794
+ }
14795
+ const eqIdx = line.indexOf("=");
14796
+ if (eqIdx === -1) {
14797
+ return { raw: line };
14798
+ }
14799
+ return {
14800
+ key: line.slice(0, eqIdx).trim(),
14801
+ value: line.slice(eqIdx + 1),
14802
+ raw: line
14803
+ };
14804
+ });
14805
+ }
14806
+ var encrypt_default;
14807
+ var init_encrypt = __esm(() => {
14808
+ init_dist();
14809
+ init_dist2();
14810
+ init_config();
14811
+ init_env_file();
14812
+ init_fs();
14813
+ init_encryption();
14814
+ encrypt_default = defineCommand({
14815
+ meta: {
14816
+ name: "encrypt",
14817
+ description: "Encrypt plain .env values with password encryption"
14818
+ },
14819
+ args: {
14820
+ env: {
14821
+ type: "positional",
14822
+ description: "Target environment (e.g. staging, production)",
14823
+ required: true
14824
+ },
14825
+ "dry-run": {
14826
+ type: "boolean",
14827
+ description: "Preview changes without writing",
14828
+ default: false
14829
+ }
14830
+ },
14831
+ async run({ args }) {
14832
+ const environment = args.env;
14833
+ const rawConfig = await loadConfig();
14834
+ const errors = validateConfig(rawConfig);
14835
+ if (errors.length > 0) {
14836
+ for (const err of errors)
14837
+ consola.error(err);
14838
+ process.exit(1);
14839
+ }
14840
+ const config = resolveConfig(rawConfig);
14841
+ if (config.raw.encryption !== "password") {
14842
+ consola.error('encrypt command requires encryption: "password" in config.');
14843
+ process.exit(1);
14844
+ }
14845
+ if (!config.environments.includes(environment)) {
14846
+ consola.error(`Unknown environment: "${environment}". Available: ${config.environments.join(", ")}`);
14847
+ process.exit(1);
14848
+ }
14849
+ const password = findPassword(environment, config.projectRoot);
14850
+ if (!password) {
14851
+ consola.error(`No password found. Set ENVSYNC_PASSWORD or ENVSYNC_PASSWORD_${environment.toUpperCase()}, or create .env.password`);
14852
+ process.exit(1);
14853
+ }
14854
+ const envFilePath = getRootEnvPath(config, environment);
14855
+ if (!fileExists(envFilePath)) {
14856
+ consola.error(`File not found: ${envFilePath}`);
14857
+ process.exit(1);
14858
+ }
14859
+ const content = await readFile(envFilePath);
14860
+ const lines = parseLines(content);
14861
+ let encryptedCount = 0;
14862
+ let skippedCount = 0;
14863
+ const outputLines = [];
14864
+ for (const line of lines) {
14865
+ if (!line.key || line.value === undefined) {
14866
+ outputLines.push(line.raw);
14867
+ continue;
14868
+ }
14869
+ const value = line.value.trim();
14870
+ if (isEnvsyncEncrypted(value) || value === "") {
14871
+ outputLines.push(line.raw);
14872
+ skippedCount++;
14873
+ continue;
14874
+ }
14875
+ let plainValue = value;
14876
+ if (plainValue.startsWith('"') && plainValue.endsWith('"') || plainValue.startsWith("'") && plainValue.endsWith("'")) {
14877
+ plainValue = plainValue.slice(1, -1);
14878
+ }
14879
+ const encrypted = encryptValue(plainValue, password);
14880
+ outputLines.push(`${line.key}=${encrypted}`);
14881
+ encryptedCount++;
14882
+ consola.log(` ${line.key}: encrypted`);
14883
+ }
14884
+ if (encryptedCount === 0) {
14885
+ consola.info("No values to encrypt.");
14886
+ return;
14887
+ }
14888
+ const dryRun = args["dry-run"];
14889
+ if (dryRun) {
14890
+ consola.info(`[dry-run] Would encrypt ${encryptedCount} values in ${envFilePath}`);
14891
+ return;
14892
+ }
14893
+ await writeFile(envFilePath, outputLines.join(`
14894
+ `) + `
14895
+ `);
14896
+ consola.success(`Encrypted ${encryptedCount} values in ${envFilePath} (${skippedCount} skipped)`);
14897
+ }
14898
+ });
14899
+ });
14900
+
14615
14901
  // src/index.ts
14616
14902
  init_dist();
14617
14903
  var main = defineCommand({
@@ -14629,7 +14915,8 @@ var main = defineCommand({
14629
14915
  init: () => Promise.resolve().then(() => (init_init(), exports_init)).then((m2) => m2.default),
14630
14916
  normalize: () => Promise.resolve().then(() => (init_normalize(), exports_normalize)).then((m2) => m2.default),
14631
14917
  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)
14918
+ list: () => Promise.resolve().then(() => (init_list(), exports_list)).then((m2) => m2.default),
14919
+ encrypt: () => Promise.resolve().then(() => (init_encrypt(), exports_encrypt)).then((m2) => m2.default)
14633
14920
  }
14634
14921
  });
14635
14922
  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.0",
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>;