Skip to content

Commit 559620a

Browse files
committed
feat: extension
1 parent 064c417 commit 559620a

File tree

8 files changed

+384
-0
lines changed

8 files changed

+384
-0
lines changed

app-config-exec/.eslintrc.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
module.exports = require('@lcdev/eslint-config/cwd')(__dirname);

app-config-exec/README.md

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
## App Config Exec Plugin
2+
3+
Run arbitrary programs or shell commands to generate config.
4+
5+
```yaml
6+
$exec: echo Hello World!
7+
```
8+
9+
## Usage
10+
11+
#### Basic:
12+
13+
```yaml
14+
$exec: echo Hello World!
15+
```
16+
17+
#### Auto-parse output as YAML/TOML/JSON/JSON5:
18+
19+
```yaml
20+
$exec:
21+
command: curl https://my-api.example.com
22+
parseOutput: true
23+
```
24+
25+
#### Build a custom script to generate config:
26+
27+
```yaml
28+
$exec: node ./my-custom-config-generator.js
29+
```
30+
31+
#### Retrieve arbitrary information:
32+
33+
_Node v8 version:_
34+
35+
```yaml
36+
$exec: node -p -e "process.versions.v8"
37+
```
38+
39+
_System's architecture:_
40+
41+
```yaml
42+
$exec: uname -p
43+
```
44+
45+
#### Retrieve list of AWS S3 buckets via `aws` & `jq` CLI:
46+
47+
```yaml
48+
$exec:
49+
command: aws s3api list-buckets --output json | jq -r '.Buckets'
50+
parseOutput: true
51+
```
52+
53+
_Note: When possible, we encourage you to build a dedicated extension to better support features you are looking for (and help out the community). eg. `$aws` directive instead of the above._
54+
55+
## Installing
56+
57+
Install and use:
58+
59+
```sh
60+
yarn add @app-config/exec
61+
```
62+
63+
In `.app-config.meta.yml` file:
64+
65+
```yaml
66+
parsingExtensions:
67+
- '@app-config/exec'
68+
```
69+
70+
## Options
71+
72+
The following options can be passed to each `$exec` directive:
73+
74+
```yaml
75+
$exec:
76+
command: echo Hello World!
77+
trimWhitespace: true
78+
parseOutput: false
79+
failOnStderr: false
80+
```
81+
82+
#### `command: string`:
83+
84+
The command to run. Should be a single `string` containing both command and arguments. The command's `stdout` will be used as the resulting value. Runs in a shell, `/bin/sh` on Unix and `process.env.ComSpec` on Windows.
85+
86+
#### `trimWhitespace: boolean`:
87+
88+
If `true`, automatically trim whitespace from the start and end of the command's output. Useful to remove the trailing newline produced by most commands. If `false`, output will be left in its raw form. Default `true`.
89+
90+
#### `parseOutput: boolean`:
91+
92+
If `true`, automatically guess output content type (YAML, TOML, JSON, or JSON5) and parse. Throws if parse fails. If `false`, output will be read as a `string`. Default `false`.
93+
94+
#### `failOnStderr: boolean`:
95+
96+
If `true`, fail if any output is found in command's `stderr`. Default `false`.

app-config-exec/package.json

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
{
2+
"name": "@app-config/exec",
3+
"description": "Generate config by running arbitrary programs",
4+
"version": "2.0.3",
5+
"license": "MPL-2.0",
6+
"author": {
7+
"name": "Launchcode",
8+
"email": "[email protected]",
9+
"url": "https://lc.dev"
10+
},
11+
"repository": {
12+
"type": "git",
13+
"url": "https://github.com/launchcodedev/app-config.git"
14+
},
15+
"main": "dist/index.js",
16+
"module": "dist/es/index.js",
17+
"types": "dist/index.d.ts",
18+
"files": [
19+
"/dist",
20+
"!*.tsbuildinfo",
21+
"!*.test.*"
22+
],
23+
"scripts": {
24+
"build": "tsc -b",
25+
"build:es": "tsc -b tsconfig.es.json",
26+
"clean": "rm -rf dist *.tsbuildinfo",
27+
"lint": "eslint src",
28+
"fix": "eslint --fix src",
29+
"test": "jest",
30+
"prepublishOnly": "yarn clean && yarn build && yarn build:es"
31+
},
32+
"dependencies": {},
33+
"peerDependencies": {
34+
"@lcdev/app-config": "2"
35+
},
36+
"devDependencies": {
37+
"@lcdev/app-config": "2"
38+
},
39+
"prettier": "@lcdev/prettier",
40+
"jest": {
41+
"preset": "@lcdev/jest"
42+
}
43+
}

app-config-exec/src/index.test.ts

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { defaultEnvExtensions, defaultExtensions, loadUnvalidatedConfig } from '@lcdev/app-config';
2+
import execParsingExtension from '.';
3+
4+
const defaultOptions = {
5+
environmentExtensions: defaultEnvExtensions().concat(execParsingExtension()),
6+
parsingExtensions: defaultExtensions().concat(execParsingExtension()),
7+
};
8+
9+
describe('execParsingExtension', () => {
10+
it('reads from command as root level string', async () => {
11+
process.env.APP_CONFIG = JSON.stringify({
12+
$exec: 'printf test123',
13+
});
14+
15+
const { fullConfig } = await loadUnvalidatedConfig(defaultOptions);
16+
17+
expect(fullConfig).toEqual('test123');
18+
});
19+
20+
it('reads from command within nested object options', async () => {
21+
process.env.APP_CONFIG = JSON.stringify({
22+
$exec: { command: 'printf test123' },
23+
});
24+
25+
const { fullConfig } = await loadUnvalidatedConfig(defaultOptions);
26+
27+
expect(fullConfig).toEqual('test123');
28+
});
29+
30+
it('reads JSON as string by default', async () => {
31+
process.env.APP_CONFIG = JSON.stringify({
32+
$exec: { command: `printf '{"test": true}'` },
33+
});
34+
35+
const { fullConfig } = await loadUnvalidatedConfig(defaultOptions);
36+
37+
expect(fullConfig).toBe('{"test": true}');
38+
});
39+
40+
it('parses JSON if parseOutput true', async () => {
41+
process.env.APP_CONFIG = JSON.stringify({
42+
$exec: { command: `printf '{"test": true}'`, parseOutput: true },
43+
});
44+
45+
const { fullConfig } = await loadUnvalidatedConfig(defaultOptions);
46+
47+
expect(fullConfig).toMatchObject({ test: true });
48+
});
49+
50+
it('trims whitespace by default', async () => {
51+
process.env.APP_CONFIG = JSON.stringify({
52+
$exec: { command: `printf ' test123\n'` },
53+
});
54+
55+
const { fullConfig } = await loadUnvalidatedConfig(defaultOptions);
56+
57+
expect(fullConfig).toBe('test123');
58+
});
59+
60+
it('reads raw output if trimWhitespace false', async () => {
61+
process.env.APP_CONFIG = JSON.stringify({
62+
$exec: { command: `printf ' test123\n'`, trimWhitespace: false },
63+
});
64+
65+
const { fullConfig } = await loadUnvalidatedConfig(defaultOptions);
66+
67+
expect(fullConfig).toBe(' test123\n');
68+
});
69+
70+
it('does not fail on stderr by default', async () => {
71+
process.env.APP_CONFIG = JSON.stringify({
72+
// eslint-disable-next-line no-useless-escape, prettier/prettier
73+
$exec: {
74+
command: `node -e 'process.stdout.write("stdout"); process.stderr.write("stderr");'`,
75+
},
76+
});
77+
78+
const { fullConfig } = await loadUnvalidatedConfig(defaultOptions);
79+
80+
expect(fullConfig).toEqual('stdout');
81+
});
82+
83+
it('fails on stderr when failOnStderr true', async () => {
84+
process.env.APP_CONFIG = JSON.stringify({
85+
$exec: {
86+
command: `node -e 'process.stdout.write("stdout"); process.stderr.write("stderr");'`,
87+
failOnStderr: true,
88+
},
89+
});
90+
91+
const action = async () => {
92+
await loadUnvalidatedConfig(defaultOptions);
93+
};
94+
95+
await expect(action()).rejects.toThrow();
96+
});
97+
98+
it('fails if options is not a string or object', async () => {
99+
process.env.APP_CONFIG = JSON.stringify({
100+
$exec: 12345,
101+
});
102+
103+
const action = async () => {
104+
await loadUnvalidatedConfig(defaultOptions);
105+
};
106+
107+
await expect(action()).rejects.toThrow();
108+
});
109+
110+
it('invalid command fails', async () => {
111+
process.env.APP_CONFIG = JSON.stringify({
112+
$exec: { command: 'non-existing-command' },
113+
});
114+
115+
const action = async () => {
116+
await loadUnvalidatedConfig(defaultOptions);
117+
};
118+
119+
await expect(action()).rejects.toThrow();
120+
});
121+
});

app-config-exec/src/index.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { Json, parseRawString, ParsingExtension } from '@lcdev/app-config';
2+
import { guessFileType } from '@lcdev/app-config/dist/config-source';
3+
import { exec } from 'child_process';
4+
import { promisify } from 'util';
5+
6+
const execAsync = promisify(exec);
7+
8+
export interface Options {
9+
command: string;
10+
failOnStderr: boolean;
11+
parseOutput: boolean;
12+
trimWhitespace: boolean;
13+
}
14+
15+
class ExecError extends Error {
16+
name = 'ExecError';
17+
}
18+
19+
function parseOptions(parsed: Json): Options {
20+
if (typeof parsed === 'string') {
21+
return { command: parsed, failOnStderr: false, parseOutput: false, trimWhitespace: true };
22+
}
23+
24+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
25+
const { command, failOnStderr = false, parseOutput = false, trimWhitespace = true } = parsed;
26+
27+
if (typeof command !== 'string') {
28+
throw new ExecError('$exec requires a "command" option');
29+
}
30+
31+
if (typeof failOnStderr !== 'boolean') {
32+
throw new ExecError('$exec "failOnStderr" option must be a boolean');
33+
}
34+
35+
if (typeof parseOutput !== 'boolean') {
36+
throw new ExecError('$exec "parseString" option must be a boolean');
37+
}
38+
39+
if (typeof trimWhitespace !== 'boolean') {
40+
throw new ExecError('$exec "trimWhitespace" option must be a boolean');
41+
}
42+
43+
return { command, failOnStderr, parseOutput, trimWhitespace };
44+
}
45+
46+
throw new ExecError('$exec must be a string, or object with options');
47+
}
48+
49+
function execParsingExtension(): ParsingExtension {
50+
return (value, [_, objectKey]) => {
51+
if (objectKey !== '$exec') {
52+
return false;
53+
}
54+
55+
return async (parse) => {
56+
const parsed = await parse(value).then((v) => v.toJSON());
57+
58+
const { command, failOnStderr, parseOutput, trimWhitespace } = parseOptions(parsed);
59+
60+
try {
61+
const { stdout, stderr } = await execAsync(command);
62+
63+
if (failOnStderr && stderr) {
64+
throw new ExecError(`$exec command "${command}" produced stderr: ${stderr}`);
65+
}
66+
67+
let result: Json = stdout;
68+
69+
if (trimWhitespace) {
70+
result = stdout.trim();
71+
}
72+
73+
if (parseOutput) {
74+
const fileType = await guessFileType(stdout);
75+
result = await parseRawString(stdout, fileType);
76+
}
77+
78+
return parse(result, { shouldFlatten: true });
79+
} catch (err: unknown) {
80+
if (!isError(err)) {
81+
throw err;
82+
}
83+
84+
if (err instanceof ExecError) {
85+
throw err;
86+
}
87+
88+
throw new ExecError(`$exec command "${command}" failed with error:\n${err.message}`);
89+
}
90+
};
91+
};
92+
}
93+
94+
/**
95+
* Check if a value is an Error.
96+
*
97+
* This was created because `value instanceof Error` was returning false
98+
* for normal-looking errors coming from `child_process` `exec()` command
99+
*/
100+
function isError(value: unknown): value is Error {
101+
return typeof value === 'object' && (value as Error).message !== undefined;
102+
}
103+
104+
export default execParsingExtension;

app-config-exec/tsconfig.es.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"extends": "./tsconfig.json",
3+
"compilerOptions": {
4+
"target": "es2020",
5+
"module": "es2020",
6+
"outDir": "./dist/es"
7+
}
8+
}

app-config-exec/tsconfig.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"extends": "@lcdev/tsconfig",
3+
"compilerOptions": {
4+
"rootDir": "./src",
5+
"outDir": "./dist",
6+
"lib": ["dom"]
7+
},
8+
"include": ["src/**/*"],
9+
"exclude": ["node_modules"]
10+
}

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"app-config-inject",
1111
"app-config-react-native-transformer",
1212
"app-config-vault",
13+
"app-config-exec",
1314
"examples/*",
1415
"tests/parsing-extensions/*",
1516
"tests/webpack-projects/*",

0 commit comments

Comments
 (0)