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 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
- emit(tfvarsParse(filePath), opts);
70
+ const data = tfvarsParse(filePath);
71
+ emitSingle(filePath, data, opts);
48
72
  return;
49
73
  }
50
74
  if (ext === '.tfstate') {
51
- emit(new artifactParsers_1.TfStateParser().parseFile(filePath), opts);
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
- emit(new artifactParsers_1.TfPlanParser().parseFile(filePath), opts);
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
- emit(doc, opts);
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 combined = result.combined ?? parser.combine(result.files.map((f) => f.document));
66
- emit(opts.graph ? (0, serializer_1.toExport)(combined, { pruneEmpty: opts.prune }) : result, opts);
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 emit(data, opts) {
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
- console.info((0, serializer_1.toYamlDocument)(data, { pruneEmpty: opts.prune }));
78
- return;
140
+ return (0, serializer_1.toYamlDocument)(data, { pruneEmpty: opts.prune });
79
141
  }
80
142
  if (opts.graph && isTerraformDoc(data)) {
81
- console.info((0, serializer_1.toJsonExport)(data, { pruneEmpty: opts.prune }));
82
- return;
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
- console.info((0, serializer_1.toJson)(data, { pruneEmpty: opts.prune }));
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);
@@ -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.1.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",