diff --git a/README.md b/README.md index 30cdc2e..f8f2028 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,9 @@ 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 .end files +* supports reading and updating .env files +* can parse .env file into JSON format +* when parsing checks format validity and report error line number * 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 @@ -47,7 +49,7 @@ $ dotenv-tool -h ``` Usage: dotenv-tool [options] [command] [paramsToSet...] -Tool to read and update .env files +Tool to read, parse and update .env files Arguments: paramsToSet space separated list of additional envs to set, in format key=value (default: "") @@ -67,6 +69,7 @@ Options: Commands: get [options] Returns given variable from env file (if specified) + parse [options] Parses and returns env file as JSON ``` ### Read prop from file @@ -88,6 +91,27 @@ 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 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[]`: diff --git a/package.json b/package.json index 9c1ac46..f0125e3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "dotenv-tool", "version": "1.2.0", - "description": "Tool to read and update .env files", + "description": "Tool to read, parse and update .env files", "repository": { "type": "git", "url": "https://gitea.dzienia.pl/shared/dotenv-tool.git" diff --git a/src/bin_get.test.ts b/src/bin_get.test.ts new file mode 100644 index 0000000..2546264 --- /dev/null +++ b/src/bin_get.test.ts @@ -0,0 +1,121 @@ +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); + }); +}); diff --git a/src/bin_parse.test.ts b/src/bin_parse.test.ts new file mode 100644 index 0000000..55b79fa --- /dev/null +++ b/src/bin_parse.test.ts @@ -0,0 +1,121 @@ +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}"); + }); +}); diff --git a/src/bin.test.ts b/src/bin_update.test.ts similarity index 88% rename from src/bin.test.ts rename to src/bin_update.test.ts index d87e190..539f89e 100644 --- a/src/bin.test.ts +++ b/src/bin_update.test.ts @@ -46,64 +46,6 @@ 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 = ''; @@ -388,19 +330,6 @@ 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'; diff --git a/src/binlib.ts b/src/binlib.ts index cb8522e..e82668a 100644 --- a/src/binlib.ts +++ b/src/binlib.ts @@ -6,6 +6,14 @@ 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"']/)) { @@ -88,13 +96,71 @@ function makeReadCommand() { return getCmd; } +function makeParseCommand() { + const getCmd = new Command('parse'); + getCmd + .description('Parses and returns env file as JSON') + .option('-f, --file ', '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 and update .env files') + .description('Tool to read, parse 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 ', 'Input file(s)') .option('-o, --outputFile ', 'Output file') diff --git a/src/manipulation.test.ts b/src/manipulation.test.ts index 6d88bb9..9550da1 100644 --- a/src/manipulation.test.ts +++ b/src/manipulation.test.ts @@ -118,6 +118,17 @@ 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(