Compare commits

..

No commits in common. 'master' and 'v1.2.0' have entirely different histories.

@ -3,9 +3,7 @@ The command line tool and library to read, manipulate and update .env files.
## Features
* by default, preserves white spaces, line breaks and comments
* supports reading and updating .env files
* can parse .env file into JSON format
* when parsing checks format validity and report error line number
* supports reading and updating .end files
* can read and write to stdin/stdout to be used in shell as a part of commands pipeline
* support multiple input files for updates as well as list of arguments at end of command
* supports single and double quoted values
@ -49,7 +47,7 @@ $ dotenv-tool -h
```
Usage: dotenv-tool [options] [command] [paramsToSet...]
Tool to read, parse and update .env files
Tool to read and update .env files
Arguments:
paramsToSet space separated list of additional envs to set, in format key=value (default: "")
@ -69,7 +67,6 @@ Options:
Commands:
get [options] <key> Returns given variable from env file (if specified)
parse [options] Parses and returns env file as JSON
```
### Read prop from file
@ -91,27 +88,6 @@ Options:
-h, --help display help for command
```
Also accept `-o` flag.
### Parse .env file/input as JSON
```bash
$ dotenv-tool parse -h
```
```
Usage: dotenv-tool parse [options]
Parses and returns env file as JSON
Options:
-f, --file <filePath> Input file to parse (if not given, stdio is used)
-n, --normalize Normalize keys in JSON (lowercase them)
-h, --help display help for command
```
Parse also accepts `-b` and `-o` flags.
## API
Internally, library parses into and manipulates on array of tokens `Token[]`:

4
package-lock.json generated

@ -1,12 +1,12 @@
{
"name": "dotenv-tool",
"version": "1.3.0",
"version": "1.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dotenv-tool",
"version": "1.3.0",
"version": "1.2.0",
"license": "MIT",
"dependencies": {
"commander": "^11.0.0",

@ -1,7 +1,7 @@
{
"name": "dotenv-tool",
"version": "1.3.0",
"description": "Tool to read, parse and update .env files",
"version": "1.2.0",
"description": "Tool to read and update .env files",
"repository": {
"type": "git",
"url": "https://gitea.dzienia.pl/shared/dotenv-tool.git"

@ -46,6 +46,64 @@ afterEach(() => {
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 = '';
@ -330,6 +388,19 @@ describe('Update command resiliency', () => {
});
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';

@ -1,121 +0,0 @@
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('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);
});
});

@ -1,121 +0,0 @@
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('Parse command', () => {
it('reads from empty stdin', () => {
stdinContents = '';
program.parse(['node', 'test', 'parse']);
expect(mockStdout).toBeCalledTimes(2);
expect(mockStdout).toHaveBeenNthCalledWith(1, "{\"status\":\"ok\",\"values\":{}}");
});
it('reads from invalid stdin', () => {
stdinContents = 'junks';
program.parse(['node', 'test', 'parse']);
expect(mockExit).toHaveBeenCalledWith(1);
expect(mockStdout).toBeCalledTimes(2);
expect(mockStdout).toHaveBeenNthCalledWith(1, expect.stringMatching(/\"status\":\"parse_error\"/));
expect(mockStdout).toHaveBeenNthCalledWith(1, expect.stringMatching(/\"errorLine\":1/));
});
it('reads from correct stdin', () => {
stdinContents = 'TEST=works';
program.parse(['node', 'test', 'parse', 'TEST']);
expect(mockExit).toBeCalled();
expect(mockStdout).toHaveBeenCalledWith("{\"status\":\"ok\",\"values\":{\"TEST\":\"works\"}}");
});
it('writes result to file', () => {
stdinContents = 'TEST=works';
program.parse(['node', 'test', 'parse', '-o', 'test.txt']);
expect(mockExit).toBeCalled();
expect(fs.readFileSync('test.txt').toString()).toEqual("{\"status\":\"ok\",\"values\":{\"TEST\":\"works\"}}");
});
it('writes error to file', () => {
stdinContents = 'junks';
program.parse(['node', 'test', 'parse', '-o', 'test.txt']);
expect(mockExit).toBeCalled();
const fileCnt = fs.readFileSync('test.txt').toString();
expect(fileCnt).toMatch(/\"status\":\"parse_error\"/);
expect(fileCnt).toMatch(/\"errorLine\":1/);
});
it('writes error to file beautified', () => {
stdinContents = 'junks';
program.parse(['node', 'test', 'parse', '-b', '-o', 'test.txt']);
expect(mockExit).toBeCalled();
const fileCnt = fs.readFileSync('test.txt').toString();
expect(fileCnt).toMatch(/\"status\": \"parse_error\"/);
expect(fileCnt).toMatch(/\"errorLine\": 1/);
});
it('reads from correct file', () => {
program.parse(['node', 'test', 'parse', '-f', '.env']);
expect(mockExit).toBeCalled();
expect(mockStdout).toHaveBeenCalledWith("{\"status\":\"ok\",\"values\":{\"ALA\":\"makota\"}}");
});
it('parses file', () => {
program.parse(['node', 'test', 'parse', '-f', 'second.env']);
expect(mockExit).toBeCalled();
expect(mockStdout).toHaveBeenCalledWith("{\"status\":\"ok\",\"values\":{\"SERVER_PORT\":\"8080\",\"SERVER_PASSWORD\":\"secret\",\"CLIENT_HOST\":\"192.168.4.42\"}}");
});
it('normalize json keys', () => {
program.parse(['node', 'test', 'parse', '-n', '-f', 'second.env']);
expect(mockExit).toBeCalled();
expect(mockStdout).toHaveBeenCalledWith("{\"status\":\"ok\",\"values\":{\"server_port\":\"8080\",\"server_password\":\"secret\",\"client_host\":\"192.168.4.42\"}}");
});
it('beautify keys', () => {
program.parse(['node', 'test', 'parse', '-b', '-n', '-f', '.env']);
expect(mockExit).toBeCalled();
expect(mockStdout).toHaveBeenCalledWith("{\n \"status\": \"ok\",\n \"values\": {\n \"ala\": \"makota\"\n }\n}");
});
});

@ -6,14 +6,6 @@ import { parseMultiLine, stringifyTokens } from './parser';
import { Config, ModifyMode, TokenType, VariableToken } from './types';
import { update } from './manipulation';
type StringMapObj = { [key: string]: any };
enum ParseStatus {
OK = 'ok',
PARSE_ERROR = 'parse_error',
ERROR = 'error',
}
type ParseResult = { status: ParseStatus; values?: StringMapObj; error?: any; errorLine?: Number };
export function normalizeParamVar(name: string, value: string): string {
let fixValue = value;
if (value.match(/[\s"']/)) {
@ -96,71 +88,13 @@ function makeReadCommand() {
return getCmd;
}
function makeParseCommand() {
const getCmd = new Command('parse');
getCmd
.description('Parses and returns env file as JSON')
.option('-f, --file <filePath>', 'Input file to parse (if not given, stdio is used)')
.option('-n, --normalize', 'Normalize keys in JSON (lowercase them)')
.action((options, cmd) => {
const globOpts = cmd.optsWithGlobals();
const data = options.file ? fs.readFileSync(options.file).toString() : stdinToString();
const sourceName = options.file ? `file ${options.file}` : `stdin`;
let result: ParseResult = { status: ParseStatus.OK };
try {
const tokens = parseMultiLine(data);
let values: StringMapObj = {};
tokens
.filter((t) => t.token === TokenType.VARIABLE)
.forEach((vt) => {
let tokenName = (vt as VariableToken).name;
const tokenValue = (vt as VariableToken).value;
if (options.normalize) {
tokenName = tokenName.toLowerCase();
}
values[tokenName] = tokenValue;
});
result.values = values;
const outputStr = JSON.stringify(result, null, globOpts.beautify ? ' ' : '');
if (globOpts.outputFile) {
fs.writeFileSync(globOpts.outputFile, outputStr);
} else {
process.stdout.write(outputStr);
process.stdout.write('\n');
}
process.exit();
} catch (e: any) {
result.status = ParseStatus.ERROR;
result.error = e;
if (e.type == 'ParsimmonError') {
result.status = ParseStatus.PARSE_ERROR;
result.errorLine = e.result.index.line;
}
const outputStr = JSON.stringify(result, null, globOpts.beautify ? ' ' : '');
if (globOpts.outputFile) {
fs.writeFileSync(globOpts.outputFile, outputStr);
} else {
process.stdout.write(outputStr);
process.stdout.write('\n');
}
process.exit(1);
}
});
return getCmd;
}
export function makeProgram() {
const program = new Command();
program
.name('dotenv-tool')
.description('Tool to read, parse and update .env files')
.description('Tool to read and update .env files')
.version(version, '-v, --version')
.addCommand(makeReadCommand())
.addCommand(makeParseCommand())
.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')

@ -118,17 +118,6 @@ describe('Updating tokens', () => {
),
),
).toEqual('ALA="ma kota"\n');
expect(
stringifyTokens(
update(
[vtoken('ALA', 'ma kota')],
[
['ALA', 'ma psa'],
['ALA', 'ma jednak kota'],
],
),
),
).toEqual('ALA="ma jednak kota"\n');
expect(
stringifyTokens(

Loading…
Cancel
Save