cf-envsync 0.3.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
@@ -93,6 +93,135 @@ envsync validate
93
93
 
94
94
  ---
95
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
+ ```
222
+
223
+ ---
224
+
96
225
  ## Commands
97
226
 
98
227
  ### `envsync dev` — Generate `.dev.vars`
@@ -130,6 +259,8 @@ Done!
130
259
 
131
260
  Every key shows exactly where its value came from. Missing per-dev overrides are caught immediately.
132
261
 
262
+ > **Vite / non-wrangler apps:** Set `devFile: ".env.local"` in your app config. See [the tutorial](#4-local-development).
263
+
133
264
  ---
134
265
 
135
266
  ### `envsync push` — Deploy secrets
@@ -459,6 +590,7 @@ export default {
459
590
  | `apps.{name}.workers` | `Record<string, string>` | Worker name per environment |
460
591
  | `apps.{name}.secrets` | `string[]` | Secret keys pushed via `wrangler secret bulk` |
461
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 |
462
594
  | `shared` | `string[]` | Keys with the same value across multiple apps |
463
595
  | `local.overrides` | `string[]` | Keys each developer must set in `.env.local` |
464
596
  | `local.perApp` | `Record<string, string[]>` | Per-app developer override keys |
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) {
@@ -13166,6 +13176,16 @@ async function loadEnvFile(filePath, env2, projectRoot, encryption) {
13166
13176
  return {};
13167
13177
  }
13168
13178
  const content = await readFile(filePath);
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
+ }
13169
13189
  if (encryption === "password") {
13170
13190
  const envMap = parsePlainEnv(content);
13171
13191
  const password = findPassword(env2, projectRoot);
@@ -13228,17 +13248,22 @@ function getLocalOverridePath(config) {
13228
13248
  return join3(config.projectRoot, config.raw.envFiles.local);
13229
13249
  }
13230
13250
  var init_env_file = __esm(() => {
13251
+ init_dist2();
13231
13252
  init_encryption();
13232
13253
  init_config();
13233
13254
  init_fs();
13234
13255
  });
13235
13256
 
13236
13257
  // src/core/resolver.ts
13237
- import { normalize } from "node:path";
13258
+ import { normalize, relative } from "node:path";
13238
13259
  async function resolveAppEnv(config, app, environment) {
13239
13260
  const layers = [];
13240
13261
  const encryption = config.raw.encryption;
13241
13262
  const rootEnvPath = getRootEnvPath(config, 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
+ }
13242
13267
  const rootEnv = await loadEnvFile(rootEnvPath, environment, config.projectRoot, encryption);
13243
13268
  if (Object.keys(rootEnv).length > 0) {
13244
13269
  layers.push({ source: rootEnvPath, map: rootEnv });
@@ -13281,8 +13306,12 @@ function findMissingOverrides(config, app, localEnv) {
13281
13306
  }
13282
13307
  return missing;
13283
13308
  }
13309
+ var warnedMissingFiles;
13284
13310
  var init_resolver = __esm(() => {
13311
+ init_dist2();
13285
13312
  init_env_file();
13313
+ init_fs();
13314
+ warnedMissingFiles = new Set;
13286
13315
  });
13287
13316
 
13288
13317
  // src/commands/dev.ts
@@ -13290,13 +13319,13 @@ var exports_dev = {};
13290
13319
  __export(exports_dev, {
13291
13320
  default: () => dev_default
13292
13321
  });
13293
- import { join as join4, relative } from "node:path";
13322
+ import { join as join4, relative as relative2 } from "node:path";
13294
13323
  function parseAppNames(args) {
13295
13324
  const rest = args._;
13296
13325
  return rest?.length ? rest : undefined;
13297
13326
  }
13298
13327
  function formatSource(source, key, projectRoot, sharedKeys, localOverrideKeys) {
13299
- const rel = relative(projectRoot, source);
13328
+ const rel = relative2(projectRoot, source);
13300
13329
  if (localOverrideKeys.has(key))
13301
13330
  return `${rel} (per-dev override)`;
13302
13331
  if (sharedKeys.has(key))
@@ -13352,26 +13381,21 @@ var init_dev = __esm(() => {
13352
13381
  const localEnv = await loadEnvFile(localOverridePath);
13353
13382
  for (const app of apps) {
13354
13383
  const resolved = await resolveAppEnv(config, app, environment);
13355
- const devVarsPath = join4(app.absolutePath, ".dev.vars");
13356
- const relDevVars = relative(config.projectRoot, devVarsPath);
13357
13384
  if (Object.keys(resolved.map).length === 0) {
13358
13385
  consola.warn(` No env vars resolved for ${app.name}. Skipping.`);
13359
13386
  continue;
13360
13387
  }
13361
- if (args["dry-run"]) {
13362
- consola.log(`
13363
- ${relDevVars}`);
13364
- for (let i2 = 0;i2 < resolved.entries.length; i2++) {
13365
- const entry = resolved.entries[i2];
13366
- const isLast = i2 === resolved.entries.length - 1;
13367
- const prefix = isLast ? "└" : "├";
13368
- const src2 = formatSource(entry.source, entry.key, config.projectRoot, sharedKeys, localOverrideKeys);
13369
- 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}`);
13370
13398
  }
13371
- } else {
13372
- await writeEnvFile(devVarsPath, resolved.map);
13373
- consola.log(`
13374
- ${relDevVars}`);
13375
13399
  for (let i2 = 0;i2 < resolved.entries.length; i2++) {
13376
13400
  const entry = resolved.entries[i2];
13377
13401
  const isLast = i2 === resolved.entries.length - 1;
@@ -13385,12 +13409,18 @@ var init_dev = __esm(() => {
13385
13409
  if (missing.length > 0) {
13386
13410
  for (const key of missing) {
13387
13411
  consola.warn(`
13388
- ⚠ Missing in ${relative(config.projectRoot, localOverridePath)}: ${key} (required per-dev override)`);
13389
- 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)}`);
13390
13414
  }
13391
13415
  }
13392
13416
  }
13393
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
+ }
13394
13424
  consola.success(`
13395
13425
  Done!`);
13396
13426
  }
@@ -13572,14 +13602,33 @@ var init_push = __esm(() => {
13572
13602
  consola.warn("No apps to process.");
13573
13603
  return;
13574
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
+ }
13575
13614
  const sharedKeys = new Set(config.raw.shared ?? []);
13576
- 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}] ` : "";
13577
13626
  const workerName = getWorkerName(app, environment);
13578
13627
  if (!workerName) {
13579
- consola.warn(` No worker defined for ${app.name} in ${environment}. Skipping.`);
13628
+ consola.warn(`${progress}No worker defined for ${app.name} in ${environment}. Skipping.`);
13580
13629
  continue;
13581
13630
  }
13582
- consola.start(`Pushing secrets for ${app.name} → ${workerName} (${environment})...`);
13631
+ consola.start(`${progress}Pushing secrets for ${app.name} → ${workerName} (${environment})...`);
13583
13632
  const resolved = await resolveAppEnv(config, app, environment);
13584
13633
  let secretsToPush;
13585
13634
  if (args.shared) {
@@ -13600,7 +13649,8 @@ var init_push = __esm(() => {
13600
13649
  }
13601
13650
  const keyCount = Object.keys(secretsToPush).length;
13602
13651
  if (keyCount === 0) {
13603
- 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.`);
13604
13654
  continue;
13605
13655
  }
13606
13656
  if (args["dry-run"]) {
@@ -13623,8 +13673,13 @@ var init_push = __esm(() => {
13623
13673
  consola.success(` Pushed ${keyCount} secrets to ${workerName}`);
13624
13674
  } else {
13625
13675
  consola.error(` Failed to push secrets to ${workerName}`);
13676
+ hasFailure = true;
13626
13677
  }
13627
13678
  }
13679
+ if (hasFailure) {
13680
+ consola.error("Some pushes failed.");
13681
+ process.exit(1);
13682
+ }
13628
13683
  consola.success("Done!");
13629
13684
  }
13630
13685
  });
@@ -13760,11 +13815,18 @@ var init_validate = __esm(() => {
13760
13815
  let environments;
13761
13816
  let appNames;
13762
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
+ }
13763
13824
  environments = [envArg];
13764
13825
  appNames = parseAppNames4(args);
13765
13826
  } else if (envArg) {
13766
13827
  environments = config.environments;
13767
13828
  appNames = parseAppNames4(args, 0);
13829
+ consola.info(`"${envArg}" is not an environment. Treating as app name. Validating all environments.`);
13768
13830
  } else {
13769
13831
  environments = config.environments;
13770
13832
  appNames = undefined;
@@ -13783,10 +13845,12 @@ var init_validate = __esm(() => {
13783
13845
  ...config.raw.local?.overrides ?? [],
13784
13846
  ...Object.values(config.raw.local?.perApp ?? {}).flat()
13785
13847
  ]);
13848
+ const totalChecks = environments.length * apps.length;
13786
13849
  consola.log(`
13787
- Checking against .env.example...`);
13850
+ Checking against .env.example... (${environments.length} env × ${apps.length} app${apps.length > 1 ? "s" : ""})`);
13788
13851
  const results = [];
13789
13852
  let hasIssues = false;
13853
+ let checkIdx = 0;
13790
13854
  for (const environment of environments) {
13791
13855
  consola.log(`
13792
13856
  ${environment}`);
@@ -13951,8 +14015,15 @@ var init_diff = __esm(() => {
13951
14015
  process.exit(1);
13952
14016
  }
13953
14017
  const target = args.target;
13954
- const isEnvVsEnv = target && config.environments.includes(target);
13955
- 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) {
13956
14027
  const env2 = target;
13957
14028
  const appNames = parseAppNames5(args, 2);
13958
14029
  const apps = resolveApps(config, appNames);
@@ -14049,7 +14120,7 @@ var exports_init = {};
14049
14120
  __export(exports_init, {
14050
14121
  default: () => init_default
14051
14122
  });
14052
- import { join as join6, relative as relative2, basename } from "node:path";
14123
+ import { join as join6, relative as relative3, basename } from "node:path";
14053
14124
  function generateConfigTS(config) {
14054
14125
  const lines = [
14055
14126
  `import { defineConfig } from "cf-envsync";`,
@@ -14133,7 +14204,16 @@ var init_init = __esm(() => {
14133
14204
  const existingConfig = CONFIG_FILES.find((f3) => fileExists(join6(cwd, f3)));
14134
14205
  if (existingConfig) {
14135
14206
  consola.warn(`${existingConfig} already exists.`);
14136
- 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?", {
14137
14217
  type: "confirm"
14138
14218
  });
14139
14219
  if (!overwrite) {
@@ -14160,12 +14240,13 @@ var init_init = __esm(() => {
14160
14240
  return (name === "wrangler.json" || name === "wrangler.jsonc") && !f3.includes("node_modules");
14161
14241
  });
14162
14242
  if (wranglerFiles.length === 0) {
14163
- 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.");
14164
14245
  }
14165
14246
  for (const wranglerFile of wranglerFiles.sort()) {
14166
14247
  const fullPath = join6(cwd, wranglerFile);
14167
14248
  const appDir = join6(cwd, wranglerFile, "..");
14168
- const appPath = relative2(cwd, appDir);
14249
+ const appPath = relative3(cwd, appDir);
14169
14250
  const appName = basename(appDir);
14170
14251
  consola.info(` Found ${wranglerFile}`);
14171
14252
  let wranglerConfig = {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cf-envsync",
3
- "version": "0.3.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": {
@@ -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
  }