parent
0d89f36319
commit
20ebc2e23c
@ -1,3 +1,4 @@
|
||||
node_modules
|
||||
coverage
|
||||
dist
|
||||
dist
|
||||
.env
|
@ -0,0 +1,62 @@
|
||||
The command line tool to read, manipulate and update .env files.
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
npm install -g dotenv-tool --registry https://npm.dzienia.pl
|
||||
```
|
||||
|
||||
## Command Line
|
||||
|
||||
If input or output file(s) are not specified, `dotenv-tool` will use `stdin` or `stdout`
|
||||
|
||||
```bash
|
||||
$ echo -n "VAR1=value1\nVAR2=value2" | dotenv-tool read VAR1 > result.txt
|
||||
$ cat result.txt
|
||||
value1
|
||||
```
|
||||
|
||||
### Modify files
|
||||
|
||||
```bash
|
||||
$ dotenv-tool -h
|
||||
```
|
||||
|
||||
```
|
||||
Usage: dotenv-tool [options] [command] [paramsToSet]
|
||||
|
||||
Tool to read and update .env files
|
||||
|
||||
Arguments:
|
||||
paramsToSet space separated list of additional envs to set, in format key=value (default: "")
|
||||
|
||||
Options:
|
||||
-v, --version output the version number
|
||||
-i, --files <filePaths...> Input file(s)
|
||||
-o, --outputFile <filePath> Output file
|
||||
-m, --modify Modify first input file
|
||||
-s, --silent Mute all messages and errors
|
||||
-h, --help display help for command
|
||||
|
||||
Commands:
|
||||
get [options] <key> Returns given variable from env file (if specified)
|
||||
```
|
||||
|
||||
### Read prop from file
|
||||
|
||||
```bash
|
||||
$ dotenv-tool get -h
|
||||
```
|
||||
|
||||
```
|
||||
Usage: dotenv-tool get [options] <key>
|
||||
|
||||
Returns given variable from env file (if specified)
|
||||
|
||||
Arguments:
|
||||
key env variable name a.k.a. key
|
||||
|
||||
Options:
|
||||
-f, --file <filePath> Input file to parse (if not given, stdio is used)
|
||||
-h, --help display help for command
|
||||
```
|
@ -0,0 +1,497 @@
|
||||
import { mockProcessExit, mockProcessStderr, mockProcessStdout } from 'jest-mock-process';
|
||||
import { makeProgram } from './binlib';
|
||||
import { Command } from 'commander';
|
||||
import mock from 'mock-fs';
|
||||
import fs from 'fs';
|
||||
|
||||
let mockStdout: jest.SpyInstance;
|
||||
let mockStderr: jest.SpyInstance;
|
||||
let mockExit: jest.SpyInstance;
|
||||
let program: Command;
|
||||
let stdinContents = '';
|
||||
|
||||
function getContents() {
|
||||
return stdinContents;
|
||||
}
|
||||
|
||||
jest.mock('./binutils.ts', () => ({
|
||||
stdinToString: jest.fn(() => getContents()),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
mockStdout = mockProcessStdout();
|
||||
mockStderr = mockProcessStderr();
|
||||
program = makeProgram();
|
||||
program.exitOverride();
|
||||
mockExit = mockProcessExit();
|
||||
mock({
|
||||
'.env': 'ALA=makota',
|
||||
'ugly.env': '########\n\n SOME = value \n\n\n ANOTHER= "value with space"',
|
||||
'first.env': 'SERVER_PORT=80\nSERVER_HOST=localhost',
|
||||
'second.env': 'SERVER_PORT=8080\nSERVER_PASSWORD=secret\nCLIENT_HOST=192.168.4.42',
|
||||
'third.env': 'CLIENT_PORT=3000',
|
||||
'broken.env': 'CLI ENT_PORT=3000',
|
||||
'read-only.env': mock.file({
|
||||
content: 'READ=only',
|
||||
mode: 0o444,
|
||||
}),
|
||||
});
|
||||
stdinContents = '';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockStdout.mockRestore();
|
||||
mockStderr.mockRestore();
|
||||
mockExit.mockRestore();
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
describe('Standard utils', () => {
|
||||
it('outputs help', () => {
|
||||
expect(() => {
|
||||
program.parse(['node', 'test', '--help']);
|
||||
}).toThrow();
|
||||
expect(mockStdout).toHaveBeenCalledWith(expect.stringContaining('Usage: dotenv-tool [options] [command] [paramsToSet...]'));
|
||||
});
|
||||
|
||||
it('outputs version', () => {
|
||||
expect(() => {
|
||||
program.parse(['node', 'test', '-v']);
|
||||
}).toThrow(
|
||||
expect.objectContaining({
|
||||
message: expect.stringMatching(/[0-9]+\.[0-9]+\.[0-9]+/),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Read command', () => {
|
||||
it('reads from empty stdin', () => {
|
||||
stdinContents = '';
|
||||
program.parse(['node', 'test', 'get', 'TEST']);
|
||||
expect(mockExit).toHaveBeenCalledWith(1);
|
||||
expect(mockStderr).toBeCalledTimes(1);
|
||||
expect(mockStderr).toHaveBeenNthCalledWith(1, expect.stringMatching(/Variable TEST not found in stdin/));
|
||||
});
|
||||
|
||||
it('reads from invalid stdin', () => {
|
||||
stdinContents = 'junks';
|
||||
program.parse(['node', 'test', 'get', 'TEST']);
|
||||
expect(mockExit).toHaveBeenCalledWith(1);
|
||||
expect(mockStderr).toBeCalledTimes(2);
|
||||
expect(mockStderr).toHaveBeenNthCalledWith(1, expect.stringMatching(/Parsing stdin failed/));
|
||||
expect(mockStderr).toHaveBeenNthCalledWith(2, expect.stringMatching(/PARSING FAILED/));
|
||||
});
|
||||
|
||||
it('reads from correct stdin', () => {
|
||||
stdinContents = 'TEST=works';
|
||||
program.parse(['node', 'test', 'get', 'TEST']);
|
||||
expect(mockExit).toBeCalled();
|
||||
expect(mockStdout).toHaveBeenCalledWith('works');
|
||||
});
|
||||
|
||||
it('writes result to file', () => {
|
||||
stdinContents = 'TEST=works';
|
||||
program.parse(['node', 'test', 'get', '-o', 'test.txt', 'TEST']);
|
||||
expect(mockExit).toBeCalled();
|
||||
expect(fs.readFileSync('test.txt').toString()).toEqual('works');
|
||||
});
|
||||
|
||||
it('reads from correct file', () => {
|
||||
program.parse(['node', 'test', 'get', '-f', '.env', 'ALA']);
|
||||
expect(mockExit).toBeCalled();
|
||||
expect(mockStdout).toHaveBeenCalledWith('makota');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Update command', () => {
|
||||
it('creates empty output without params or stdin', () => {
|
||||
stdinContents = '';
|
||||
program.parse(['node', 'test']);
|
||||
expect(mockExit).toBeCalled();
|
||||
expect(mockStdout).toHaveBeenCalledWith('\n');
|
||||
});
|
||||
|
||||
it('uses stdin input', () => {
|
||||
stdinContents = 'VARIABLE=VALUE';
|
||||
program.parse(['node', 'test']);
|
||||
expect(mockExit).toBeCalled();
|
||||
expect(mockStdout).toHaveBeenCalledWith('VARIABLE=VALUE\n');
|
||||
});
|
||||
|
||||
it('appends empty stdin', () => {
|
||||
stdinContents = '';
|
||||
program.parse(['node', 'test', 'ANOTHER=ok']);
|
||||
expect(mockExit).toBeCalled();
|
||||
expect(mockStdout).toHaveBeenCalledWith('ANOTHER=ok\n');
|
||||
});
|
||||
|
||||
it('reads from invalid stdin', () => {
|
||||
stdinContents = 'junks';
|
||||
program.parse(['node', 'test']);
|
||||
expect(mockExit).toHaveBeenCalledWith(1);
|
||||
expect(mockStderr).toBeCalledTimes(2);
|
||||
expect(mockStderr).toHaveBeenNthCalledWith(1, expect.stringMatching(/Updating failed, cannot parse source stdin/));
|
||||
expect(mockStderr).toHaveBeenNthCalledWith(2, expect.stringMatching(/PARSING FAILED/));
|
||||
});
|
||||
|
||||
it('updates correct stdin', () => {
|
||||
stdinContents = 'TEST=works';
|
||||
program.parse(['node', 'test', 'TEST=new value']);
|
||||
expect(mockExit).toBeCalled();
|
||||
expect(mockStdout).toHaveBeenCalledWith('TEST="new value"\n');
|
||||
});
|
||||
|
||||
it('appends correct stdin', () => {
|
||||
stdinContents = 'TEST=works';
|
||||
program.parse(['node', 'test', 'ANOTHER=ok']);
|
||||
expect(mockExit).toBeCalled();
|
||||
expect(mockStdout).toHaveBeenCalledWith('TEST=works\nANOTHER=ok\n');
|
||||
});
|
||||
|
||||
it('writes result to file', () => {
|
||||
stdinContents = 'TEST=works';
|
||||
program.parse(['node', 'test', '-o', 'test.txt', 'TEST=new']);
|
||||
expect(mockExit).toBeCalled();
|
||||
expect(fs.readFileSync('test.txt').toString()).toEqual('TEST=new\n');
|
||||
});
|
||||
|
||||
it('writes result to read-only file', () => {
|
||||
stdinContents = 'TEST=works';
|
||||
program.parse(['node', 'test', '-o', 'read-only.env', 'TEST=new']);
|
||||
expect(mockExit).toHaveBeenCalledWith(1);
|
||||
expect(mockStderr).toBeCalledTimes(2);
|
||||
expect(mockStderr).toHaveBeenNthCalledWith(1, expect.stringMatching(/Updating failed - other error/));
|
||||
expect(mockStderr).toHaveBeenNthCalledWith(2, expect.stringMatching(/EACCES/));
|
||||
expect(mockStderr).toHaveBeenNthCalledWith(2, expect.stringMatching(/read-only.env/));
|
||||
});
|
||||
|
||||
it('reads from correct file', () => {
|
||||
program.parse(['node', 'test', '-i', '.env', '--', 'ALA=ma psa']);
|
||||
expect(mockExit).toBeCalled();
|
||||
expect(mockStdout).toHaveBeenCalledWith('ALA="ma psa"\n');
|
||||
});
|
||||
|
||||
it('beautify input file', () => {
|
||||
program.parse(['node', 'test', '-b', '-i', 'ugly.env']);
|
||||
expect(mockExit).toBeCalled();
|
||||
expect(mockStdout).toHaveBeenCalledWith('########\n\nSOME=value\n\nANOTHER="value with space"\n');
|
||||
});
|
||||
|
||||
it('reading non-existing file', () => {
|
||||
program.parse(['node', 'test', '-b', '-i', 'i-am-not-here.env']);
|
||||
expect(mockExit).toHaveBeenCalledWith(1);
|
||||
expect(mockStderr).toBeCalledTimes(2);
|
||||
expect(mockStderr).toHaveBeenNthCalledWith(1, expect.stringMatching(/Updating failed, cannot read file: i-am-not-here.env/));
|
||||
expect(mockStderr).toHaveBeenNthCalledWith(2, expect.stringMatching(/ENOENT/));
|
||||
expect(mockStderr).toHaveBeenNthCalledWith(2, expect.stringMatching(/i-am-not-here.env/));
|
||||
});
|
||||
|
||||
it('modify input file', () => {
|
||||
fs.writeFileSync('inplace.env', '########\n\n SOME = value \n\n\n ANOTHER= "value with space"'),
|
||||
program.parse(['node', 'test', '-b', '-m', '-i', 'inplace.env']);
|
||||
expect(mockExit).toBeCalled();
|
||||
expect(mockStdout).toHaveBeenCalledTimes(0);
|
||||
expect(fs.readFileSync('inplace.env').toString()).toEqual('########\n\nSOME=value\n\nANOTHER="value with space"\n');
|
||||
});
|
||||
|
||||
it('do not modify input file if invalid', () => {
|
||||
const data = '########\n\n IN VALID = value \n\n\n ANOTHER= "value with space"';
|
||||
fs.writeFileSync('invalid.env', data), program.parse(['node', 'test', '-b', '-m', '-i', 'invalid.env']);
|
||||
expect(mockExit).toHaveBeenCalledWith(1);
|
||||
expect(mockStdout).toHaveBeenCalledTimes(0);
|
||||
expect(fs.readFileSync('invalid.env').toString()).toEqual(data);
|
||||
expect(mockStderr).toBeCalledTimes(4);
|
||||
// normally, exit will finish
|
||||
expect(mockStderr).toHaveBeenNthCalledWith(1, expect.stringMatching(/Updating failed, cannot parse source file invalid.env/));
|
||||
expect(mockStderr).toHaveBeenNthCalledWith(2, expect.stringMatching(/PARSING FAILED/));
|
||||
expect(mockStderr).toHaveBeenNthCalledWith(3, expect.stringMatching(/Overwriting invalid source file not possible: invalid.env/));
|
||||
expect(mockStderr).toHaveBeenNthCalledWith(4, expect.stringMatching(/PARSING FAILED/));
|
||||
});
|
||||
|
||||
it('merge files', () => {
|
||||
program.parse(['node', 'test', '-i', 'first.env', 'second.env', 'third.env']);
|
||||
expect(mockExit).toBeCalled();
|
||||
expect(mockStdout).toHaveBeenCalledWith('SERVER_PORT=8080\nSERVER_HOST=localhost\nSERVER_PASSWORD=secret\nCLIENT_HOST=192.168.4.42\nCLIENT_PORT=3000\n');
|
||||
});
|
||||
|
||||
it('merge files with smart append', () => {
|
||||
program.parse(['node', 'test', '-p', '-i', 'first.env', 'second.env', 'third.env']);
|
||||
expect(mockExit).toBeCalled();
|
||||
expect(mockStdout).toHaveBeenCalledWith('CLIENT_HOST=192.168.4.42\nCLIENT_PORT=3000\nSERVER_PASSWORD=secret\nSERVER_PORT=8080\nSERVER_HOST=localhost\n');
|
||||
});
|
||||
|
||||
it('merge files and set from parameter', () => {
|
||||
program.parse(['node', 'test', '-i', 'first.env', 'second.env', 'third.env', '--', 'SERVER_PASSWORD=updated value']);
|
||||
expect(mockExit).toBeCalled();
|
||||
expect(mockStdout).toHaveBeenCalledWith(
|
||||
'SERVER_PORT=8080\nSERVER_HOST=localhost\nSERVER_PASSWORD="updated value"\nCLIENT_HOST=192.168.4.42\nCLIENT_PORT=3000\n',
|
||||
);
|
||||
});
|
||||
|
||||
it('merge files and set from parameter and invalid parameter', () => {
|
||||
program.parse(['node', 'test', '-i', 'first.env', 'second.env', 'third.env', '--', 'SERVER_PASSWORD=updated value', 'INVA LID', 'LAST_ARGUMENT=correct']);
|
||||
expect(mockExit).toHaveBeenCalledWith(1);
|
||||
expect(mockStderr).toBeCalledTimes(2);
|
||||
expect(mockStderr).toHaveBeenNthCalledWith(1, expect.stringMatching(/Updating failed, cannot parse params passed/));
|
||||
expect(mockStderr).toHaveBeenNthCalledWith(2, expect.stringMatching(/Invalid argument/));
|
||||
expect(mockStderr).toHaveBeenNthCalledWith(2, expect.stringMatching(/INVA LID/));
|
||||
});
|
||||
|
||||
it('merge files and set from parameter and invalid parameter', () => {
|
||||
program.parse([
|
||||
'node',
|
||||
'test',
|
||||
'-i',
|
||||
'first.env',
|
||||
'second.env',
|
||||
'third.env',
|
||||
'--',
|
||||
'SERVER_PASSWORD=updated value',
|
||||
'INVA-LID=wrong',
|
||||
'LAST_ARGUMENT=correct',
|
||||
]);
|
||||
expect(mockExit).toHaveBeenCalledWith(1);
|
||||
expect(mockStderr).toBeCalledTimes(2);
|
||||
expect(mockStderr).toHaveBeenNthCalledWith(1, expect.stringMatching(/Updating failed, invalid param/));
|
||||
expect(mockStderr).toHaveBeenNthCalledWith(2, expect.stringMatching(/PARSING FAILED/));
|
||||
expect(mockStderr).toHaveBeenNthCalledWith(2, expect.stringMatching(/INVA-LID/));
|
||||
});
|
||||
|
||||
it('merge files with invalid file name', () => {
|
||||
program.parse(['node', 'test', '-i', 'first.env', 'invalid.env', 'third.env']);
|
||||
expect(mockExit).toHaveBeenCalledWith(1);
|
||||
expect(mockStderr).toBeCalledTimes(2);
|
||||
expect(mockStderr).toHaveBeenNthCalledWith(1, expect.stringMatching(/Updating failed, cannot read file: invalid.env/));
|
||||
expect(mockStderr).toHaveBeenNthCalledWith(2, expect.stringMatching(/ENOENT/));
|
||||
expect(mockStderr).toHaveBeenNthCalledWith(2, expect.stringMatching(/invalid.env/));
|
||||
});
|
||||
|
||||
it('merge files with broken file contents', () => {
|
||||
program.parse(['node', 'test', '-i', 'first.env', 'second.env', 'broken.env']);
|
||||
expect(mockExit).toHaveBeenCalledWith(1);
|
||||
expect(mockStderr).toBeCalledTimes(2);
|
||||
expect(mockStderr).toHaveBeenNthCalledWith(1, expect.stringMatching(/Updating failed, cannot parse file: broken.env/));
|
||||
expect(mockStderr).toHaveBeenNthCalledWith(2, expect.stringMatching(/PARSING FAILED/));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Update command resiliency', () => {
|
||||
it('reads from invalid stdin', () => {
|
||||
stdinContents = 'junks';
|
||||
program.parse(['node', 'test', '-r']);
|
||||
expect(mockExit).toBeCalled();
|
||||
expect(mockStderr).toHaveBeenCalledTimes(0);
|
||||
expect(mockStdout).toHaveBeenCalledWith('\n');
|
||||
});
|
||||
|
||||
it('reading non-existing file', () => {
|
||||
program.parse(['node', 'test', '-r', '-b', '-i', 'i-am-not-here.env']);
|
||||
expect(mockExit).toBeCalled();
|
||||
expect(mockStderr).toHaveBeenCalledTimes(0);
|
||||
expect(mockStdout).toHaveBeenCalledWith('\n');
|
||||
});
|
||||
|
||||
it('writes result to read-only file', () => {
|
||||
stdinContents = 'TEST=works';
|
||||
program.parse(['node', 'test', '-r', '-o', 'read-only.env', 'TEST=new']);
|
||||
expect(mockExit).toBeCalled();
|
||||
expect(mockStderr).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('do not modify input file if invalid', () => {
|
||||
const data = '########\n\n IN VALID = value \n\n\n ANOTHER= "value with space"';
|
||||
fs.writeFileSync('invalid.env', data), program.parse(['node', 'test', '-r', '-b', '-m', '-i', 'invalid.env']);
|
||||
expect(mockExit).toHaveBeenCalledWith(1);
|
||||
expect(mockStdout).toHaveBeenCalledTimes(0);
|
||||
expect(fs.readFileSync('invalid.env').toString()).toEqual(data);
|
||||
expect(mockStderr).toBeCalledTimes(2);
|
||||
expect(mockStderr).toHaveBeenNthCalledWith(1, expect.stringMatching(/Overwriting invalid source file not possible: invalid.env/));
|
||||
expect(mockStderr).toHaveBeenNthCalledWith(2, expect.stringMatching(/PARSING FAILED/));
|
||||
});
|
||||
|
||||
it('merge files with invalid file name', () => {
|
||||
program.parse(['node', 'test', '-r', '-i', 'first.env', 'invalid.env', 'third.env']);
|
||||
expect(mockExit).toBeCalled();
|
||||
expect(mockStderr).toHaveBeenCalledTimes(0);
|
||||
expect(mockStdout).toHaveBeenCalledWith('SERVER_PORT=80\nSERVER_HOST=localhost\nCLIENT_PORT=3000\n');
|
||||
});
|
||||
|
||||
it('merge files with broken file contents', () => {
|
||||
program.parse(['node', 'test', '-r', '-i', 'first.env', 'second.env', 'broken.env']);
|
||||
expect(mockExit).toBeCalled();
|
||||
expect(mockStderr).toHaveBeenCalledTimes(0);
|
||||
expect(mockStdout).toHaveBeenCalledWith('SERVER_PORT=8080\nSERVER_HOST=localhost\nSERVER_PASSWORD=secret\nCLIENT_HOST=192.168.4.42\n');
|
||||
});
|
||||
|
||||
it('merge files and set from parameter and not parsable parameter', () => {
|
||||
program.parse([
|
||||
'node',
|
||||
'test',
|
||||
'-r',
|
||||
'-i',
|
||||
'first.env',
|
||||
'second.env',
|
||||
'third.env',
|
||||
'--',
|
||||
'SERVER_PASSWORD=updated value',
|
||||
'INVA-LID=par',
|
||||
'LAST_ARGUMENT=correct',
|
||||
]);
|
||||
expect(mockExit).toBeCalled();
|
||||
expect(mockStderr).toHaveBeenCalledTimes(0);
|
||||
expect(mockStdout).toHaveBeenCalledWith(
|
||||
'SERVER_PORT=8080\nSERVER_HOST=localhost\nSERVER_PASSWORD="updated value"\nCLIENT_HOST=192.168.4.42\nCLIENT_PORT=3000\nLAST_ARGUMENT=correct\n',
|
||||
);
|
||||
});
|
||||
|
||||
it('merge files and set from parameter and not parsable parameter', () => {
|
||||
program.parse([
|
||||
'node',
|
||||
'test',
|
||||
'-r',
|
||||
'-i',
|
||||
'first.env',
|
||||
'second.env',
|
||||
'third.env',
|
||||
'--',
|
||||
'SERVER_PASSWORD=updated value',
|
||||
'INVA LID',
|
||||
'LAST_ARGUMENT=correct',
|
||||
]);
|
||||
expect(mockExit).toBeCalled();
|
||||
expect(mockStderr).toHaveBeenCalledTimes(0);
|
||||
expect(mockStdout).toHaveBeenCalledWith(
|
||||
'SERVER_PORT=8080\nSERVER_HOST=localhost\nSERVER_PASSWORD="updated value"\nCLIENT_HOST=192.168.4.42\nCLIENT_PORT=3000\nLAST_ARGUMENT=correct\n',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Silent flag', () => {
|
||||
it('reads from empty stdin', () => {
|
||||
stdinContents = '';
|
||||
program.parse(['node', 'test', '-s', 'get', 'TEST']);
|
||||
expect(mockExit).toHaveBeenCalledWith(1);
|
||||
expect(mockStderr).toBeCalledTimes(0);
|
||||
});
|
||||
|
||||
it('reads from invalid stdin', () => {
|
||||
stdinContents = 'junks';
|
||||
program.parse(['node', 'test', '-s', 'get', 'TEST']);
|
||||
expect(mockExit).toHaveBeenCalledWith(1);
|
||||
expect(mockStderr).toBeCalledTimes(0);
|
||||
});
|
||||
|
||||
it('reads from invalid stdin', () => {
|
||||
stdinContents = 'junks';
|
||||
program.parse(['node', 'test', '-s']);
|
||||
expect(mockExit).toHaveBeenCalledWith(1);
|
||||
expect(mockStderr).toBeCalledTimes(0);
|
||||
});
|
||||
|
||||
it('writes result to read-only file', () => {
|
||||
stdinContents = 'TEST=works';
|
||||
program.parse(['node', 'test', '-s', '-o', 'read-only.env', 'TEST=new']);
|
||||
expect(mockExit).toHaveBeenCalledWith(1);
|
||||
expect(mockStderr).toBeCalledTimes(0);
|
||||
});
|
||||
|
||||
it('reading non-existing file', () => {
|
||||
program.parse(['node', 'test', '-s', '-b', '-i', 'i-am-not-here.env']);
|
||||
expect(mockExit).toHaveBeenCalledWith(1);
|
||||
expect(mockStderr).toBeCalledTimes(0);
|
||||
});
|
||||
|
||||
it('do not modify input file if invalid', () => {
|
||||
const data = '########\n\n IN VALID = value \n\n\n ANOTHER= "value with space"';
|
||||
fs.writeFileSync('invalid.env', data), program.parse(['node', 'test', '-s', '-b', '-m', '-i', 'invalid.env']);
|
||||
expect(mockExit).toHaveBeenCalledWith(1);
|
||||
expect(mockStderr).toBeCalledTimes(0);
|
||||
});
|
||||
|
||||
it('merge files and set from parameter and invalid parameter', () => {
|
||||
program.parse([
|
||||
'node',
|
||||
'test',
|
||||
'-s',
|
||||
'-i',
|
||||
'first.env',
|
||||
'second.env',
|
||||
'third.env',
|
||||
'--',
|
||||
'SERVER_PASSWORD=updated value',
|
||||
'INVA LID',
|
||||
'LAST_ARGUMENT=correct',
|
||||
]);
|
||||
expect(mockExit).toHaveBeenCalledWith(1);
|
||||
expect(mockExit).toHaveBeenCalledWith(1);
|
||||
expect(mockStderr).toBeCalledTimes(0);
|
||||
});
|
||||
|
||||
it('merge files and set from parameter and invalid parameter', () => {
|
||||
program.parse([
|
||||
'node',
|
||||
'test',
|
||||
'-s',
|
||||
'-i',
|
||||
'first.env',
|
||||
'second.env',
|
||||
'third.env',
|
||||
'--',
|
||||
'SERVER_PASSWORD=updated value',
|
||||
'INVA-LID=wrong',
|
||||
'LAST_ARGUMENT=correct',
|
||||
]);
|
||||
expect(mockExit).toHaveBeenCalledWith(1);
|
||||
expect(mockStderr).toBeCalledTimes(0);
|
||||
});
|
||||
|
||||
it('merge files with invalid file name', () => {
|
||||
program.parse(['node', 'test', '-s', '-i', 'first.env', 'invalid.env', 'third.env']);
|
||||
expect(mockExit).toHaveBeenCalledWith(1);
|
||||
expect(mockStderr).toBeCalledTimes(0);
|
||||
});
|
||||
|
||||
it('merge files with broken file contents', () => {
|
||||
program.parse(['node', 'test', '-s', '-i', 'first.env', 'second.env', 'broken.env']);
|
||||
expect(mockExit).toHaveBeenCalledWith(1);
|
||||
expect(mockStderr).toBeCalledTimes(0);
|
||||
});
|
||||
|
||||
it('do not modify input file if invalid', () => {
|
||||
const data = '########\n\n IN VALID = value \n\n\n ANOTHER= "value with space"';
|
||||
fs.writeFileSync('invalid.env', data), program.parse(['node', 'test', '-s', '-r', '-b', '-m', '-i', 'invalid.env']);
|
||||
expect(mockExit).toHaveBeenCalledWith(1);
|
||||
expect(mockStderr).toBeCalledTimes(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Update command without appending', () => {
|
||||
it('do not appends empty stdin', () => {
|
||||
stdinContents = '';
|
||||
program.parse(['node', 'test', '-u', 'ANOTHER=ok']);
|
||||
expect(mockExit).toBeCalled();
|
||||
expect(mockStdout).toHaveBeenCalledWith('\n');
|
||||
});
|
||||
|
||||
it('do not appends correct stdin', () => {
|
||||
stdinContents = 'TEST=works';
|
||||
program.parse(['node', 'test', '-u', 'ANOTHER=ok']);
|
||||
expect(mockExit).toBeCalled();
|
||||
expect(mockStdout).toHaveBeenCalledWith('TEST=works\n');
|
||||
});
|
||||
|
||||
it('merge files', () => {
|
||||
program.parse(['node', 'test', '-u', '-i', 'first.env', 'second.env', 'third.env']);
|
||||
expect(mockExit).toBeCalled();
|
||||
expect(mockStderr).toBeCalledTimes(0);
|
||||
expect(mockStdout).toHaveBeenCalledWith('SERVER_PORT=8080\nSERVER_HOST=localhost\n');
|
||||
});
|
||||
|
||||
it('merge files and set from parameter and not append new', () => {
|
||||
program.parse(['node', 'test', '-u', '-i', 'first.env', 'second.env', 'third.env', '--', 'SERVER_PORT=443', 'LAST_ARGUMENT=correct']);
|
||||
expect(mockExit).toBeCalled();
|
||||
expect(mockStderr).toBeCalledTimes(0);
|
||||
expect(mockStdout).toHaveBeenCalledWith('SERVER_PORT=443\nSERVER_HOST=localhost\n');
|
||||
});
|
||||
});
|
@ -0,0 +1,6 @@
|
||||
#! /usr/bin/env node
|
||||
|
||||
import { makeProgram } from "./binlib";
|
||||
|
||||
const program = makeProgram();
|
||||
program.parse(process.argv);
|
@ -0,0 +1,28 @@
|
||||
import { normalizeParams } from './binlib';
|
||||
|
||||
describe('Normalizing input variables', () => {
|
||||
it('is works on empty param list', () => {
|
||||
expect(normalizeParams([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles standard cases', () => {
|
||||
expect(normalizeParams(['aaa=bbb', 'ccc=ddd'])).toEqual(['aaa=bbb', 'ccc=ddd']);
|
||||
expect(normalizeParams(['aaa =bbb', 'ccc= ddd', 'eee = fff'])).toEqual(['aaa=bbb', 'ccc=" ddd"', 'eee=" fff"']);
|
||||
});
|
||||
|
||||
it('joins', () => {
|
||||
expect(normalizeParams(['aaa', '=', 'bbb', 'ccc', '=', 'ddd'])).toEqual(['aaa=bbb', 'ccc=ddd']);
|
||||
});
|
||||
|
||||
it('not alow empty props when escaped', () => {
|
||||
expect(() => normalizeParams(['aaa', '='])).toThrowError(/Invalid argument/);
|
||||
});
|
||||
|
||||
it('allow empty props when properly formatted', () => {
|
||||
expect(normalizeParams(['aaa='])).toEqual(['aaa=']);
|
||||
});
|
||||
|
||||
it('escapes strings with spaces or double quotes', () => {
|
||||
expect(normalizeParams(['aaa=ala ma kota', 'ccc="ddd'])).toEqual(['aaa=\"ala ma kota\"', 'ccc=\"\\\"ddd\"']);
|
||||
});
|
||||
});
|
@ -0,0 +1,214 @@
|
||||
import { Command } from 'commander';
|
||||
import fs from 'fs';
|
||||
import { version } from '../package.json';
|
||||
import { stdinToString } from './binutils';
|
||||
import { parseMultiLine, stringifyTokens } from './parser';
|
||||
import { Config, ModifyMode, TokenType, VariableToken } from './types';
|
||||
import { update } from './manipulation';
|
||||
|
||||
export function normalizeParamVar(name: string, value: string): string {
|
||||
let fixValue = value;
|
||||
if (value.match(/[\s"']/)) {
|
||||
fixValue = `"${value.replace(/\"/g, '\\"')}"`;
|
||||
}
|
||||
return `${name.trim()}=${fixValue}`;
|
||||
}
|
||||
|
||||
export function normalizeParams(rawParams: string[], resilient = false): string[] {
|
||||
const paramsFixed: string[] = [];
|
||||
let buffer: string[] = [];
|
||||
|
||||
const bufferFlush = () => {
|
||||
const joined = buffer.join('');
|
||||
const bPos = joined.indexOf('=');
|
||||
if (bPos > -1) {
|
||||
paramsFixed.push(normalizeParamVar(joined.substring(0, bPos), joined.substring(bPos + 1)));
|
||||
} else {
|
||||
if (!resilient) {
|
||||
throw new Error(`Invalid argument: ${joined}`);
|
||||
}
|
||||
}
|
||||
buffer = [];
|
||||
};
|
||||
|
||||
rawParams.forEach((ps: string, idx) => {
|
||||
const eqPos = ps.indexOf('=');
|
||||
if ((eqPos >= 0 && ps.length > 1) || idx == rawParams.length - 1) {
|
||||
if (eqPos == -1) {
|
||||
buffer.push(ps);
|
||||
}
|
||||
if (buffer.length > 0) {
|
||||
bufferFlush();
|
||||
}
|
||||
if (eqPos > -1) {
|
||||
paramsFixed.push(normalizeParamVar(ps.substring(0, eqPos), ps.substring(eqPos + 1)));
|
||||
}
|
||||
} else {
|
||||
buffer.push(ps);
|
||||
if (buffer.length >= 2 && buffer[buffer.length - 2] == '=') {
|
||||
bufferFlush();
|
||||
}
|
||||
}
|
||||
});
|
||||
return paramsFixed;
|
||||
}
|
||||
|
||||
function makeReadCommand() {
|
||||
const getCmd = new Command('get');
|
||||
getCmd
|
||||
.description('Returns given variable from env file (if specified)')
|
||||
.option('-f, --file <filePath>', 'Input file to parse (if not given, stdio is used)')
|
||||
.argument('<key>', 'env variable name a.k.a. key')
|
||||
.action((key, options, cmd) => {
|
||||
const globOpts = cmd.optsWithGlobals();
|
||||
const data = options.file ? fs.readFileSync(options.file).toString() : stdinToString();
|
||||
const sourceName = options.file ? `file ${options.file}` : `stdin`;
|
||||
try {
|
||||
const tokens = parseMultiLine(data);
|
||||
const found = tokens.filter((t) => t.token === TokenType.VARIABLE && (t as VariableToken).name == key);
|
||||
if (found.length > 0) {
|
||||
if (globOpts.outputFile) {
|
||||
fs.writeFileSync(globOpts.outputFile, found[0].value);
|
||||
} else {
|
||||
process.stdout.write(found[0].value);
|
||||
process.stdout.write('\n');
|
||||
}
|
||||
process.exit();
|
||||
} else {
|
||||
if (!globOpts.silent) process.stderr.write(`Variable ${key} not found in ${sourceName}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (!globOpts.silent) process.stderr.write(`Parsing ${sourceName} failed\n`);
|
||||
if (!globOpts.silent) process.stderr.write(e.toString() + '\n');
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
return getCmd;
|
||||
}
|
||||
|
||||
export function makeProgram() {
|
||||
const program = new Command();
|
||||
program
|
||||
.name('dotenv-tool')
|
||||
.description('Tool to read and update .env files')
|
||||
.version(version, '-v, --version')
|
||||
.addCommand(makeReadCommand())
|
||||
.argument('[paramsToSet...]', 'space separated list of additional envs to set, in format key=value', '')
|
||||
.option('-i, --files <filePaths...>', 'Input file(s)')
|
||||
.option('-o, --outputFile <filePath>', 'Output file')
|
||||
.option('-m, --modify', 'Modify first input file')
|
||||
.option('-b, --beautify', 'Beautifies resulting env file')
|
||||
.option('-u, --onlyUpdate', 'Only updates existing values, without appending new values')
|
||||
.option('-p, --smartAppend', 'Smart appends variables depending on their names')
|
||||
.option('-r, --resilient', 'Ignore files that cannot be read during update')
|
||||
.option('-s, --silent', 'Mute all messages and errors')
|
||||
.action((paramsToSet, options, cmd) => {
|
||||
try {
|
||||
const inputData: string[] = options.files
|
||||
? options.files.map((fname: string, index: number) => {
|
||||
try {
|
||||
return fs.readFileSync(fname).toString();
|
||||
} catch (e: any) {
|
||||
if (!options.resilient) {
|
||||
if (!options.silent) process.stderr.write(`Updating failed, cannot read file: ${fname}\n`);
|
||||
if (!options.silent) process.stderr.write(e.toString() + '\n');
|
||||
process.exit(1);
|
||||
} else {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
})
|
||||
: [stdinToString()];
|
||||
const sourceName = options.files ? `file ${options.files[0]}` : `stdin`;
|
||||
const config: Config = {};
|
||||
const remainingFiles = (options.files && options.files.length) > 1 ? options.files.slice(1) : [];
|
||||
let validSource = false;
|
||||
let sourceParsingError = '';
|
||||
|
||||
config.beautify = options.beautify || false;
|
||||
config.modifyMode = options.onlyUpdate ? ModifyMode.NO_APPEND : options.smartAppend ? ModifyMode.SMART_APPEND : ModifyMode.APPEND;
|
||||
|
||||
const baseData = inputData.shift() || '';
|
||||
|
||||
let tokens = [];
|
||||
try {
|
||||
tokens.push(...parseMultiLine(baseData));
|
||||
validSource = true;
|
||||
} catch (e: any) {
|
||||
sourceParsingError = e;
|
||||
if (!options.resilient) {
|
||||
if (!options.silent) process.stderr.write(`Updating failed, cannot parse source ${sourceName}\n`);
|
||||
if (!options.silent) process.stderr.write(e.toString() + '\n');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
let updates = [];
|
||||
|
||||
while (inputData.length > 0) {
|
||||
const parsedFile = remainingFiles.shift();
|
||||
try {
|
||||
const updateTokens = parseMultiLine(inputData.shift() || '');
|
||||
updates.push(...updateTokens.filter((t) => t.token === TokenType.VARIABLE).map((t) => t as VariableToken));
|
||||
} catch (e: any) {
|
||||
if (!options.resilient) {
|
||||
if (!options.silent) process.stderr.write(`Updating failed, cannot parse file: ${parsedFile}\n`);
|
||||
if (!options.silent) process.stderr.write(e.toString() + '\n');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (paramsToSet && paramsToSet.length > 0) {
|
||||
try {
|
||||
const paramsFixed = normalizeParams(paramsToSet, options.resilient);
|
||||
paramsFixed.forEach((param) => {
|
||||
try {
|
||||
const updateTokens = parseMultiLine(param);
|
||||
updates.push(...updateTokens.filter((t) => t.token === TokenType.VARIABLE).map((t) => t as VariableToken));
|
||||
} catch (e: any) {
|
||||
if (!options.resilient) {
|
||||
if (!options.silent) process.stderr.write(`Updating failed, invalid param\n`);
|
||||
if (!options.silent) process.stderr.write(e.toString() + '\n');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (e: any) {
|
||||
if (!options.resilient) {
|
||||
if (!options.silent) process.stderr.write(`Updating failed, cannot parse params passed\n`);
|
||||
if (!options.silent) process.stderr.write(e.toString() + '\n');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tokens = update(tokens, updates, config);
|
||||
const outputStr = stringifyTokens(tokens, config);
|
||||
|
||||
if (options.outputFile) {
|
||||
fs.writeFileSync(options.outputFile, outputStr);
|
||||
} else if (options.modify && options.files.length > 0) {
|
||||
if (validSource) {
|
||||
fs.writeFileSync(options.files[0], outputStr);
|
||||
} else {
|
||||
if (!options.silent) process.stderr.write(`Overwriting invalid source file not possible: ${options.files[0]}\n`);
|
||||
if (!options.silent) process.stderr.write(sourceParsingError.toString() + '\n');
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
process.stdout.write(outputStr);
|
||||
}
|
||||
process.exit();
|
||||
} catch (e: any) {
|
||||
if (!options.resilient) {
|
||||
if (!options.silent) process.stderr.write(`Updating failed - other error\n`);
|
||||
if (!options.silent) process.stderr.write(e.toString() + '\n');
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
return program;
|
||||
}
|
@ -0,0 +1,42 @@
|
||||
import fs from 'fs';
|
||||
const BUFSIZE = 256;
|
||||
const buf = Buffer.alloc(BUFSIZE);
|
||||
let bytesRead;
|
||||
let stdin = '';
|
||||
|
||||
export function stdinToString(): string {
|
||||
do {
|
||||
// Loop as long as stdin input is available.
|
||||
bytesRead = 0;
|
||||
try {
|
||||
bytesRead = fs.readSync(process.stdin.fd, buf, 0, BUFSIZE, null);
|
||||
} catch (e: any) {
|
||||
if (e.code === 'EAGAIN') {
|
||||
// 'resource temporarily unavailable'
|
||||
// Happens on OS X 10.8.3 (not Windows 7!), if there's no
|
||||
// stdin input - typically when invoking a script without any
|
||||
// input (for interactive stdin input).
|
||||
// If you were to just continue, you'd create a tight loop.
|
||||
// throw 'ERROR: interactive stdin input not supported.';
|
||||
break;
|
||||
} else if (e.code === 'EOF') {
|
||||
// Happens on Windows 7, but not OS X 10.8.3:
|
||||
// simply signals the end of *piped* stdin input.
|
||||
break;
|
||||
}
|
||||
throw e; // unexpected exception
|
||||
}
|
||||
if (bytesRead === 0) {
|
||||
// No more stdin input available.
|
||||
// OS X 10.8.3: regardless of input method, this is how the end
|
||||
// of input is signaled.
|
||||
// Windows 7: this is how the end of input is signaled for
|
||||
// *interactive* stdin input.
|
||||
break;
|
||||
}
|
||||
// Process the chunk read.
|
||||
stdin += buf.toString(undefined, 0, bytesRead);
|
||||
} while (bytesRead > 0);
|
||||
|
||||
return stdin;
|
||||
}
|
@ -1,37 +1,3 @@
|
||||
#! /usr/bin/env node
|
||||
import commander, { program } from 'commander';
|
||||
import { getVersionSync } from '@bconnorwhite/module';
|
||||
|
||||
function makeReadCommand() {
|
||||
const getCmd = new commander.Command('get');
|
||||
getCmd
|
||||
.description('Returns given variable from env file (if specified)')
|
||||
.option('-f, --file <filePath>', 'Input file to parse (if not given, stdio is used)')
|
||||
.argument('<key>', 'env variable name a.k.a. key')
|
||||
.action((key) => {
|
||||
const options = program.opts();
|
||||
console.log(options);
|
||||
console.log('heat jug', key);
|
||||
});
|
||||
|
||||
return getCmd;
|
||||
}
|
||||
|
||||
program
|
||||
.name('dotenv-tool')
|
||||
.description('Tool to read and update .env files')
|
||||
.version(getVersionSync(__dirname) || '1.0.0', '-v, --version')
|
||||
.argument('[paramsToSet]', 'space separated list of additional envs to set, in format key=value', '')
|
||||
.option('-f, --files <filePaths...>', 'Input file(s)')
|
||||
.option('-o, --outputFile <filePath>', 'Output file')
|
||||
.option('-m, --modify', 'Modify first input file')
|
||||
.action((paramsToSet) => {
|
||||
console.log('got', paramsToSet);
|
||||
const options = program.opts();
|
||||
console.log(options);
|
||||
})
|
||||
.addCommand(makeReadCommand())
|
||||
|
||||
.parse(process.argv);
|
||||
|
||||
|
||||
export * from './types'
|
||||
export * from './parser'
|
||||
export { beautify, update } from './manipulation'
|
@ -0,0 +1,18 @@
|
||||
{
|
||||
"include": ["./src/*.ts", "./src/tests/utils/**.d.ts"],
|
||||
"compilerOptions": {
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"inlineSources": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"isolatedModules": true,
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"target": "es6",
|
||||
"module": "commonjs"
|
||||
}
|
||||
}
|
@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig-base.json",
|
||||
"exclude": ["src/bin.ts"],
|
||||
"compilerOptions": {
|
||||
"module": "esnext",
|
||||
"outDir": "dist/mjs"
|
||||
}
|
||||
}
|
@ -1,13 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig-base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"strict": true,
|
||||
"target": "es6",
|
||||
"module": "commonjs",
|
||||
"sourceMap": true,
|
||||
"esModuleInterop": true,
|
||||
"moduleResolution": "node"
|
||||
},
|
||||
"include": ["./src/*.ts", "./src/tests/utils/**.d.ts"]
|
||||
"outDir": "dist/cjs",
|
||||
"moduleResolution": "Node"
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,15 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const version = require("./package.json").version
|
||||
|
||||
fs.writeFileSync(`${__dirname}${path.sep}dist${path.sep}cjs${path.sep}package.json`, JSON.stringify({
|
||||
version: version,
|
||||
type: 'commonjs'
|
||||
}, null, ' '));
|
||||
|
||||
fs.writeFileSync(`${__dirname}${path.sep}dist${path.sep}mjs${path.sep}package.json`, JSON.stringify({
|
||||
version: version,
|
||||
type: 'module'
|
||||
}, null, ' '));
|
||||
|
||||
fs.chmodSync(`${__dirname}${path.sep}dist${path.sep}cjs${path.sep}src${path.sep}bin.js`, 0o755)
|
Loading…
Reference in New Issue