Skip to content

Commit 53071d0

Browse files
authored
Merge pull request #95 from launchcodedev/feat/exec-extension
$exec extension
2 parents 1f67a75 + c43f354 commit 53071d0

File tree

11 files changed

+402
-0
lines changed

11 files changed

+402
-0
lines changed

.github/workflows/main.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ jobs:
3535
./app-config-cypress/coverage/coverage-final.json,
3636
./app-config-default-extensions/coverage/coverage-final.json,
3737
./app-config-encryption/coverage/coverage-final.json,
38+
./app-config-exec/coverage/coverage-final.json,
3839
./app-config-extension-utils/coverage/coverage-final.json,
3940
./app-config-extensions/coverage/coverage-final.json,
4041
./app-config-generate/coverage/coverage-final.json,

.github/workflows/publishing.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@ jobs:
4646
token: ${{ secrets.NPM_TOKEN }}
4747
access: public
4848
package: ./app-config-encryption/package.json
49+
- uses: JS-DevTools/npm-publish@v1
50+
with:
51+
token: ${{ secrets.NPM_TOKEN }}
52+
access: public
53+
package: ./app-config-exec/package.json
4954
- uses: JS-DevTools/npm-publish@v1
5055
with:
5156
token: ${{ secrets.NPM_TOKEN }}

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: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
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+
"@app-config/core": "^2.1.8",
34+
"@app-config/extension-utils": "^2.1.8",
35+
"@app-config/utils": "^2.1.8"
36+
},
37+
"devDependencies": {
38+
"@app-config/main": "^2.1.8"
39+
},
40+
"prettier": "@lcdev/prettier",
41+
"jest": {
42+
"preset": "@lcdev/jest",
43+
"setupFilesAfterEnv": [
44+
"<rootDir>/test-setup.js"
45+
]
46+
}
47+
}

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

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { defaultEnvExtensions, defaultExtensions, loadUnvalidatedConfig } from '@app-config/main';
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: 'echo 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: 'echo 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: `echo '{"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: `echo '{"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: `echo ' 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: `echo ' test123'`, 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+
$exec: {
73+
command: `node -e 'process.stdout.write("stdout"); process.stderr.write("stderr");'`,
74+
},
75+
});
76+
77+
const { fullConfig } = await loadUnvalidatedConfig(defaultOptions);
78+
79+
expect(fullConfig).toEqual('stdout');
80+
});
81+
82+
it('fails on stderr when failOnStderr true', async () => {
83+
process.env.APP_CONFIG = JSON.stringify({
84+
$exec: {
85+
command: `node -e 'process.stdout.write("stdout"); process.stderr.write("stderr");'`,
86+
failOnStderr: true,
87+
},
88+
});
89+
90+
const action = async () => {
91+
await loadUnvalidatedConfig(defaultOptions);
92+
};
93+
94+
await expect(action()).rejects.toThrow();
95+
});
96+
97+
it('fails if options is not a string or object', async () => {
98+
process.env.APP_CONFIG = JSON.stringify({
99+
$exec: 12345,
100+
});
101+
102+
const action = async () => {
103+
await loadUnvalidatedConfig(defaultOptions);
104+
};
105+
106+
await expect(action()).rejects.toThrow();
107+
});
108+
109+
it('fails if options dont include command', async () => {
110+
process.env.APP_CONFIG = JSON.stringify({
111+
$exec: {},
112+
});
113+
114+
await expect(loadUnvalidatedConfig(defaultOptions)).rejects.toThrow();
115+
});
116+
117+
it('invalid command fails', async () => {
118+
process.env.APP_CONFIG = JSON.stringify({
119+
$exec: { command: 'non-existing-command' },
120+
});
121+
122+
const action = async () => {
123+
await loadUnvalidatedConfig(defaultOptions);
124+
};
125+
126+
await expect(action()).rejects.toThrow();
127+
});
128+
});

app-config-exec/src/index.ts

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

app-config-exec/test-setup.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
const originalEnvironment = { ...process.env };
2+
3+
afterEach(() => {
4+
process.env = { ...originalEnvironment };
5+
});

0 commit comments

Comments
 (0)