Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions cli/commands/catalog/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package catalog
import (
"github.com/gruntwork-io/terragrunt/cli/commands/scaffold"
"github.com/gruntwork-io/terragrunt/cli/flags"
"github.com/gruntwork-io/terragrunt/cli/flags/shared"
"github.com/gruntwork-io/terragrunt/internal/cli"
"github.com/gruntwork-io/terragrunt/options"
"github.com/gruntwork-io/terragrunt/pkg/log"
Expand All @@ -15,10 +16,7 @@ const (
)

func NewFlags(opts *options.TerragruntOptions, prefix flags.Prefix) cli.Flags {
return scaffold.NewFlags(opts, prefix).Filter(
scaffold.RootFileNameFlagName,
scaffold.NoIncludeRootFlagName,
)
return shared.NewScaffoldingFlags(opts, prefix)
}

func NewCommand(l log.Logger, opts *options.TerragruntOptions) *cli.Command {
Expand Down
51 changes: 12 additions & 39 deletions cli/commands/scaffold/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ import (

runCmd "github.com/gruntwork-io/terragrunt/cli/commands/run"
"github.com/gruntwork-io/terragrunt/cli/flags"
"github.com/gruntwork-io/terragrunt/cli/flags/shared"
"github.com/gruntwork-io/terragrunt/config"
"github.com/gruntwork-io/terragrunt/internal/cli"
"github.com/gruntwork-io/terragrunt/internal/errors"
"github.com/gruntwork-io/terragrunt/internal/strict/controls"
"github.com/gruntwork-io/terragrunt/options"
"github.com/gruntwork-io/terragrunt/pkg/log"
Expand All @@ -19,49 +19,20 @@ import (
const (
CommandName = "scaffold"

RootFileNameFlagName = "root-file-name"
NoIncludeRootFlagName = "no-include-root"
OutputFolderFlagName = "output-folder"
VarFlagName = "var"
VarFileFlagName = "var-file"
NoDependencyPrompt = "no-dependency-prompt"
OutputFolderFlagName = "output-folder"
VarFlagName = "var"
VarFileFlagName = "var-file"
NoDependencyPrompt = "no-dependency-prompt"
)

func NewFlags(opts *options.TerragruntOptions, prefix flags.Prefix) cli.Flags {
tgPrefix := prefix.Prepend(flags.TgPrefix)

return cli.Flags{
flags.NewFlag(&cli.GenericFlag[string]{
Name: RootFileNameFlagName,
EnvVars: tgPrefix.EnvVars(RootFileNameFlagName),
Destination: &opts.ScaffoldRootFileName,
Usage: "Name of the root Terragrunt configuration file, if used.",
Action: func(ctx *cli.Context, value string) error {
if value == "" {
return errors.New("root-file-name flag cannot be empty")
}

if value != opts.TerragruntConfigPath {
opts.ScaffoldRootFileName = value

return nil
}

if err := opts.StrictControls.FilterByNames(controls.RootTerragruntHCL).Evaluate(ctx); err != nil {
return cli.NewExitError(err, cli.ExitCodeGeneralError)
}

return nil
},
}),

flags.NewFlag(&cli.BoolFlag{
Name: NoIncludeRootFlagName,
EnvVars: tgPrefix.EnvVars(NoIncludeRootFlagName),
Destination: &opts.ScaffoldNoIncludeRoot,
Usage: "Do not include root unit in scaffolding done by catalog.",
}),
// Start with shared scaffolding flags
scaffoldFlags := shared.NewScaffoldingFlags(opts, prefix)

// Add scaffold-specific flags
scaffoldFlags = append(scaffoldFlags,
flags.NewFlag(&cli.GenericFlag[string]{
Name: OutputFolderFlagName,
Destination: &opts.ScaffoldOutputFolder,
Expand All @@ -88,7 +59,9 @@ func NewFlags(opts *options.TerragruntOptions, prefix flags.Prefix) cli.Flags {
Destination: &opts.NoDependencyPrompt,
Usage: "Do not prompt for confirmation to include dependencies.",
}),
}
)

return scaffoldFlags
}

func NewCommand(l log.Logger, opts *options.TerragruntOptions) *cli.Command {
Expand Down
155 changes: 132 additions & 23 deletions cli/commands/scaffold/scaffold.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"strings"

"github.com/gruntwork-io/terragrunt/cli/commands/hcl/format"
"github.com/gruntwork-io/terragrunt/cli/flags/shared"
"github.com/gruntwork-io/terragrunt/config"
"github.com/gruntwork-io/terragrunt/pkg/log"
"github.com/gruntwork-io/terragrunt/shell"
Expand Down Expand Up @@ -106,7 +107,31 @@ const (
rootFileName = "RootFileName"
)

// NewBoilerplateOptions creates a new BoilerplateOptions struct
func NewBoilerplateOptions(
templateFolder,
outputFolder string,
vars map[string]any,
terragruntOpts *options.TerragruntOptions,
) *boilerplate_options.BoilerplateOptions {
return &boilerplate_options.BoilerplateOptions{
TemplateFolder: templateFolder,
OutputFolder: outputFolder,
OnMissingKey: boilerplate_options.DefaultMissingKeyAction,
OnMissingConfig: boilerplate_options.DefaultMissingConfigAction,
Vars: vars,
ShellCommandAnswers: map[string]bool{},
NoShell: terragruntOpts.NoShell,
NoHooks: terragruntOpts.NoHooks,
NonInteractive: terragruntOpts.NonInteractive,
DisableDependencyPrompt: terragruntOpts.NoDependencyPrompt,
}
}

func Run(ctx context.Context, l log.Logger, opts *options.TerragruntOptions, moduleURL, templateURL string) error {
// Apply catalog configuration settings, with CLI flags taking precedence
applyCatalogConfigToScaffold(ctx, l, opts)

// download remote repo to local
var dirsToClean []string
// clean all temp dirs
Expand Down Expand Up @@ -186,27 +211,25 @@ func Run(ctx context.Context, l log.Logger, opts *options.TerragruntOptions, mod
if _, found := vars[enableRootInclude]; !found {
vars[enableRootInclude] = !opts.ScaffoldNoIncludeRoot
} else {
l.Warnf("The %s variable is already set in the var flag(s). The --%s flag will be ignored.", enableRootInclude, NoIncludeRootFlagName)
l.Warnf(
"The %s variable is already set in the var flag(s). The --%s flag will be ignored.",
enableRootInclude,
shared.NoIncludeRootFlagName,
)
}

if _, found := vars[rootFileName]; !found {
vars[rootFileName] = opts.ScaffoldRootFileName
} else {
l.Warnf("The %s variable is already set in the var flag(s). The --%s flag will be ignored.", rootFileName, NoIncludeRootFlagName)
l.Warnf(
"The %s variable is already set in the var flag(s). The --%s flag will be ignored.",
rootFileName,
shared.NoIncludeRootFlagName,
)
}
Comment on lines 221 to 229
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Wrong flag name in warning for RootFileName.

The warning for RootFileName uses shared.NoIncludeRootFlagName. It should reference the root-file-name flag. Replace with the correct constant.

-    l.Warnf(
-      "The %s variable is already set in the var flag(s). The --%s flag will be ignored.",
-      rootFileName,
-      shared.NoIncludeRootFlagName,
-    )
+    l.Warnf(
+      "The %s variable is already set in the var flag(s). The --%s flag will be ignored.",
+      rootFileName,
+      shared.RootFileNameFlagName, // define if missing
+    )

If a dedicated constant doesn’t exist yet, add one alongside NoIncludeRootFlagName.

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In cli/commands/scaffold/scaffold.go around lines 221 to 229, the warning
message for when the root-file-name var is already set incorrectly references
shared.NoIncludeRootFlagName; update the message to use the correct flag
constant for the root-file-name flag (replace shared.NoIncludeRootFlagName with
the appropriate shared constant, e.g. shared.RootFileNameFlagName). If that
constant does not exist yet, add a new exported constant next to
NoIncludeRootFlagName in the shared package (name it clearly like
RootFileNameFlagName), then use it in the l.Warnf call so the warning prints the
correct flag name.


l.Infof("Running boilerplate generation to %s", outputDir)
boilerplateOpts := &boilerplate_options.BoilerplateOptions{
OutputFolder: outputDir,
OnMissingKey: boilerplate_options.DefaultMissingKeyAction,
OnMissingConfig: boilerplate_options.DefaultMissingConfigAction,
Vars: vars,
DisableShell: true,
DisableHooks: true,
NonInteractive: opts.NonInteractive,
DisableDependencyPrompt: opts.NoDependencyPrompt,
TemplateFolder: boilerplateDir,
}
boilerplateOpts := NewBoilerplateOptions(boilerplateDir, outputDir, vars, opts)

emptyDep := variables.Dependency{}
if err := templates.ProcessTemplate(boilerplateOpts, boilerplateOpts, emptyDep); err != nil {
Expand All @@ -224,22 +247,72 @@ func Run(ctx context.Context, l log.Logger, opts *options.TerragruntOptions, mod
return nil
}

// applyCatalogConfigToScaffold applies catalog configuration settings to scaffold options.
// CLI flags take precedence over config file settings.
func applyCatalogConfigToScaffold(ctx context.Context, l log.Logger, opts *options.TerragruntOptions) {
catalogCfg, err := config.ReadCatalogConfig(ctx, l, opts)
if err != nil {
// Don't fail if catalog config can't be read - it's optional
l.Debugf("Could not read catalog config for scaffold: %v", err)
return
}

if catalogCfg == nil {
return
}

// Apply config settings only if CLI flags weren't explicitly set
// Since both NoShell and NoHooks default to false, we apply the config value
// only if it's true (enabling the restriction)
if catalogCfg.NoShell != nil && *catalogCfg.NoShell && !opts.NoShell {
l.Debugf("Applying catalog config: no_shell = true")

opts.NoShell = true
}

if catalogCfg.NoHooks != nil && *catalogCfg.NoHooks && !opts.NoHooks {
l.Debugf("Applying catalog config: no_hooks = true")

opts.NoHooks = true
}
}
Comment on lines +250 to +278
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

Precedence bug: CLI “false” cannot override catalog “true”.

applyCatalogConfigToScaffold can’t distinguish default false from an explicitly passed --no-shell=false/--no-hooks=false, so config=true always wins. This contradicts “CLI flags take precedence” and your unit test expectation (e.g., “cli_override_config_true_with_false”). Fix by tracking “flag was set” state and honoring it.

Suggested approach:

  • Add NoShellFlagSet/NoHooksFlagSet to options.TerragruntOptions and set them in the flags layer.
  • Gate config application on these.

Diff (scaffold.go):

-func applyCatalogConfigToScaffold(ctx context.Context, l log.Logger, opts *options.TerragruntOptions) {
+func applyCatalogConfigToScaffold(ctx context.Context, l log.Logger, opts *options.TerragruntOptions) {
   catalogCfg, err := config.ReadCatalogConfig(ctx, l, opts)
   if err != nil {
     l.Debugf("Could not read catalog config for scaffold: %v", err)
     return
   }
   if catalogCfg == nil {
     return
   }
-  if catalogCfg.NoShell != nil && *catalogCfg.NoShell && !opts.NoShell {
+  // Only apply when CLI did not set the flag explicitly.
+  if catalogCfg.NoShell != nil && *catalogCfg.NoShell && !opts.NoShellFlagSet {
     l.Debugf("Applying catalog config: no_shell = true")
     opts.NoShell = true
   }
-  if catalogCfg.NoHooks != nil && *catalogCfg.NoHooks && !opts.NoHooks {
+  if catalogCfg.NoHooks != nil && *catalogCfg.NoHooks && !opts.NoHooksFlagSet {
     l.Debugf("Applying catalog config: no_hooks = true")
     opts.NoHooks = true
   }
 }

And in options.TerragruntOptions (separate file):

 type TerragruntOptions struct {
   // existing...
   NoShell bool
   NoHooks bool
+  // Set by flag parsing layer to preserve CLI-precedence semantics
+  NoShellFlagSet bool
+  NoHooksFlagSet bool
 }

Update flag parsing to set these booleans when the corresponding CLI flags are present.

Run to locate flags to update:


🏁 Script executed:

#!/usr/bin/env bash
rg -nP --type=go -C3 '\bNoShell\b|\bNoHooks\b|no-?shell|no-?hooks' cli/ --hidden

Length of output: 20069



---

🏁 Script executed:

```shell
#!/bin/bash
# Find TerragruntOptions struct definition
fd -e go -x grep -l 'type TerragruntOptions struct' {} \;

Length of output: 20


🏁 Script executed:

#!/bin/bash
# Show snippet of TerragruntOptions struct
fd -e go -x grep -A 30 'type TerragruntOptions struct' {} \;

Length of output: 1468



---

🏁 Script executed:

```shell
#!/bin/bash
# Get entire TerragruntOptions struct definition
cat options/options.go | awk '/type TerragruntOptions struct/,/^}/ {print NR": "$0}'

Length of output: 12716





🏁 Script executed:

#!/bin/bash
# Check cli package and flag parsing details
rg -nP --type=go 'import.*cli' cli/flags/shared/shared.go -A 5 -B 2

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Look at complete flag parsing context for NoShell and NoHooks
rg -nP --type=go -B 10 -A 5 'Destination:.*NoShell|Destination:.*NoHooks' cli/flags/shared/shared.go

Length of output: 795



---

🌐 Web query:

```web
urfave/cli BoolFlag explicit value detection when flag not provided

💡 Result:

Use the flag/Context IsSet check instead of the boolean value.

  • Context.IsSet("name") returns true when the flag was provided (including --no-foo), false when it was not provided. Use that to distinguish "not provided" from "provided false". [1][2]

Minimal example (urfave/cli/v3):

app := &cli.App{
  Flags: []cli.Flag{
    &cli.BoolFlag{Name: "foo"},
  },
  Action: func(c *cli.Context) error {
    if c.IsSet("foo") {
      // user explicitly passed --foo (true) or --no-foo (false)
      val := c.Bool("foo")
      fmt.Println("explicit:", val)
    } else {
      // flag not provided
      fmt.Println("not provided")
    }
    return nil
  },
}

References:

  • Context and Flag API (IsSet / BoolFlag docs). [1]
  • Change notes adding IsSet / BoolFlag.Value features. [2]

Sources:
[1] pkg.go.dev — github.com/urfave/cli/v3 (Flag/Context docs).
[2] urfave/cli CHANGELOG (IsSet / BoolFlag.Value).





🏁 Script executed:

#!/bin/bash
# Find where flags are used/applied, look for OnAction or Action callbacks
rg -nP --type=go 'OnAction|Action.*func|flag.*action' cli/ -A 3 -B 3 | head -100

Length of output: 6426


🏁 Script executed:

#!/bin/bash
# Look for where applyCatalogConfigToScaffold is called
rg -nP --type=go 'applyCatalogConfigToScaffold' cli/

Length of output: 353





🏁 Script executed:

#!/bin/bash
# View the call site of applyCatalogConfigToScaffold
sed -n '120,145p' cli/commands/scaffold/scaffold.go

Length of output: 940


🏁 Script executed:

#!/bin/bash
# Check scaffold command Action callback signature
sed -n '74,95p' cli/commands/scaffold/cli.go

Length of output: 502


Precedence bug confirmed: CLI "false" cannot override catalog "true".

The review comment correctly identifies the issue. The applyCatalogConfigToScaffold function checks !opts.NoShell and !opts.NoHooks, which cannot distinguish an explicit --no-shell=false from a default unset state, so catalog config with true always wins—contradicting the stated precedence.

The architecture prevents using urfave/cli's Context.IsSet() at the applyCatalogConfigToScaffold call site (it receives a Go context.Context, not a CLI Context). The suggested approach of adding NoShellFlagSet and NoHooksFlagSet tracking fields aligns with the existing pattern (TFPathExplicitlySet at options.go:326) and is the correct fix.

Required changes:

  1. options/options.go: Add NoShellFlagSet bool and NoHooksFlagSet bool fields to TerragruntOptions struct.
  2. cli/flags/shared/shared.go: Add Action callbacks to NoShellFlagName and NoHooksFlagName flags (similar to lines 104–115 in cli/commands/stack/cli.go) to set the tracking fields when flags are parsed.
  3. cli/commands/scaffold/scaffold.go: Update the precedence checks at lines 267 and 273 to use !opts.NoShellFlagSet and !opts.NoHooksFlagSet instead of !opts.NoShell and !opts.NoHooks.
🤖 Prompt for AI Agents
In cli/commands/scaffold/scaffold.go around lines 250 to 278: the function
currently cannot tell if the CLI explicitly set --no-shell/--no-hooks to false
vs left them unset, so catalog true always wins; add two boolean tracking fields
NoShellFlagSet and NoHooksFlagSet to TerragruntOptions in options/options.go,
add Action callbacks on the NoShell and NoHooks flag definitions in
cli/flags/shared/shared.go that set those tracking fields when the flags are
parsed, and update the precedence checks in this file (lines ~267 and ~273) to
use !opts.NoShellFlagSet and !opts.NoHooksFlagSet instead of !opts.NoShell and
!opts.NoHooks so explicit CLI false overrides catalog true.


// generateDefaultTemplate - write default template to provided dir
func generateDefaultTemplate(boilerplateDir string) (string, error) {
const ownerWriteGlobalReadPerms = 0644
if err := os.WriteFile(util.JoinPath(boilerplateDir, config.DefaultTerragruntConfigPath), []byte(DefaultTerragruntTemplate), ownerWriteGlobalReadPerms); err != nil {
if err := os.WriteFile(
util.JoinPath(
boilerplateDir,
config.DefaultTerragruntConfigPath,
),
[]byte(DefaultTerragruntTemplate),
ownerWriteGlobalReadPerms,
); err != nil {
return "", errors.New(err)
}

if err := os.WriteFile(util.JoinPath(boilerplateDir, "boilerplate.yml"), []byte(DefaultBoilerplateConfig), ownerWriteGlobalReadPerms); err != nil {
if err := os.WriteFile(
util.JoinPath(
boilerplateDir,
"boilerplate.yml",
),
[]byte(DefaultBoilerplateConfig),
ownerWriteGlobalReadPerms,
); err != nil {
return "", errors.New(err)
}

return boilerplateDir, nil
}

// downloadTemplate - parse URL, download files, and handle subfolders
func downloadTemplate(ctx context.Context, l log.Logger, opts *options.TerragruntOptions, templateURL string, tempDir string) (string, error) {
func downloadTemplate(
ctx context.Context,
l log.Logger,
opts *options.TerragruntOptions,
templateURL,
tempDir string,
) (string, error) {
parsedTemplateURL, err := tf.ToSourceURL(templateURL, tempDir)
if err != nil {
return "", errors.New(err)
Expand Down Expand Up @@ -278,15 +351,25 @@ func downloadTemplate(ctx context.Context, l log.Logger, opts *options.Terragrun
templateDir = filepath.Join(templateDir, subFolder)
// Verify that subfolder exists
if _, err := os.Stat(templateDir); os.IsNotExist(err) {
return "", errors.Errorf("subfolder \"//%s\" not found in downloaded template from %s", subFolder, templateURL)
return "", errors.Errorf(
"subfolder \"//%s\" not found in downloaded template from %s",
subFolder,
templateURL,
)
}
}

return templateDir, nil
}

// prepareBoilerplateFiles - prepare boilerplate files from provided template, tf module, or (custom) default template
func prepareBoilerplateFiles(ctx context.Context, l log.Logger, opts *options.TerragruntOptions, templateURL string, tempDir string) (string, error) {
func prepareBoilerplateFiles(
ctx context.Context,
l log.Logger,
opts *options.TerragruntOptions,
templateURL,
tempDir string,
) (string, error) {
boilerplateDir := util.JoinPath(tempDir, util.DefaultBoilerplateDir)

// process template url if it was passed. This overrides the .boilerplate folder in the OpenTofu/Terraform module
Expand Down Expand Up @@ -335,7 +418,11 @@ func prepareBoilerplateFiles(ctx context.Context, l log.Logger, opts *options.Te
}

// parseVariables - parse variables from tf files.
func parseVariables(l log.Logger, opts *options.TerragruntOptions, moduleDir string) ([]*config.ParsedVariable, []*config.ParsedVariable, error) {
func parseVariables(
l log.Logger,
opts *options.TerragruntOptions,
moduleDir string,
) ([]*config.ParsedVariable, []*config.ParsedVariable, error) {
inputs, err := config.ParseVariables(l, opts, moduleDir)
if err != nil {
return nil, nil, errors.New(err)
Expand All @@ -359,7 +446,13 @@ func parseVariables(l log.Logger, opts *options.TerragruntOptions, moduleDir str
}

// parseModuleURL - parse module url and rewrite it if required
func parseModuleURL(ctx context.Context, l log.Logger, opts *options.TerragruntOptions, vars map[string]any, moduleURL string) (string, error) {
func parseModuleURL(
ctx context.Context,
l log.Logger,
opts *options.TerragruntOptions,
vars map[string]any,
moduleURL string,
) (string, error) {
parsedModuleURL, err := tf.ToSourceURL(moduleURL, opts.WorkingDir)
if err != nil {
return "", errors.New(err)
Expand All @@ -385,7 +478,12 @@ func parseModuleURL(ctx context.Context, l log.Logger, opts *options.TerragruntO

// rewriteModuleURL rewrites module url to git ssh if required
// github.com/gruntwork-io/terragrunt.git//test/fixtures/inputs => git::https://github.com/gruntwork-io/terragrunt.git//test/fixtures/inputs
func rewriteModuleURL(l log.Logger, opts *options.TerragruntOptions, vars map[string]any, moduleURL string) (*url.URL, error) {
func rewriteModuleURL(
l log.Logger,
opts *options.TerragruntOptions,
vars map[string]any,
moduleURL string,
) (*url.URL, error) {
var updatedModuleURL = moduleURL

sourceURLType := sourceURLTypeHTTPS
Expand Down Expand Up @@ -428,7 +526,12 @@ func rewriteModuleURL(l log.Logger, opts *options.TerragruntOptions, vars map[st

// rewriteTemplateURL rewrites template url with reference to tag
// github.com/denis256/terragrunt-tests.git//scaffold/base-template => github.com/denis256/terragrunt-tests.git//scaffold/base-template?ref=v0.53.8
func rewriteTemplateURL(ctx context.Context, l log.Logger, opts *options.TerragruntOptions, parsedTemplateURL *url.URL) (*url.URL, error) {
func rewriteTemplateURL(
ctx context.Context,
l log.Logger,
opts *options.TerragruntOptions,
parsedTemplateURL *url.URL,
) (*url.URL, error) {
var (
updatedTemplateURL = parsedTemplateURL
templateParams = updatedTemplateURL.Query()
Expand Down Expand Up @@ -459,7 +562,13 @@ func rewriteTemplateURL(ctx context.Context, l log.Logger, opts *options.Terragr
}

// addRefToModuleURL adds ref to module url if is passed through variables or find it from git tags
func addRefToModuleURL(ctx context.Context, l log.Logger, opts *options.TerragruntOptions, parsedModuleURL *url.URL, vars map[string]any) (*url.URL, error) {
func addRefToModuleURL(
ctx context.Context,
l log.Logger,
opts *options.TerragruntOptions,
parsedModuleURL *url.URL,
vars map[string]any,
) (*url.URL, error) {
var moduleURL = parsedModuleURL
// append ref to source url, if is passed through variables or find it from git tags
params := moduleURL.Query()
Expand Down
Loading