@saasak/tool-env 0.0.3 → 1.0.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.
Files changed (3) hide show
  1. package/README.md +77 -0
  2. package/bin/index.js +235 -168
  3. package/package.json +1 -1
package/README.md ADDED
@@ -0,0 +1,77 @@
1
+ # Env management
2
+
3
+ ## Warnings
4
+
5
+ - Make sure to have the git hook setup OR TO add any new variable viw the dedicated subcommand
6
+ - Obviously do not commit your password file
7
+ - In variable composition, the "static" parts are NOT encrypted (static part should never contain sensitive information)
8
+
9
+ ## Goals
10
+
11
+ - Centralize env variables definitions
12
+ - Allow explicit variables composition
13
+ - Good DX (setup and forget)
14
+
15
+ ## TODOs
16
+
17
+ [] Handle encryption
18
+ [] Handle variable composition everywhere (not just in overrides)
19
+ [] Create runtime library to read vars (even encrypted)
20
+ [] Handle env.local files
21
+
22
+ ## Open questions
23
+
24
+ - Should we write all the env with the next.js convention ?
25
+ - How to handle adding a var easily in an encrypted context ?
26
+ - Should we split BUILD / RUN variables ?
27
+
28
+ ## Examples
29
+
30
+ in package.json (root)
31
+ ```json
32
+ {
33
+ "scripts": {
34
+ "postinstall": "wrenv --secret ~/.big-secret write --target dev",
35
+ "env:update": "wrenv --secret ~/.big-secret write"
36
+ }
37
+ }
38
+ ```
39
+
40
+ and then later
41
+ ```bash
42
+ pnpm run env:update --target prod
43
+ ```
44
+ or
45
+ ```bash
46
+ WRENV_TARGET=staging bun run env:update
47
+ ```
48
+
49
+ you can also pass the secret via an env variable (Even though it is not really encouraged)
50
+ ```bash
51
+ WRENV_SECRET=super-secret WRENV_TARGET=prod npm run env:update
52
+ ```
53
+
54
+ or via stdin
55
+ ```bash
56
+ cat ~/.big-secret | wrenv --secret=stdin write --target=dev
57
+ wrenv --secret stdin --target=dev < ~/.big-secret
58
+ ```
59
+
60
+ ## Git hooks
61
+
62
+ Wrenv provide a git hook (to be configured independently with the solution of your choosing) to encrypt all added variables
63
+ So in `.git/hooks/pre-commit` you can add
64
+ ```bash
65
+ bun run wrenv --secret ~/.big-secret besafe
66
+ ```
67
+ This will run on the .env.json file and make sure all variables are encrypted.
68
+ This way you can add new variables and make sure they don't leak, offering minimum friction.
69
+
70
+ /!\ You must make sure to add this hook OR to always add var via the dedicated subcommand `wrenv add`
71
+
72
+
73
+ ## Add a variable
74
+
75
+ To add a new variable use the `add` subcommand
76
+ ```bash
77
+ wrenv --secret=~/.big-secret add NEW_VAR +fallback=@@_VALUE +dev=DEV_VALUE +production=PROD_VALUE
package/bin/index.js CHANGED
@@ -1,12 +1,29 @@
1
1
  import minimist from 'minimist';
2
2
  import fs from 'fs-extra';
3
3
  import path from 'path';
4
- import { $ } from 'execa';
4
+ import { execa } from 'execa';
5
+
6
+ import { buildEnv } from '../src/utils-env.js';
7
+ import { findMonorepoPackages } from '../src/utils-pkg.js';
8
+ import { encrypt, isEncrypted } from '../src/utils-crypto.js';
5
9
 
6
10
  const args = minimist(process.argv.slice(2));
7
11
  const __root = process.cwd();
8
12
 
9
- // Here we fetch the target env from cli args
13
+ const command = args._[0] || 'write';
14
+
15
+ const secret = args.secret
16
+ ? (args.secret === 'stdin' ? fs.readFileSync(0, 'utf8') : fs.readFileSync(args.secret, 'utf8'))
17
+ : process.env.WRENV_SECRET || process.env.TARGET_SECRET || '';
18
+
19
+ const envPath = args.env || '.env.json';
20
+ const envFile = path.resolve(__root, envPath);
21
+
22
+ if (!fs.existsSync(envFile)) {
23
+ console.error('No env file found.');
24
+ process.exit(1);
25
+ }
26
+
10
27
  const DEFAULT_ENV = 'dev';
11
28
  const targets = {
12
29
  "development": "dev",
@@ -17,187 +34,237 @@ const targets = {
17
34
  "prod": "production",
18
35
  "production": "production",
19
36
  }
20
- const target = targets[args.target] || DEFAULT_ENV;
21
37
 
22
- // Here we fetch the master env.json file
23
- // from which we will build all the .env files
24
- const envPath = args.env || '.env.json';
25
- const maybeEnvFile = path.resolve(__root, envPath);
26
- const envFile = fs.existsSync(maybeEnvFile) ? maybeEnvFile : null;
38
+ async function showCommand() {
39
+ if (!secret) {
40
+ console.log('Secret not provided. Use --secret flag or WRENV_SECRET env variable.');
41
+ }
42
+
43
+ const target = targets[args.target || process.env.WRENV_TARGET || process.env.TARGET_ENV] || DEFAULT_ENV;
44
+
45
+ const envContent = fs.readFileSync(envFile, 'utf8');
46
+ const env = JSON.parse(envContent);
47
+
48
+ const rootPkgPath = path.resolve(__root, 'package.json');
49
+ const rootPkgContent = fs.readFileSync(rootPkgPath, 'utf8');
50
+ const rootPkg = JSON.parse(rootPkgContent);
51
+ const scope = rootPkg.name.split('/')[0];
52
+
53
+ const [_, ...foundPackages] = await findMonorepoPackages(__root, scope);
54
+ const deps = [
55
+ ...foundPackages.map((pkg) => ({
56
+ name: pkg.name.replace(`${scope}/`, ''),
57
+ dir: pkg.dir,
58
+ })),
59
+ {
60
+ name: 'root',
61
+ dir: __root,
62
+ },
63
+ ];
64
+ const depsName = deps.map((dep) => dep.name);
65
+
66
+ const allowedEnvs = env && env.envs && Array.isArray(env.envs) ? env.envs : [DEFAULT_ENV];
67
+ const targetEnv = allowedEnvs.find((env) => env === target);
68
+ if (!targetEnv) {
69
+ console.error(`Target env "${target}" is not allowed.`);
70
+ process.exit(1);
71
+ }
72
+
73
+ const allEnvs = buildEnv(secret, targetEnv, env, depsName);
74
+
75
+ for await (const dep of deps) {
76
+ const pkgEnv = Object.entries(allEnvs[dep.name] || {})
77
+ .map(([key, value]) => `${key}=${value}`)
78
+ .join('\n')
79
+ console.log('~~~~~~~~~~~~~~~~~~~~~~~~~~~~');
80
+ console.log(`=== ENV for ${dep.name} in ${dep.dir} with target ${targetEnv}`);
81
+ console.log(pkgEnv);
82
+ }
27
83
 
28
- if (!envFile) {
29
- console.error('No env file found.');
30
- process.exit(1);
31
84
  }
32
85
 
33
- const envContent = fs.readFileSync(envFile, 'utf8');
34
- const env = JSON.parse(envContent);
35
-
36
- // Here we fetch the root package.json file
37
- // And all packages that live in the monorepo
38
- // So we can extract names and directories
39
- const rootPkgPath = path.resolve(__root, 'package.json');
40
- const rootPkgContent = fs.readFileSync(rootPkgPath, 'utf8');
41
- const rootPkg = JSON.parse(rootPkgContent);
42
- const scope = rootPkg.name.split('/')[0];
43
-
44
- const packageList = await $`pnpm ls -r --depth=-1`
45
- const packageDirs = packageList.stdout
46
- .split('\n')
47
- .filter((line) => !!line)
48
- .map((line) => line.split(' ')[1]);
49
-
50
- const [_, ...depPkgs] = packageDirs;
51
- const deps = depPkgs.map((pkgDir) => {
52
- const pkgFile = fs.readFileSync(path.join(pkgDir, 'package.json'));
53
- const pkg = JSON.parse(pkgFile);
54
- return {
55
- name: pkg.name.replace(`${scope}/`, ''),
56
- dir: pkgDir,
57
- };
58
- }).concat({
59
- name: 'root',
60
- dir: __root,
61
- });
62
-
63
- const depsName = deps.map((dep) => dep.name);
64
-
65
- // Here we do a check to see if target env is described
66
- const allowedEnvs = env && env.envs && Array.isArray(env.envs) ? env.envs : [DEFAULT_ENV];
67
- const targetEnv = allowedEnvs.find((env) => env === target);
68
- if (!targetEnv) {
69
- console.error(`Target env "${target}" is not allowed.`);
70
- process.exit(1);
86
+ async function writeCommand() {
87
+ if (!secret) {
88
+ console.log('Secret not provided. Use --secret flag or WRENV_SECRET env variable.');
89
+ }
90
+
91
+ const target = targets[args.target || process.env.WRENV_TARGET || process.env.TARGET_ENV] || DEFAULT_ENV;
92
+
93
+ const envContent = fs.readFileSync(envFile, 'utf8');
94
+ const env = JSON.parse(envContent);
95
+
96
+ const rootPkgPath = path.resolve(__root, 'package.json');
97
+ const rootPkgContent = fs.readFileSync(rootPkgPath, 'utf8');
98
+ const rootPkg = JSON.parse(rootPkgContent);
99
+ const scope = rootPkg.name.split('/')[0];
100
+
101
+ const [_, ...foundPackages] = await findMonorepoPackages(__root, scope);
102
+ const deps = [
103
+ ...foundPackages.map((pkg) => ({
104
+ name: pkg.name.replace(`${scope}/`, ''),
105
+ dir: pkg.dir,
106
+ })),
107
+ {
108
+ name: 'root',
109
+ dir: __root,
110
+ },
111
+ ];
112
+ const depsName = deps.map((dep) => dep.name);
113
+
114
+ const allowedEnvs = env && env.envs && Array.isArray(env.envs) ? env.envs : [DEFAULT_ENV];
115
+ const targetEnv = allowedEnvs.find((env) => env === target);
116
+ if (!targetEnv) {
117
+ console.error(`Target env "${target}" is not allowed.`);
118
+ process.exit(1);
119
+ }
120
+
121
+ const allEnvs = buildEnv(secret, targetEnv, env, depsName);
122
+
123
+ for await (const dep of deps) {
124
+ const pkgEnv = Object.entries(allEnvs[dep.name] || {})
125
+ .map(([key, value]) => `${key}=${value}`)
126
+ .join('\n')
127
+ console.log(`Writing env file for ${dep.name} in ${dep.dir} with target ${targetEnv}`);
128
+ await fs.writeFile(path.join(dep.dir, '.env'), pkgEnv);
129
+ }
71
130
  }
72
131
 
73
- // Here we build the env object which will be used
74
- // and which contains all the variables for all packages
75
- const allEnvs = buildEnv(targetEnv, env, depsName);
76
-
77
- // Here we write the .env files for each package to disk
78
- // This should not influence the vcs state as those env files
79
- // should be ignored by it
80
- for await (const dep of deps) {
81
- const pkgEnv = Object.entries(allEnvs[dep.name] || {})
82
- .map(([key, value]) => `${key}=${value}`)
83
- .join('\n')
84
- console.log(`Writing env file for ${dep.name} in ${dep.dir} with target ${targetEnv}`);
85
- await fs.writeFile(path.join(dep.dir, '.env'), pkgEnv);
132
+ async function encryptCommand() {
133
+ const envContent = fs.readFileSync(envFile, 'utf8');
134
+ const env = JSON.parse(envContent);
135
+
136
+ if (!secret) {
137
+ console.error('Cannot encrypt env file: secret not provided. Doing nothing');
138
+ return;
139
+ }
140
+
141
+ if (env.variables) {
142
+ for (const [varName, varValue] of Object.entries(env.variables)) {
143
+ for (const [envKey, envVal] of Object.entries(varValue)) {
144
+ if (typeof envVal === 'string' && !isEncrypted(envVal)) {
145
+ env.variables[varName][envKey] = encrypt(envVal, secret);
146
+ }
147
+ }
148
+ }
149
+ }
150
+
151
+ if (env.overrides) {
152
+ for (const [pkgName, pkgOverrides] of Object.entries(env.overrides)) {
153
+ for (const [varName, varValue] of Object.entries(pkgOverrides)) {
154
+ for (const [envKey, envVal] of Object.entries(varValue)) {
155
+ if (typeof envVal === 'string' && !isEncrypted(envVal)) {
156
+ env.overrides[pkgName][varName][envKey] = encrypt(envVal, secret);
157
+ }
158
+ }
159
+ }
160
+ }
161
+ }
162
+
163
+ await fs.writeFile(envFile, JSON.stringify(env, null, 2));
86
164
  }
87
165
 
88
- // ========================================
89
- // Helpers for core logic
90
- // ========================================
91
-
92
- function buildEnv(target, env, names) {
93
- const variablesForAllTargets = env.variables;
94
- const overrides = env.overrides || {};
95
- const sections = env.sections || {};
96
-
97
- // For each variables, extract the value for the target env
98
- const variables = Object.entries(variablesForAllTargets).reduce((acc, entry) => {
99
- const [key, value] = entry;
100
- return {
101
- ...acc,
102
- [key]: value[target] || value['@@'] || ''
103
- };
104
- }, {});
105
-
106
- // For each package, get all variables
107
- // compute the overrides and merge them
108
- // allVars is an object with the package name as key
109
- // and the variables as value like so
110
- // => { [name]: { [variable]: value } }
111
- const allVars = names.reduce((acc, name) => {
112
- acc[name] = {
113
- ...variables,
114
- ...parseOverrides(target, overrides[name], variables)
115
- };
116
-
117
- return acc;
118
- }, {});
119
-
120
- // For each package, get all dependencies of variables
121
- // For instance and for clarity, backoffice can include all vars
122
- // from the api, and the api can include all vars from the core
123
- const allDeps = names.reduce((acc, name) => ({
124
- ...acc,
125
- [name]: parseSectionDeps(name, sections).reverse()
126
- }), {})
127
-
128
- // For each package, get all variable names from the dependencies
129
- // and from the package itself and merge them
130
- const allSections = Object.entries(allDeps).reduce((acc, entry) => {
131
- const [key, deps] = entry;
132
-
133
- // For all deps, get the variable names
134
- const depVars = deps.filter(Boolean).reduce((_acc, dep) => {
135
- const vars = parseSectionVars(dep, sections)
136
- return [..._acc, ...vars];
137
- }, []);
138
-
139
- // For the current section, get the variable names
140
- const nameVars = parseSectionVars(key, sections)
141
-
142
- // Merge all variables names, and for each variable name,
143
- // get the value from the allVars object
144
- return {
145
- ...acc,
146
- [key]: [...depVars, ...nameVars].reduce((_acc, variable) => {
147
- if (!allVars[key] || allVars[key][variable] === null) return _acc;
148
-
149
- return { ..._acc, [variable]: allVars[key][variable] };
150
- }, {})
151
- };
152
- }, {});
153
-
154
-
155
- return allSections;
156
- }
166
+ async function besafeCommand() {
167
+ const relativeEnvPath = path.relative(__root, envFile);
157
168
 
158
- function parseOverrides(target, overrides, vars) {
159
- if (!overrides) return {};
169
+ try {
170
+ const { stdout: stagedDiff } = await execa('git', ['diff', '--cached', '--name-only', '--', relativeEnvPath], { cwd: __root, reject: false });
171
+ const { stdout: unstagedDiff } = await execa('git', ['diff', '--name-only', '--', relativeEnvPath], { cwd: __root, reject: false });
172
+ const { stdout: untracked } = await execa('git', ['ls-files', '--others', '--exclude-standard', '--', relativeEnvPath], { cwd: __root, reject: false });
160
173
 
161
- return Object.entries(overrides).reduce((acc, entry) => {
162
- const [key, val] = entry;
163
- const value = val[target] || val['@@'] || '';
174
+ if (!stagedDiff?.trim() && !unstagedDiff?.trim() && !untracked?.trim()) {
175
+ return;
176
+ }
177
+ } catch (error) {
178
+ return;
179
+ }
164
180
 
165
- if (value === null) return acc;
181
+ await encrypt();
166
182
 
167
- acc[key] = Array.isArray(value)
168
- ? value.map(v => vars[v] || v).join('')
169
- : value
170
- return acc;
171
- }, {});
183
+ if (!secret) {
184
+ console.error('Warning: potential security risk: secret not provided. Variables WERE NOT be encrypted.');
185
+ }
186
+
187
+ await execa('git', ['add', relativeEnvPath], { cwd: __root });
172
188
  }
173
189
 
174
- function parseSectionDeps(name, sections, collected = []) {
175
- if (!sections || !sections[name]) return [];
176
-
177
- const collectedWithSelf = Array.from(new Set([...collected, name]));
178
- const deps = sections[name]
179
- .filter((key) => key.startsWith('@@'))
180
- .map((key) => key.split('@@')[1])
181
- .filter((key) => !collectedWithSelf.includes(key));
182
-
183
- if (!deps.length) return [];
184
-
185
- const uniquelyCollected = Array.from(new Set([...collected, ...deps]));
186
- return [
187
- ...deps,
188
- ...deps.map(
189
- (dep) => parseSectionDeps(
190
- dep,
191
- sections,
192
- uniquelyCollected
193
- )
194
- ).flat(),
195
- ];
190
+ async function addCommand() {
191
+ const varName = args._[1];
192
+ if (!varName) {
193
+ console.error('Variable name is required.');
194
+ process.exit(1);
195
+ }
196
+
197
+ if (!secret) {
198
+ console.error('Warning: potential security risk: secret not provided. Variable WILL NOT be encrypted.');
199
+ }
200
+
201
+ const envContent = fs.readFileSync(envFile, 'utf8');
202
+ const env = JSON.parse(envContent);
203
+
204
+ if (!env.variables) {
205
+ env.variables = {};
206
+ }
207
+
208
+ if (!env.variables[varName]) {
209
+ env.variables[varName] = {};
210
+ }
211
+
212
+ for (const arg of args._.slice(2)) {
213
+ if (!arg.startsWith('+')) continue;
214
+ const [key, ...valueParts] = arg.slice(1).split('=');
215
+ const value = valueParts.join('=');
216
+ const envKey = key === 'fallback' ? '@@' : key;
217
+ env.variables[varName][envKey] = secret ? encrypt(value, secret) : value;
218
+ }
219
+
220
+ await fs.writeFile(envFile, JSON.stringify(env, null, 2));
221
+ }
222
+
223
+ function helpCommand() {
224
+ console.log('Usage: wrenv [command] [options]\n');
225
+ console.log('Commands:');
226
+ console.log(' write Write .env files for all packages (default)');
227
+ console.log(' show Show environment variables without writing files');
228
+ console.log(' encrypt Encrypt all unencrypted variables in .env.json');
229
+ console.log(' besafe Encrypt and stage .env.json if it has changes');
230
+ console.log(' add <var> Add a new variable with environment-specific values');
231
+ console.log(' help Show this help message\n');
232
+ console.log('Options:');
233
+ console.log(' --secret <path|stdin> Path to secret file or "stdin" to read from stdin');
234
+ console.log(' --target <env> Target environment (dev, staging, prod, etc.)');
235
+ console.log(' --env <path> Path to env file (default: .env.json)\n');
236
+ console.log('Environment Variables:');
237
+ console.log(' WRENV_SECRET Secret for encryption/decryption');
238
+ console.log(' WRENV_TARGET Target environment');
239
+ console.log(' TARGET_SECRET Alias for WRENV_SECRET');
240
+ console.log(' TARGET_ENV Alias for WRENV_TARGET\n');
241
+ console.log('Examples:');
242
+ console.log(' wrenv --secret ~/.big-secret write --target dev');
243
+ console.log(' wrenv --secret stdin write < ~/.big-secret');
244
+ console.log(' wrenv show --target prod');
245
+ console.log(' wrenv add NEW_VAR +fallback=value +dev=dev_value +production=prod_value');
196
246
  }
197
247
 
198
- function parseSectionVars(name, sections) {
199
- if (!sections || !sections[name]) return [];
248
+ const commands = {
249
+ write: writeCommand,
250
+ show: showCommand,
251
+ besafe: besafeCommand,
252
+ encrypt: encryptCommand,
253
+ add: addCommand,
254
+ help: helpCommand,
255
+ };
256
+
257
+ const commandFunction = commands[command];
258
+
259
+ if (!commandFunction) {
260
+ console.error(`Unknown command: ${command}\n`);
261
+ helpCommand();
262
+ process.exit(1);
263
+ }
200
264
 
201
- return sections[name]
202
- .filter((key) => !key.startsWith('@@'))
265
+ try {
266
+ await commandFunction();
267
+ } catch (error) {
268
+ console.error(error.message);
269
+ process.exit(1);
203
270
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@saasak/tool-env",
3
3
  "license": "MIT",
4
- "version": "0.0.3",
4
+ "version": "1.0.0",
5
5
  "author": "dev@saasak.studio",
6
6
  "description": "A small util to manage environment variables for your monorepo",
7
7
  "keywords": [