parse-hcl 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -0
- package/dist/cli.js +110 -14
- package/dist/types/blocks.d.ts +10 -0
- package/dist/utils/outputMetadata.d.ts +20 -0
- package/dist/utils/outputMetadata.js +78 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -49,6 +49,12 @@ pnpm add parse-hcl
|
|
|
49
49
|
|
|
50
50
|
The `parse-hcl` CLI provides instant Terraform configuration analysis from your terminal.
|
|
51
51
|
|
|
52
|
+
### Command Synopsis
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
parse-hcl --file <path> | --dir <path> [--format json|yaml] [--graph] [--no-prune]
|
|
56
|
+
```
|
|
57
|
+
|
|
52
58
|
### Basic Commands
|
|
53
59
|
|
|
54
60
|
```bash
|
|
@@ -75,6 +81,9 @@ parse-hcl --file plan.json
|
|
|
75
81
|
|
|
76
82
|
# Keep empty arrays/objects in output
|
|
77
83
|
parse-hcl --file main.tf --no-prune
|
|
84
|
+
|
|
85
|
+
# Save to custom path and also print
|
|
86
|
+
parse-hcl --file main.tf --out ./out/result.json --stdout
|
|
78
87
|
```
|
|
79
88
|
|
|
80
89
|
### Options Reference
|
|
@@ -86,6 +95,26 @@ parse-hcl --file main.tf --no-prune
|
|
|
86
95
|
| `--format <type>` | Output format: `json` or `yaml` | `json` |
|
|
87
96
|
| `--graph` | Include dependency graph (nodes, edges, references) | `false` |
|
|
88
97
|
| `--no-prune` | Keep empty arrays and objects in output | `false` |
|
|
98
|
+
| `--out <path>` | Save output to file (or directory for combined output) | `./parse-hcl-output*.{json,yaml}` |
|
|
99
|
+
| `--out-dir <dir>` | Save per-file results under this directory (directory mode) | `./parse-hcl-output/files` |
|
|
100
|
+
| `--split` / `--no-split` | Enable/disable per-file saving in directory mode | `true` |
|
|
101
|
+
| `--stdout` / `--no-stdout` | Also print to stdout (default off) | `false` |
|
|
102
|
+
|
|
103
|
+
### Behavior and Defaults
|
|
104
|
+
|
|
105
|
+
- Pass either `--file` or `--dir`; if both are present, `--file` is used. Missing inputs print usage to stderr and exit with code `1`.
|
|
106
|
+
- **Default output is files, stdout off.**
|
|
107
|
+
- Single file: writes `./parse-hcl-output.{json|yaml}`.
|
|
108
|
+
- Directory: writes combined `./parse-hcl-output.combined.{json|yaml}` and per-file under `./parse-hcl-output/files/<relative-path>.{json|yaml}`.
|
|
109
|
+
- Add `--stdout` to also print.
|
|
110
|
+
- `--out` overrides the combined/single output path. If it points to a directory, the tool writes `output.{json|yaml}` (single file) or `combined.{json|yaml}` (directory). If no extension is given, one is added based on `--format`.
|
|
111
|
+
- `--out-dir` sets the root for per-file outputs (directory mode). If omitted but `--out` is provided, per-file results go under `per-file/` next to the `--out` target. Disable per-file writes with `--no-split`.
|
|
112
|
+
- `--file` auto-detects artifacts: paths containing `tfvars` use the tfvars parser, `.tfstate` uses the state parser, and `plan.json` uses the plan parser. Other files are treated as Terraform configs. The `--graph` flag only applies to Terraform configs; artifact parsers ignore it and emit the raw parse.
|
|
113
|
+
- `--dir` walks recursively, parsing only `.tf` and `.tf.json` files while skipping `.terraform`, `.git`, and `node_modules`. Default output contains `combined` (aggregated document) and `files` (per-file results). With `--graph`, the dependency graph is built from the aggregated document.
|
|
114
|
+
- When split outputs are enabled, each `files` entry includes `relative_path`, `output_path`, and `output_dir` (all relative). Module blocks include `source_raw` (as written) and, when local, `source_output_dir`, pointing to the per-file output directory for that module.
|
|
115
|
+
- Warnings and usage go to stderr. The CLI exits non-zero on invalid arguments or parsing failures.
|
|
116
|
+
- `--format` applies to every output shape; `--no-prune` keeps empty arrays/objects that are removed by default for compactness.
|
|
117
|
+
- Run without a global install via `npx parse-hcl ...` or `yarn dlx parse-hcl ...`.
|
|
89
118
|
|
|
90
119
|
### Output Formats
|
|
91
120
|
|
|
@@ -154,6 +183,13 @@ variable:
|
|
|
154
183
|
value: us-east-1
|
|
155
184
|
```
|
|
156
185
|
|
|
186
|
+
**Default saved files (no flags):**
|
|
187
|
+
```bash
|
|
188
|
+
$ ls parse-hcl-output*
|
|
189
|
+
parse-hcl-output.combined.json
|
|
190
|
+
parse-hcl-output/files/main.tf.json
|
|
191
|
+
```
|
|
192
|
+
|
|
157
193
|
**Graph Output:**
|
|
158
194
|
```bash
|
|
159
195
|
$ parse-hcl --file main.tf --graph --format json
|
package/dist/cli.js
CHANGED
|
@@ -4,12 +4,17 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
4
4
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
5
5
|
};
|
|
6
6
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
const fs_1 = __importDefault(require("fs"));
|
|
7
8
|
const path_1 = __importDefault(require("path"));
|
|
8
9
|
const terraformParser_1 = require("./services/terraformParser");
|
|
9
10
|
const artifactParsers_1 = require("./services/artifactParsers");
|
|
10
11
|
const serializer_1 = require("./utils/serialization/serializer");
|
|
12
|
+
const outputMetadata_1 = require("./utils/outputMetadata");
|
|
13
|
+
const DEFAULT_SINGLE_BASENAME = 'parse-hcl-output';
|
|
14
|
+
const DEFAULT_COMBINED_BASENAME = 'parse-hcl-output.combined';
|
|
15
|
+
const DEFAULT_PER_FILE_DIR = 'parse-hcl-output/files';
|
|
11
16
|
function parseArgs(argv) {
|
|
12
|
-
const opts = { format: 'json', graph: false, prune: true };
|
|
17
|
+
const opts = { format: 'json', graph: false, prune: true, split: true, stdout: false };
|
|
13
18
|
for (let i = 0; i < argv.length; i += 1) {
|
|
14
19
|
const arg = argv[i];
|
|
15
20
|
if (arg === '--file' && argv[i + 1]) {
|
|
@@ -30,6 +35,24 @@ function parseArgs(argv) {
|
|
|
30
35
|
else if (arg === '--no-prune') {
|
|
31
36
|
opts.prune = false;
|
|
32
37
|
}
|
|
38
|
+
else if (arg === '--out' && argv[i + 1]) {
|
|
39
|
+
opts.out = argv[++i];
|
|
40
|
+
}
|
|
41
|
+
else if (arg === '--out-dir' && argv[i + 1]) {
|
|
42
|
+
opts.outDir = argv[++i];
|
|
43
|
+
}
|
|
44
|
+
else if (arg === '--split') {
|
|
45
|
+
opts.split = true;
|
|
46
|
+
}
|
|
47
|
+
else if (arg === '--no-split') {
|
|
48
|
+
opts.split = false;
|
|
49
|
+
}
|
|
50
|
+
else if (arg === '--stdout') {
|
|
51
|
+
opts.stdout = true;
|
|
52
|
+
}
|
|
53
|
+
else if (arg === '--no-stdout') {
|
|
54
|
+
opts.stdout = false;
|
|
55
|
+
}
|
|
33
56
|
}
|
|
34
57
|
return opts;
|
|
35
58
|
}
|
|
@@ -37,51 +60,124 @@ function main() {
|
|
|
37
60
|
const opts = parseArgs(process.argv.slice(2));
|
|
38
61
|
const parser = new terraformParser_1.TerraformParser();
|
|
39
62
|
if (!opts.file && !opts.dir) {
|
|
40
|
-
console.error('Usage: parse-hcl --file <path> | --dir <path> [--format json|yaml] [--graph] [--no-prune]');
|
|
63
|
+
console.error('Usage: parse-hcl --file <path> | --dir <path> [--format json|yaml] [--graph] [--no-prune] [--out <path>] [--out-dir <dir>] [--stdout]');
|
|
41
64
|
process.exit(1);
|
|
42
65
|
}
|
|
43
66
|
if (opts.file) {
|
|
44
67
|
const filePath = path_1.default.resolve(opts.file);
|
|
45
68
|
const ext = path_1.default.extname(filePath);
|
|
46
69
|
if (ext.includes('tfvars')) {
|
|
47
|
-
|
|
70
|
+
const data = tfvarsParse(filePath);
|
|
71
|
+
emitSingle(filePath, data, opts);
|
|
48
72
|
return;
|
|
49
73
|
}
|
|
50
74
|
if (ext === '.tfstate') {
|
|
51
|
-
|
|
75
|
+
const data = new artifactParsers_1.TfStateParser().parseFile(filePath);
|
|
76
|
+
emitSingle(filePath, data, opts);
|
|
52
77
|
return;
|
|
53
78
|
}
|
|
54
79
|
if (ext === '.json' && filePath.endsWith('plan.json')) {
|
|
55
|
-
|
|
80
|
+
const data = new artifactParsers_1.TfPlanParser().parseFile(filePath);
|
|
81
|
+
emitSingle(filePath, data, opts);
|
|
56
82
|
return;
|
|
57
83
|
}
|
|
58
84
|
const doc = parser.parseFile(filePath);
|
|
59
|
-
|
|
85
|
+
emitSingle(filePath, doc, opts);
|
|
60
86
|
return;
|
|
61
87
|
}
|
|
62
88
|
if (opts.dir) {
|
|
63
89
|
const dirPath = path_1.default.resolve(opts.dir);
|
|
64
90
|
const result = parser.parseDirectory(dirPath);
|
|
65
|
-
const
|
|
66
|
-
|
|
91
|
+
const combinedDoc = result.combined ?? parser.combine(result.files.map((f) => f.document));
|
|
92
|
+
emitDirectory(dirPath, result.files, combinedDoc, opts);
|
|
67
93
|
}
|
|
68
94
|
}
|
|
69
95
|
function tfvarsParse(filePath) {
|
|
70
96
|
return new artifactParsers_1.TfVarsParser().parseFile(filePath);
|
|
71
97
|
}
|
|
72
|
-
function
|
|
98
|
+
function emitSingle(filePath, data, opts) {
|
|
99
|
+
const rendered = render(data, opts);
|
|
100
|
+
const ext = getExt(opts.format);
|
|
101
|
+
const defaultName = `${DEFAULT_SINGLE_BASENAME}${ext}`;
|
|
102
|
+
const targetPath = resolveOutPath(opts.out, defaultName, opts.format);
|
|
103
|
+
writeFile(targetPath, rendered);
|
|
104
|
+
if (opts.stdout) {
|
|
105
|
+
console.info(rendered);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
function emitDirectory(dirPath, files, combinedDoc, opts) {
|
|
109
|
+
const ext = getExt(opts.format);
|
|
110
|
+
const perFileBase = opts.split ? resolvePerFileBase(opts) : undefined;
|
|
111
|
+
(0, outputMetadata_1.annotateOutputMetadata)({
|
|
112
|
+
dirPath,
|
|
113
|
+
files,
|
|
114
|
+
perFileBase,
|
|
115
|
+
ext,
|
|
116
|
+
cwd: process.cwd()
|
|
117
|
+
});
|
|
118
|
+
const combinedData = opts.graph ? combinedDoc : { combined: combinedDoc, files: opts.split ? files : [] };
|
|
119
|
+
const combinedRendered = render(combinedData, opts);
|
|
120
|
+
const combinedDefaultName = `${DEFAULT_COMBINED_BASENAME}${ext}`;
|
|
121
|
+
const combinedTarget = resolveOutPath(opts.out, combinedDefaultName, opts.format, true);
|
|
122
|
+
writeFile(combinedTarget, combinedRendered);
|
|
123
|
+
if (opts.split && perFileBase) {
|
|
124
|
+
files.forEach((file) => {
|
|
125
|
+
const relPath = file.relative_path ?? path_1.default.relative(dirPath, path_1.default.resolve(file.path));
|
|
126
|
+
const perFileTarget = path_1.default.join(perFileBase, `${relPath}${ext}`);
|
|
127
|
+
const rendered = render(file.document, opts);
|
|
128
|
+
writeFile(perFileTarget, rendered);
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
if (opts.stdout) {
|
|
132
|
+
console.info(combinedRendered);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
function render(data, opts) {
|
|
73
136
|
if (opts.graph && !isTerraformDoc(data)) {
|
|
74
137
|
console.warn('Graph export requested but input is not a Terraform document; emitting raw output.');
|
|
75
138
|
}
|
|
76
139
|
if (opts.format === 'yaml') {
|
|
77
|
-
|
|
78
|
-
return;
|
|
140
|
+
return (0, serializer_1.toYamlDocument)(data, { pruneEmpty: opts.prune });
|
|
79
141
|
}
|
|
80
142
|
if (opts.graph && isTerraformDoc(data)) {
|
|
81
|
-
|
|
82
|
-
|
|
143
|
+
return (0, serializer_1.toJsonExport)(data, { pruneEmpty: opts.prune });
|
|
144
|
+
}
|
|
145
|
+
return (0, serializer_1.toJson)(data, { pruneEmpty: opts.prune });
|
|
146
|
+
}
|
|
147
|
+
function resolveOutPath(out, defaultName, format, isDirMode = false) {
|
|
148
|
+
const resolvedDefault = path_1.default.resolve(defaultName);
|
|
149
|
+
if (!out) {
|
|
150
|
+
return resolvedDefault;
|
|
83
151
|
}
|
|
84
|
-
|
|
152
|
+
const resolvedOut = path_1.default.resolve(out);
|
|
153
|
+
if (fs_1.default.existsSync(resolvedOut) && fs_1.default.statSync(resolvedOut).isDirectory()) {
|
|
154
|
+
const name = isDirMode ? `combined${getExt(format)}` : `output${getExt(format)}`;
|
|
155
|
+
return path_1.default.join(resolvedOut, name);
|
|
156
|
+
}
|
|
157
|
+
if (!path_1.default.extname(resolvedOut)) {
|
|
158
|
+
return `${resolvedOut}${getExt(format)}`;
|
|
159
|
+
}
|
|
160
|
+
return resolvedOut;
|
|
161
|
+
}
|
|
162
|
+
function resolvePerFileBase(opts) {
|
|
163
|
+
if (opts.outDir) {
|
|
164
|
+
return path_1.default.resolve(opts.outDir);
|
|
165
|
+
}
|
|
166
|
+
if (opts.out) {
|
|
167
|
+
const resolvedOut = path_1.default.resolve(opts.out);
|
|
168
|
+
if (fs_1.default.existsSync(resolvedOut) && fs_1.default.statSync(resolvedOut).isDirectory()) {
|
|
169
|
+
return path_1.default.join(resolvedOut, 'per-file');
|
|
170
|
+
}
|
|
171
|
+
return path_1.default.join(path_1.default.dirname(resolvedOut), 'per-file');
|
|
172
|
+
}
|
|
173
|
+
return path_1.default.resolve(DEFAULT_PER_FILE_DIR);
|
|
174
|
+
}
|
|
175
|
+
function writeFile(targetPath, contents) {
|
|
176
|
+
fs_1.default.mkdirSync(path_1.default.dirname(targetPath), { recursive: true });
|
|
177
|
+
fs_1.default.writeFileSync(targetPath, contents, 'utf-8');
|
|
178
|
+
}
|
|
179
|
+
function getExt(format) {
|
|
180
|
+
return format === 'yaml' ? '.yaml' : '.json';
|
|
85
181
|
}
|
|
86
182
|
function isTerraformDoc(data) {
|
|
87
183
|
return Boolean(data && typeof data === 'object' && 'resource' in data);
|
package/dist/types/blocks.d.ts
CHANGED
|
@@ -196,6 +196,10 @@ export interface ModuleBlock {
|
|
|
196
196
|
name: string;
|
|
197
197
|
/** All properties defined in the block (including source, version, etc.) */
|
|
198
198
|
properties: Record<string, Value>;
|
|
199
|
+
/** Raw source string as written in HCL (useful when a resolved output dir is added) */
|
|
200
|
+
source_raw?: string;
|
|
201
|
+
/** Relative path to the per-file parse output for the referenced source directory (when available) */
|
|
202
|
+
source_output_dir?: string;
|
|
199
203
|
/** The original raw text */
|
|
200
204
|
raw: string;
|
|
201
205
|
/** Source file path */
|
|
@@ -396,6 +400,12 @@ export declare function createEmptyDocument(): TerraformDocument;
|
|
|
396
400
|
export interface FileParseResult {
|
|
397
401
|
/** The file path */
|
|
398
402
|
path: string;
|
|
403
|
+
/** Path relative to the parsed directory root */
|
|
404
|
+
relative_path?: string;
|
|
405
|
+
/** Relative path (from cwd) to the per-file parse output */
|
|
406
|
+
output_path?: string;
|
|
407
|
+
/** Relative directory containing the per-file parse output */
|
|
408
|
+
output_dir?: string;
|
|
399
409
|
/** The parsed document */
|
|
400
410
|
document: TerraformDocument;
|
|
401
411
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { FileParseResult } from '../types/blocks';
|
|
2
|
+
type FileWithMetadata = FileParseResult & {
|
|
3
|
+
relative_path?: string;
|
|
4
|
+
output_path?: string;
|
|
5
|
+
output_dir?: string;
|
|
6
|
+
};
|
|
7
|
+
interface MetadataOptions {
|
|
8
|
+
dirPath: string;
|
|
9
|
+
files: FileWithMetadata[];
|
|
10
|
+
perFileBase?: string;
|
|
11
|
+
ext: '.json' | '.yaml';
|
|
12
|
+
cwd?: string;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Attaches relative path metadata to per-file results and module blocks.
|
|
16
|
+
* - Adds relative paths for files and their output targets (when split outputs are enabled).
|
|
17
|
+
* - Adds `source_output_dir` to module blocks that point to local directories within the parsed tree.
|
|
18
|
+
*/
|
|
19
|
+
export declare function annotateOutputMetadata(options: MetadataOptions): void;
|
|
20
|
+
export {};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.annotateOutputMetadata = annotateOutputMetadata;
|
|
7
|
+
const fs_1 = __importDefault(require("fs"));
|
|
8
|
+
const path_1 = __importDefault(require("path"));
|
|
9
|
+
const valueHelpers_1 = require("./common/valueHelpers");
|
|
10
|
+
/**
|
|
11
|
+
* Attaches relative path metadata to per-file results and module blocks.
|
|
12
|
+
* - Adds relative paths for files and their output targets (when split outputs are enabled).
|
|
13
|
+
* - Adds `source_output_dir` to module blocks that point to local directories within the parsed tree.
|
|
14
|
+
*/
|
|
15
|
+
function annotateOutputMetadata(options) {
|
|
16
|
+
const root = path_1.default.resolve(options.dirPath);
|
|
17
|
+
const cwd = options.cwd ? path_1.default.resolve(options.cwd) : process.cwd();
|
|
18
|
+
const perFileBase = options.perFileBase ? path_1.default.resolve(options.perFileBase) : undefined;
|
|
19
|
+
for (const file of options.files) {
|
|
20
|
+
const absPath = path_1.default.resolve(file.path);
|
|
21
|
+
const relPath = path_1.default.relative(root, absPath);
|
|
22
|
+
file.relative_path = relPath;
|
|
23
|
+
if (perFileBase) {
|
|
24
|
+
const perFileTarget = path_1.default.join(perFileBase, `${relPath}${options.ext}`);
|
|
25
|
+
const outputPath = normalizeRelative(cwd, perFileTarget);
|
|
26
|
+
file.output_path = outputPath;
|
|
27
|
+
file.output_dir = path_1.default.dirname(outputPath);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
for (const file of options.files) {
|
|
31
|
+
const fileDir = path_1.default.dirname(path_1.default.resolve(file.path));
|
|
32
|
+
for (const mod of file.document.module) {
|
|
33
|
+
const sourceRaw = getRawSource(mod.properties.source);
|
|
34
|
+
if (sourceRaw) {
|
|
35
|
+
mod.source_raw = sourceRaw;
|
|
36
|
+
}
|
|
37
|
+
if (!perFileBase) {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
const sourceLiteral = (0, valueHelpers_1.literalString)(mod.properties.source);
|
|
41
|
+
if (!sourceLiteral || !isLocalPath(sourceLiteral)) {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
const resolvedSource = path_1.default.resolve(fileDir, sourceLiteral);
|
|
45
|
+
if (!fs_1.default.existsSync(resolvedSource) || !fs_1.default.statSync(resolvedSource).isDirectory()) {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const relToRoot = path_1.default.relative(root, resolvedSource);
|
|
49
|
+
if (relToRoot.startsWith('..') || path_1.default.isAbsolute(relToRoot)) {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
const outputDir = path_1.default.join(perFileBase, relToRoot);
|
|
53
|
+
mod.source_output_dir = normalizeRelative(cwd, outputDir);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function isLocalPath(source) {
|
|
58
|
+
if (source.includes('://') || source.includes('::')) {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
return source.startsWith('.') || path_1.default.isAbsolute(source);
|
|
62
|
+
}
|
|
63
|
+
function normalizeRelative(from, to) {
|
|
64
|
+
const rel = path_1.default.relative(from, to);
|
|
65
|
+
return rel || '.';
|
|
66
|
+
}
|
|
67
|
+
function getRawSource(value) {
|
|
68
|
+
if (!value) {
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
if (value.type === 'literal' && typeof value.value === 'string') {
|
|
72
|
+
return value.value;
|
|
73
|
+
}
|
|
74
|
+
if ('raw' in value && typeof value.raw === 'string') {
|
|
75
|
+
return value.raw;
|
|
76
|
+
}
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "parse-hcl",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Lightweight HCL parser focused on identifying and classifying blocks. Supports common Terraform blocks (resource, variable, output, locals, etc.) for tooling and automation.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|