@jondotsoy/envs 0.1.5 → 0.1.7

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.
Files changed (3) hide show
  1. package/README.md +42 -15
  2. package/envs.js +163 -36
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -42,6 +42,7 @@ Run the commands inside the repo or any of its worktrees. Inside a worktree, `.e
42
42
  | `envs init` | Creates `.envs/`, `.envs/.gitignore` (containing `*`) and an empty `.envs/values.yml`. Does not overwrite existing files. |
43
43
  | `envs pull` | Reads the `.env` of the main repo and of each worktree and writes it to `values.yml`. Variables that are no longer in a `.env` are removed from `values.yml` for that worktree. |
44
44
  | `envs push` | Writes `values.yml` to the `.env` of each worktree. Updates existing variables in place, appends new ones at the end, and keeps comments and variables that are not in `values.yml`. |
45
+ | `envs use [profile]` | Sets the profile of the current worktree (`uses.<worktree>`) and pushes that worktree. Without arguments it lists the profile of each worktree. The profile must already exist in `values.yml`. |
45
46
  | `envs lint` | Checks security and prints a warning for each problem. Exits with code 1 if there is any (useful in CI). See below. |
46
47
  | `envs help` | Shows the help (also `--help`, `-h`, or no command). |
47
48
 
@@ -87,33 +88,59 @@ It never prints values, only the variable and its location (`envs.DB_PASSWORD.ma
87
88
  | `executable-files` | `values.yml` or a `.env` has the execute permission (use `chmod -x`). |
88
89
  | `writable-files` | `values.yml` or a `.env` is writable by the group or other users (use `chmod go-w`). |
89
90
  | `weak-secret` | Sensitive variable (`PASSWORD`, `TOKEN`, `SECRET`, `API_KEY`…) that is empty or has a typical value (`changeme`, `admin`…). |
90
- | `shared-secret` | Sensitive variable in `defaults`, which is written to all worktrees. |
91
+ | `shared-secret` | Sensitive variable in `defaults`, which is written to all worktrees using that profile. |
92
+ | `unknown-profile` | `uses` names a profile that is not in `profiles`. |
91
93
  | `secret-pattern` | Value with a known secret format (AWS, GitHub, Slack, `sk-…`, private key). |
92
94
  | `url-credentials`, `insecure-url` | Remote URL with an embedded password, or using `http://`, `ws://` or `ftp://`. |
93
95
 
94
96
  ## `values.yml` format
95
97
 
96
98
  ```yaml
97
- defaults:
98
- LOG_LEVEL: info
99
-
100
- envs:
101
- DATABASE_URL:
102
- main: postgres://localhost/main
103
- feature-x: postgres://localhost/feature_x
104
- PORT:
105
- main: "3000"
106
- feature-x: "3001"
99
+ uses:
100
+ main: local
101
+ feature-x: dev
102
+ profiles:
103
+ default:
104
+ defaults:
105
+ LOG_LEVEL: info
106
+ envs:
107
+ PORT:
108
+ main: 3000
109
+ feature-x: 3001
110
+ local:
111
+ defaults:
112
+ DATABASE_URL: postgres://localhost/app
113
+ envs: {}
114
+ dev:
115
+ defaults:
116
+ DATABASE_URL: postgres://dev.internal/app
117
+ envs:
118
+ PORT:
119
+ feature-x: 4001
107
120
  ```
108
121
 
109
- - `defaults`: default value of each variable. `push` writes it to the `.env` of all worktrees.
110
- - `envs.<VARIABLE>.<worktree>`: value of the variable in that worktree. It overrides the value from `defaults`.
122
+ - Every profile is written with both `defaults` and `envs` (empty when it has nothing); a profile is never `null`.
123
+ - `profiles.<profile>.defaults`: default value of each variable for the worktrees that use the profile.
124
+ - `profiles.<profile>.envs.<VARIABLE>.<worktree>`: value of the variable in that worktree. It overrides the profile's `defaults`.
125
+ - `uses.<worktree>`: profile of that worktree. A worktree that is not listed uses `default`; `pull` adds it as `default`.
111
126
  - Values can be strings, numbers or booleans (`PORT: 3000`, `DEBUG: true`); `push` writes them to the `.env` as text (`PORT=3000`, `DEBUG=true`). `pull` converts `true`/`false` and canonical numbers (`3000`, `0.5`) to booleans and numbers; anything else (`007`, `1e3`) stays a string.
112
127
 
113
- The schema (JSON Schema) is at [`schema/values.schema.json`](schema/values.schema.json). To have VS Code validate the file with the YAML extension, add this at the top of `values.yml`:
128
+ A worktree receives, from lowest to highest precedence: `default` defaults, `default` envs, its profile's defaults, its profile's envs. With the `default` profile only the first two apply.
129
+
130
+ The `.env` files are the source of truth: `pull` (and `edit`) rewrites `values.yml` from them, keeping only `uses` and `profiles`.
131
+
132
+ ### Creating a profile
133
+
134
+ Write a profile that does not exist yet in `uses.<worktree>` while running `envs edit`: when you close the editor it is created as a copy of `default` (and the worktree receives it). A profile you add by hand under `profiles`, with values or empty, keeps what you wrote (nothing is copied from `default`) and is completed with an empty `defaults`/`envs` if one is missing. `envs push` and `envs use` do not create profiles: they fail with an unknown profile error. `envs lint` reports it as `unknown-profile`.
135
+
136
+ ### Previous format
137
+
138
+ Files with `defaults` and `envs` at the root keep working: they are read as `profiles.default.defaults` and `profiles.default.envs`, and the next `envs edit` or `envs pull` rewrites the file in the new format.
139
+
140
+ The schema (JSON Schema) is at [`schema/values.schema.json`](schema/values.schema.json). It is also published at <https://jondotsoy.github.io/envs/schema.json>. `init`, `pull` and `edit` write this line at the top of `values.yml`, so VS Code validates the file with the YAML extension:
114
141
 
115
142
  ```yaml
116
- # yaml-language-server: $schema=../schema/values.schema.json
143
+ # yaml-language-server: $schema=https://jondotsoy.github.io/envs/schema.json
117
144
  ```
118
145
 
119
146
  ## Development
package/envs.js CHANGED
@@ -40,18 +40,24 @@ function isSensitiveKey(key) {
40
40
  }
41
41
  function entries({ values }) {
42
42
  const result = [];
43
- for (const [key, value] of Object.entries(values.defaults ?? {})) {
44
- result.push({ key, text: String(value), location: `defaults.${key}`, shared: true });
45
- }
46
- for (const [key, byWorktree] of Object.entries(values.envs ?? {})) {
47
- for (const [worktree, value] of Object.entries(byWorktree ?? {})) {
48
- result.push({
49
- key,
50
- text: String(value),
51
- location: `envs.${key}.${worktree}`,
52
- shared: false
53
- });
43
+ const collect = (prefix, section) => {
44
+ for (const [key, value] of Object.entries(section?.defaults ?? {})) {
45
+ result.push({ key, text: String(value), location: `${prefix}defaults.${key}`, shared: true });
46
+ }
47
+ for (const [key, byWorktree] of Object.entries(section?.envs ?? {})) {
48
+ for (const [worktree, value] of Object.entries(byWorktree ?? {})) {
49
+ result.push({
50
+ key,
51
+ text: String(value),
52
+ location: `${prefix}envs.${key}.${worktree}`,
53
+ shared: false
54
+ });
55
+ }
54
56
  }
57
+ };
58
+ collect("", values);
59
+ for (const [name, profile] of Object.entries(values.profiles ?? {})) {
60
+ collect(`profiles.${name}.`, profile);
55
61
  }
56
62
  return result;
57
63
  }
@@ -180,7 +186,7 @@ var rules = [
180
186
  },
181
187
  {
182
188
  id: "shared-secret",
183
- description: "A sensitive variable is in defaults, so it goes to every worktree.",
189
+ description: "A sensitive variable is in defaults, so it goes to every worktree using that profile.",
184
190
  check: (ctx) => entries(ctx).filter((e) => e.shared && isSensitiveKey(e.key)).map((e) => ({
185
191
  location: e.location,
186
192
  message: `${e.key} looks sensitive but is written to every worktree; set it per worktree under envs instead.`
@@ -215,6 +221,14 @@ var rules = [
215
221
  location: e.location,
216
222
  message: `${e.key} uses ${parseUrl(e.text).scheme}:// instead of an encrypted protocol.`
217
223
  }))
224
+ },
225
+ {
226
+ id: "unknown-profile",
227
+ description: "A worktree uses a profile that is not defined.",
228
+ check: ({ values }) => Object.entries(values.uses ?? {}).filter(([, profile]) => profile !== "default" && !Object.hasOwn(values.profiles ?? {}, profile)).map(([worktree, profile]) => ({
229
+ location: `uses.${worktree}`,
230
+ message: `Profile "${profile}" is not defined under profiles; \`envs edit\` creates it from default.`
231
+ }))
218
232
  }
219
233
  ];
220
234
 
@@ -227,6 +241,7 @@ Commands:
227
241
  push Write defaults and .envs/values.yml into the .env of each worktree
228
242
  lint Warn about insecure values and unprotected secret files
229
243
  edit Pull, open .envs/values.yml in VS Code (code -w), then push
244
+ use Select the profile of the current worktree (envs use <profile>), or list them
230
245
  help Show this help message
231
246
  `;
232
247
  var colorsDisabled = () => Boolean(process.env.NO_COLOR);
@@ -247,6 +262,72 @@ var defaultContext = () => ({
247
262
  return await proc.exited;
248
263
  }
249
264
  });
265
+ var DEFAULT_PROFILE = "default";
266
+ var hasProfile = (values, name) => Object.hasOwn(values.profiles, name);
267
+ function normalizeValues(raw) {
268
+ const profiles = {};
269
+ const add = (name, profile) => {
270
+ const target = profiles[name] ??= { defaults: {}, envs: {} };
271
+ Object.assign(target.defaults, profile?.defaults);
272
+ for (const [key, byWorktree] of Object.entries(profile?.envs ?? {})) {
273
+ Object.assign(target.envs[key] ??= {}, byWorktree);
274
+ }
275
+ };
276
+ add(DEFAULT_PROFILE, { defaults: raw.defaults, envs: raw.envs });
277
+ for (const [name, profile] of Object.entries(raw.profiles ?? {})) {
278
+ add(name, profile);
279
+ }
280
+ return { uses: { ...raw.uses }, profiles };
281
+ }
282
+ function materializeProfiles(values) {
283
+ const created = [];
284
+ for (const name of Object.values(values.uses).map(String)) {
285
+ if (hasProfile(values, name))
286
+ continue;
287
+ values.profiles[name] = structuredClone(values.profiles[DEFAULT_PROFILE]);
288
+ created.push(name);
289
+ }
290
+ return created;
291
+ }
292
+ function resolveEntries(values, worktree, profile, includeProfileEnvs = true) {
293
+ const entries = {};
294
+ for (const name of new Set([DEFAULT_PROFILE, profile])) {
295
+ const layer = values.profiles[name];
296
+ for (const [key, value] of Object.entries(layer.defaults)) {
297
+ entries[key] = String(value);
298
+ }
299
+ if (name === profile && !includeProfileEnvs)
300
+ continue;
301
+ for (const [key, byWorktree] of Object.entries(layer.envs)) {
302
+ if (byWorktree && worktree in byWorktree) {
303
+ entries[key] = String(byWorktree[worktree]);
304
+ }
305
+ }
306
+ }
307
+ return entries;
308
+ }
309
+ var hasCompleteProfiles = (raw) => Object.values(raw.profiles ?? {}).every((profile) => typeof profile?.defaults === "object" && profile.defaults !== null && typeof profile.envs === "object" && profile.envs !== null);
310
+ function stringifyValues(values) {
311
+ const profiles = Object.fromEntries(Object.entries(values.profiles).map(([name, { defaults, envs }]) => {
312
+ const kept = Object.fromEntries(Object.entries(envs).filter(([, byWorktree]) => Object.keys(byWorktree).length > 0));
313
+ return [name, { defaults, envs: kept }];
314
+ }));
315
+ const out = Object.keys(values.uses).length > 0 ? { uses: values.uses, profiles } : { profiles };
316
+ return SCHEMA_COMMENT + Bun.YAML.stringify(out, null, 2);
317
+ }
318
+ var SCHEMA_COMMENT = `# yaml-language-server: $schema=https://jondotsoy.github.io/envs/schema.json
319
+ `;
320
+ function checkProfiles(ctx, values, worktrees) {
321
+ let ok = true;
322
+ for (const { name } of worktrees) {
323
+ const profile = values.uses[name] ?? DEFAULT_PROFILE;
324
+ if (hasProfile(values, profile))
325
+ continue;
326
+ ok = false;
327
+ ctx.error(`Unknown profile "${profile}" for worktree ${name}. Available: ${Object.keys(values.profiles).join(", ")}. Run \`envs edit\` to create it.`);
328
+ }
329
+ return ok;
330
+ }
250
331
  async function findRoot(cwd) {
251
332
  const result = await $`git rev-parse --path-format=absolute --git-common-dir`.cwd(cwd).nothrow().quiet();
252
333
  if (result.exitCode !== 0)
@@ -336,7 +417,7 @@ async function readValues(ctx) {
336
417
  async function init(ctx) {
337
418
  const root = await findRoot(ctx.cwd);
338
419
  const files = { ".envs/.gitignore": `*
339
- `, ".envs/values.yml": "" };
420
+ `, ".envs/values.yml": SCHEMA_COMMENT };
340
421
  await mkdir(join(root, ".envs"), { recursive: true });
341
422
  for (const [name, content] of Object.entries(files)) {
342
423
  const path = join(root, name);
@@ -349,23 +430,21 @@ async function init(ctx) {
349
430
  }
350
431
  return 0;
351
432
  }
352
- async function push(ctx) {
433
+ async function push(ctx, only) {
353
434
  const read = await readValues(ctx);
354
435
  if (!read)
355
436
  return 1;
356
- const { values } = read;
437
+ const values = normalizeValues(read.values);
438
+ const worktrees = (await listWorktrees(ctx.cwd)).filter(({ name }) => only === undefined || name === only);
439
+ if (!checkProfiles(ctx, values, worktrees))
440
+ return 1;
357
441
  const changes = new Map;
358
- for (const { name, path } of await listWorktrees(ctx.cwd)) {
359
- const entries = {};
360
- for (const [key, value] of Object.entries(values.defaults ?? {})) {
361
- entries[key] = String(value);
362
- }
363
- for (const [key, byWorktree] of Object.entries(values.envs ?? {})) {
364
- if (name in byWorktree)
365
- entries[key] = String(byWorktree[name]);
366
- }
442
+ for (const { name, path } of worktrees) {
443
+ const profile = values.uses[name] ?? DEFAULT_PROFILE;
444
+ const entries = resolveEntries(values, name, profile);
367
445
  if (Object.keys(entries).length === 0)
368
446
  continue;
447
+ const label = profile === DEFAULT_PROFILE ? name : `${name} [${profile}]`;
369
448
  const dotenv = Bun.file(join(path, ".env"));
370
449
  const current = await dotenv.exists() ? await dotenv.text() : "";
371
450
  const existing = parseDotenv(current);
@@ -374,7 +453,7 @@ async function push(ctx) {
374
453
  continue;
375
454
  const shown = isMaskedKey(key) ? MASK : formatDotenvValue(value);
376
455
  const line = `${key}=${shown}`;
377
- changes.set(line, [...changes.get(line) ?? [], name]);
456
+ changes.set(line, [...changes.get(line) ?? [], label]);
378
457
  }
379
458
  await Bun.write(dotenv, updateDotenv(current, entries));
380
459
  }
@@ -387,12 +466,13 @@ async function pull(ctx) {
387
466
  const read = await readValues(ctx);
388
467
  if (!read)
389
468
  return 1;
390
- const { path: valuesPath, values } = read;
391
- const envs = values.envs ??= {};
392
- const defaults = values.defaults ?? {};
469
+ const values = normalizeValues(read.values);
470
+ const worktrees = await listWorktrees(ctx.cwd);
471
+ materializeProfiles(values);
393
472
  const pulledNames = [];
394
473
  const pulledKeys = new Set;
395
- for (const { name, path } of await listWorktrees(ctx.cwd)) {
474
+ for (const { name, path } of worktrees) {
475
+ const profile = values.uses[name] ??= DEFAULT_PROFILE;
396
476
  const dotenv = Bun.file(join(path, ".env"));
397
477
  if (!await dotenv.exists())
398
478
  continue;
@@ -400,14 +480,15 @@ async function pull(ctx) {
400
480
  pulledNames.push(name);
401
481
  for (const key of Object.keys(parsed))
402
482
  pulledKeys.add(key);
483
+ const { envs } = values.profiles[profile];
403
484
  for (const [key, byWorktree] of Object.entries(envs)) {
404
485
  if (!(key in parsed))
405
486
  delete byWorktree[name];
406
487
  }
488
+ const base = resolveEntries(values, name, profile, false);
407
489
  for (const [key, value] of Object.entries(parsed)) {
408
490
  const parsedValue = parseValue(value);
409
- const fromDefaults = key in defaults && String(defaults[key]) === String(parsedValue);
410
- if (fromDefaults) {
491
+ if (key in base && base[key] === String(parsedValue)) {
411
492
  if (envs[key])
412
493
  delete envs[key][name];
413
494
  } else {
@@ -415,16 +496,40 @@ async function pull(ctx) {
415
496
  }
416
497
  }
417
498
  }
418
- for (const [key, byWorktree] of Object.entries(envs)) {
419
- if (Object.keys(byWorktree).length === 0)
420
- delete envs[key];
421
- }
422
- await Bun.write(valuesPath, Bun.YAML.stringify(values, null, 2));
499
+ await Bun.write(read.path, stringifyValues(values));
423
500
  if (pulledNames.length > 0) {
424
501
  ctx.log(green(`\u2193 pulling ${pulledNames.join(", ")} - ${pulledKeys.size} variables`));
425
502
  }
426
503
  return 0;
427
504
  }
505
+ async function use(ctx, profile) {
506
+ const read = await readValues(ctx);
507
+ if (!read)
508
+ return 1;
509
+ const values = normalizeValues(read.values);
510
+ const worktrees = await listWorktrees(ctx.cwd);
511
+ if (profile === undefined) {
512
+ for (const { name } of worktrees) {
513
+ ctx.log(`${name}: ${values.uses[name] ?? DEFAULT_PROFILE}`);
514
+ }
515
+ return 0;
516
+ }
517
+ if (!hasProfile(values, profile)) {
518
+ ctx.error(`Unknown profile "${profile}". Available: ${Object.keys(values.profiles).join(", ")}. Add it in \`envs edit\` first.`);
519
+ return 1;
520
+ }
521
+ const top = await $`git rev-parse --show-toplevel`.cwd(ctx.cwd).nothrow().quiet();
522
+ const current = worktrees.find(({ path }) => path === top.stdout.toString().trim());
523
+ if (!current) {
524
+ ctx.error("Run `envs use` inside a git worktree.");
525
+ return 1;
526
+ }
527
+ for (const { name } of worktrees)
528
+ values.uses[name] ??= DEFAULT_PROFILE;
529
+ values.uses[current.name] = profile;
530
+ await Bun.write(read.path, stringifyValues(values));
531
+ return await push(ctx, current.name);
532
+ }
428
533
  async function isIgnored(dir, file) {
429
534
  const result = await $`git check-ignore -q ${file}`.cwd(dir).nothrow().quiet();
430
535
  return result.exitCode === 0;
@@ -477,11 +582,31 @@ async function edit(ctx) {
477
582
  const pulled = await pull(ctx);
478
583
  if (pulled !== 0)
479
584
  return pulled;
585
+ const original = await Bun.file(valuesPath).text();
586
+ await Bun.write(valuesPath, EDIT_NOTICE + original);
480
587
  const code = await ctx.openEditor(valuesPath);
588
+ const edited = await Bun.file(valuesPath).text();
589
+ if (edited.startsWith(EDIT_NOTICE)) {
590
+ await Bun.write(valuesPath, edited.slice(EDIT_NOTICE.length));
591
+ }
481
592
  if (code !== 0)
482
593
  return code;
594
+ const raw = Bun.YAML.parse(await Bun.file(valuesPath).text()) ?? {};
595
+ const values = normalizeValues(raw);
596
+ const created = materializeProfiles(values);
597
+ if (created.length > 0 || !hasCompleteProfiles(raw)) {
598
+ await Bun.write(valuesPath, stringifyValues(values));
599
+ }
600
+ if (created.length > 0) {
601
+ ctx.log(green(`+ created ${created.join(", ")} from ${DEFAULT_PROFILE}`));
602
+ }
483
603
  return await push(ctx);
484
604
  }
605
+ var EDIT_NOTICE = `# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
606
+ # Close this file when your changes are ready, then they are pushed.
607
+ # To cancel the edit, press Ctrl+C in the terminal and then close this file.
608
+ # \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
609
+ `;
485
610
  async function run(args, ctx = defaultContext()) {
486
611
  const [command = "help"] = args;
487
612
  switch (command) {
@@ -500,6 +625,8 @@ async function run(args, ctx = defaultContext()) {
500
625
  return await lint(ctx);
501
626
  case "edit":
502
627
  return await edit(ctx);
628
+ case "use":
629
+ return await use(ctx, args[1]);
503
630
  default:
504
631
  ctx.error(`Unknown command: ${command}
505
632
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jondotsoy/envs",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "license": "MIT",
5
5
  "author": {
6
6
  "name": "Jonathan Delgado",