|
| 1 | +package cli |
| 2 | + |
| 3 | +import ( |
| 4 | + "context" |
| 5 | + "encoding/json" |
| 6 | + "fmt" |
| 7 | + "io" |
| 8 | + "os" |
| 9 | + |
| 10 | + "github.com/urfave/cli/v2" |
| 11 | + |
| 12 | + "github.com/gruntwork-io/boilerplate/getterhelper" |
| 13 | + "github.com/gruntwork-io/boilerplate/inputs" |
| 14 | + "github.com/gruntwork-io/boilerplate/options" |
| 15 | + "github.com/gruntwork-io/boilerplate/pkg/logging" |
| 16 | + "github.com/gruntwork-io/boilerplate/variables" |
| 17 | +) |
| 18 | + |
| 19 | +const inputsMapHelpText = `Usage: boilerplate inputs map [OPTIONS] |
| 20 | +
|
| 21 | +Analyze the template at --template-url and print a JSON object describing which |
| 22 | +output files are affected by each declared input variable. The command never |
| 23 | +renders or writes any output files; it parses each template's AST to compute |
| 24 | +the mapping. |
| 25 | +
|
| 26 | +The output schema is: |
| 27 | +
|
| 28 | + { |
| 29 | + "inputs": { |
| 30 | + "<template_path>:<input_name>": { |
| 31 | + "name": "<input_name>", |
| 32 | + "declared_in": "<template_path>", |
| 33 | + "files": ["relative/path/to/file1", ...], |
| 34 | + "type": "string|bool|int|list|map|...", |
| 35 | + "description": "<from boilerplate.yml if present>" |
| 36 | + } |
| 37 | + }, |
| 38 | + "files": { |
| 39 | + "relative/path/to/file1": ["<template_path>:<input_name>", ...] |
| 40 | + }, |
| 41 | + "sources": { |
| 42 | + "relative/path/to/file1": "<absolute_path_to_source_template_file>" |
| 43 | + }, |
| 44 | + "errors": [ |
| 45 | + { "kind": "undeclared_variable", "template": "...", "name": "...", "file": "..." } |
| 46 | + ] |
| 47 | + } |
| 48 | +
|
| 49 | +Keys in "inputs" are fully-qualified as <template_path>:<input_name>, where |
| 50 | +<template_path> is "." for the root template and the dependency's |
| 51 | +output-folder path (relative to the root output) for nested templates. |
| 52 | +
|
| 53 | +Keys in "sources" mirror those in "files": each output path maps to the |
| 54 | +absolute filesystem path of the source template file that produces it. |
| 55 | +Templates resolved via go-getter (git::, https://, ...) live under a temp |
| 56 | +dir for the duration of the command; consumers needing the body should |
| 57 | +read it before the process exits. Output paths whose filename template |
| 58 | +failed to render are omitted from "sources" — the corresponding |
| 59 | +"filename_render" entry in "errors" signals that the path is dynamic. |
| 60 | +
|
| 61 | +The command exits with a non-zero status only on unrecoverable parse failures. |
| 62 | +Soft errors (e.g., a referenced variable that is not declared in any |
| 63 | +boilerplate.yml in scope) appear in the "errors" array and do not change the |
| 64 | +exit code.` |
| 65 | + |
| 66 | +func newInputsCommand() *cli.Command { |
| 67 | + return &cli.Command{ |
| 68 | + Name: "inputs", |
| 69 | + Usage: "Inspect declared input variables for a template.", |
| 70 | + Subcommands: []*cli.Command{ |
| 71 | + { |
| 72 | + Name: "map", |
| 73 | + Usage: "Print a JSON map of declared inputs to the output files they affect.", |
| 74 | + Description: inputsMapHelpText, |
| 75 | + Action: runInputsMap, |
| 76 | + Flags: []cli.Flag{ |
| 77 | + &cli.StringFlag{ |
| 78 | + Name: options.OptTemplateURL, |
| 79 | + Usage: "Generate the input mapping for the template at `URL`. Same resolution rules as `boilerplate template`.", |
| 80 | + Required: true, |
| 81 | + }, |
| 82 | + &cli.StringSliceFlag{ |
| 83 | + Name: options.OptVar, |
| 84 | + Usage: "Use `NAME=VALUE` to set variable NAME to VALUE. Used when rendering output filenames so the reported paths match what `boilerplate template` would produce. May be specified more than once.", |
| 85 | + }, |
| 86 | + &cli.StringSliceFlag{ |
| 87 | + Name: options.OptVarFile, |
| 88 | + Usage: "Load variable values from the YAML file `FILE`. May be specified more than once.", |
| 89 | + }, |
| 90 | + &cli.BoolFlag{ |
| 91 | + Name: options.OptIncludeBundle, |
| 92 | + Usage: "Include a `bundle` field in the JSON output containing the contents of every text file in the resolved dependency tree. The bundle is suitable for feeding back into the WASM `boilerplateInputsMap` and `boilerplateRenderFile` functions for warm-dispatch rendering.", |
| 93 | + }, |
| 94 | + }, |
| 95 | + }, |
| 96 | + }, |
| 97 | + } |
| 98 | +} |
| 99 | + |
| 100 | +// runInputsMap routes output through c.App so tests can inject stdout/stderr. |
| 101 | +func runInputsMap(c *cli.Context) error { |
| 102 | + stdout := io.Writer(os.Stdout) |
| 103 | + if c.App != nil && c.App.Writer != nil { |
| 104 | + stdout = c.App.Writer |
| 105 | + } |
| 106 | + |
| 107 | + stderr := io.Writer(os.Stderr) |
| 108 | + if c.App != nil && c.App.ErrWriter != nil { |
| 109 | + stderr = c.App.ErrWriter |
| 110 | + } |
| 111 | + |
| 112 | + return runInputsMapTo(c, stdout, stderr) |
| 113 | +} |
| 114 | + |
| 115 | +func runInputsMapTo(c *cli.Context, stdout, stderr io.Writer) error { |
| 116 | + vars, err := variables.ParseVars(c.StringSlice(options.OptVar), c.StringSlice(options.OptVarFile)) |
| 117 | + if err != nil { |
| 118 | + writeJSONError(stdout, inputs.KindParseArgs, err) |
| 119 | + return cli.Exit("", 1) |
| 120 | + } |
| 121 | + |
| 122 | + templateURL, templateFolder, err := getterhelper.DetermineTemplateConfig(c.String(options.OptTemplateURL)) |
| 123 | + if err != nil { |
| 124 | + writeJSONError(stdout, inputs.KindParseArgs, err) |
| 125 | + return cli.Exit("", 1) |
| 126 | + } |
| 127 | + |
| 128 | + opts := &options.BoilerplateOptions{ |
| 129 | + Vars: vars, |
| 130 | + TemplateURL: templateURL, |
| 131 | + TemplateFolder: templateFolder, |
| 132 | + NonInteractive: true, |
| 133 | + NoHooks: true, |
| 134 | + NoShell: true, |
| 135 | + OnMissingKey: options.ZeroValue, |
| 136 | + OnMissingConfig: options.Exit, |
| 137 | + } |
| 138 | + |
| 139 | + logger := logging.New(stderr, logging.LevelWarn) |
| 140 | + |
| 141 | + ctx := context.Background() |
| 142 | + |
| 143 | + result, err := inputs.FromOptions(ctx, logger, opts) |
| 144 | + if err != nil { |
| 145 | + writeJSONError(stdout, inputs.KindParse, err) |
| 146 | + return cli.Exit("", 1) |
| 147 | + } |
| 148 | + |
| 149 | + var bundle *inputs.Bundle |
| 150 | + |
| 151 | + if c.Bool(options.OptIncludeBundle) { |
| 152 | + b, notes, bundleErr := inputs.BundleFromOptions(ctx, logger, opts) |
| 153 | + if bundleErr != nil { |
| 154 | + writeJSONError(stdout, inputs.KindParse, bundleErr) |
| 155 | + return cli.Exit("", 1) |
| 156 | + } |
| 157 | + |
| 158 | + bundle = b |
| 159 | + |
| 160 | + for _, n := range notes { |
| 161 | + result.Errors = append(result.Errors, inputs.AnalysisError{ |
| 162 | + Kind: n.Kind, |
| 163 | + Name: n.Name, |
| 164 | + Message: n.Message, |
| 165 | + }) |
| 166 | + } |
| 167 | + } |
| 168 | + |
| 169 | + enc := json.NewEncoder(stdout) |
| 170 | + enc.SetIndent("", " ") |
| 171 | + |
| 172 | + if bundle != nil { |
| 173 | + envelope := struct { |
| 174 | + *inputs.Result |
| 175 | + Bundle *inputs.Bundle `json:"bundle"` |
| 176 | + }{ |
| 177 | + Result: result, |
| 178 | + Bundle: bundle, |
| 179 | + } |
| 180 | + |
| 181 | + if encErr := enc.Encode(envelope); encErr != nil { |
| 182 | + return fmt.Errorf("encode result: %w", encErr) |
| 183 | + } |
| 184 | + |
| 185 | + return nil |
| 186 | + } |
| 187 | + |
| 188 | + if encErr := enc.Encode(result); encErr != nil { |
| 189 | + return fmt.Errorf("encode result: %w", encErr) |
| 190 | + } |
| 191 | + |
| 192 | + return nil |
| 193 | +} |
| 194 | + |
| 195 | +// writeJSONError writes a small JSON document with a top-level "errors" array |
| 196 | +// containing a single entry matching the AnalysisError shape. Used when the |
| 197 | +// command cannot produce a meaningful Result (e.g., the root config did not |
| 198 | +// parse). |
| 199 | +func writeJSONError(w io.Writer, kind string, err error) { |
| 200 | + doc := struct { |
| 201 | + Errors []inputs.AnalysisError `json:"errors"` |
| 202 | + }{ |
| 203 | + Errors: []inputs.AnalysisError{ |
| 204 | + {Kind: kind, Message: err.Error()}, |
| 205 | + }, |
| 206 | + } |
| 207 | + |
| 208 | + enc := json.NewEncoder(w) |
| 209 | + enc.SetIndent("", " ") |
| 210 | + |
| 211 | + if encErr := enc.Encode(doc); encErr == nil { |
| 212 | + return |
| 213 | + } |
| 214 | + |
| 215 | + // Fallback: build the document a second time using json.Marshal so the |
| 216 | + // message is encoded with the same JSON-string-escaping rules — `%q` |
| 217 | + // produces Go syntax, which is not byte-for-byte JSON-safe (e.g., it |
| 218 | + // emits \xNN for non-ASCII bytes, which JSON parsers reject). |
| 219 | + fallback, marshalErr := json.Marshal(struct { |
| 220 | + Errors []inputs.AnalysisError `json:"errors"` |
| 221 | + }{ |
| 222 | + Errors: []inputs.AnalysisError{ |
| 223 | + {Kind: "encode", Message: err.Error()}, |
| 224 | + }, |
| 225 | + }) |
| 226 | + if marshalErr != nil { |
| 227 | + return |
| 228 | + } |
| 229 | + |
| 230 | + _, _ = w.Write(fallback) |
| 231 | + _, _ = w.Write([]byte("\n")) |
| 232 | +} |
0 commit comments