Add providers mirror subcommand (#551)

Co-authored-by: Radek Simko <radeksimko@users.noreply.github.com>
Co-authored-by: Daniel Schmidt <danielmschmidt92@gmail.com>
Co-authored-by: Matej Risek <matej.risek@hashicorp.com>
Co-authored-by: Samsondeen Dare <samsondeen.dare@hashicorp.com>
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b035441..d439a90 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,7 @@
 ENHANCEMENTS:
 - tfexec: Added provider reattach support to all `terraform workspace` subcommands ([#556](https://github.com/hashicorp/terraform-exec/pull/556))
 - tfexec: Add `-generate-config-out` to the `(Terraform).Plan()` method ([#563](https://github.com/hashicorp/terraform-exec/pull/563))
+- Add support for `providers mirror` subcommand ([#551](https://github.com/hashicorp/terraform-exec/pull/551))
 
 # 0.24.0 (September 17, 2025)
 
diff --git a/tfexec/internal/e2etest/providers_lock_test.go b/tfexec/internal/e2etest/providers_lock_test.go
index ae8eb09..c0beec6 100644
--- a/tfexec/internal/e2etest/providers_lock_test.go
+++ b/tfexec/internal/e2etest/providers_lock_test.go
@@ -28,7 +28,7 @@
 
 		err = tf.ProvidersLock(context.Background())
 		if err != nil {
-			t.Fatalf("error running provider lock: %s", err)
+			t.Fatalf("error running providers lock: %s", err)
 		}
 	})
 
diff --git a/tfexec/internal/e2etest/providers_mirror_test.go b/tfexec/internal/e2etest/providers_mirror_test.go
new file mode 100644
index 0000000..aa46945
--- /dev/null
+++ b/tfexec/internal/e2etest/providers_mirror_test.go
@@ -0,0 +1,61 @@
+// Copyright (c) HashiCorp, Inc.
+// SPDX-License-Identifier: MPL-2.0
+package e2etest
+
+import (
+	"context"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/hashicorp/go-version"
+
+	"github.com/hashicorp/terraform-exec/tfexec"
+)
+
+var (
+	providersMirrorMinVersion         = version.Must(version.NewVersion("0.13.0"))
+	providersMirrorLockFileMinVersion = version.Must(version.NewVersion("1.10.0"))
+)
+
+func TestProvidersMirror(t *testing.T) {
+	runTest(t, "basic", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) {
+		if tfv.LessThan(providersMirrorMinVersion) {
+			t.Skip("terraform providers mirror was added in Terraform 0.13, so test is not valid")
+		}
+		err := tf.Init(context.Background())
+		if err != nil {
+			t.Fatalf("error running Init in test directory: %s", err)
+		}
+
+		targetDir := t.TempDir()
+		err = tf.ProvidersMirror(context.Background(), targetDir)
+		if err != nil {
+			t.Fatalf("error running providers mirror: %s", err)
+		}
+
+		expectedMirrorPath := filepath.Join(targetDir, "registry.terraform.io", "hashicorp", "null")
+		_, err = os.Stat(expectedMirrorPath)
+		if err != nil {
+			t.Fatalf("providers mirror not found in %s", expectedMirrorPath)
+		}
+	})
+}
+
+func TestProvidersMirror_lockFileFalse(t *testing.T) {
+	runTest(t, "basic", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) {
+		if tfv.LessThan(providersMirrorLockFileMinVersion) {
+			t.Skip("terraform providers mirror -lock-file flag was added in Terraform 1.10, so test is not valid")
+		}
+		err := tf.Init(context.Background())
+		if err != nil {
+			t.Fatalf("error running Init in test directory: %s", err)
+		}
+
+		targetDir := t.TempDir()
+		err = tf.ProvidersMirror(context.Background(), targetDir, tfexec.LockFile(false))
+		if err != nil {
+			t.Fatalf("error running providers mirror: %s", err)
+		}
+	})
+}
diff --git a/tfexec/internal/e2etest/show_test.go b/tfexec/internal/e2etest/show_test.go
index 342a9a1..7c3841f 100644
--- a/tfexec/internal/e2etest/show_test.go
+++ b/tfexec/internal/e2etest/show_test.go
@@ -121,7 +121,7 @@
 	// no providers to download, this is unintended behaviour, as
 	// init is not actually necessary. This is considered a known issue in
 	// pre-1.2.0 versions.
-	runTestWithVersions(t, []string{testutil.Latest012, testutil.Latest013, testutil.Latest014, testutil.Latest015, testutil.Latest_v1, testutil.Latest_v1_1}, "basic", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) {
+	runTestWithVersions(t, []string{testutil.Latest012, testutil.Latest013, testutil.Latest014, testutil.Latest015, testutil.Latest_v1_0, testutil.Latest_v1_1}, "basic", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) {
 		_, err := tf.Show(context.Background())
 		if err == nil {
 			t.Fatalf("expected error, but did not get one")
@@ -158,7 +158,7 @@
 	// no providers to download, this is unintended behaviour, as
 	// init is not actually necessary. This is considered a known issue in
 	// pre-1.2.0 versions.
-	runTestWithVersions(t, []string{testutil.Latest012, testutil.Latest013, testutil.Latest014, testutil.Latest015, testutil.Latest_v1, testutil.Latest_v1_1}, "registry_module", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) {
+	runTestWithVersions(t, []string{testutil.Latest012, testutil.Latest013, testutil.Latest014, testutil.Latest015, testutil.Latest_v1_0, testutil.Latest_v1_1}, "registry_module", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) {
 		_, err := tf.Show(context.Background())
 		if err == nil {
 			t.Fatalf("expected error, but did not get one")
diff --git a/tfexec/internal/testutil/tfcache.go b/tfexec/internal/testutil/tfcache.go
index fa7d260..b8240b4 100644
--- a/tfexec/internal/testutil/tfcache.go
+++ b/tfexec/internal/testutil/tfcache.go
@@ -15,12 +15,13 @@
 )
 
 const (
-	Latest011          = "0.11.15"
-	Latest012          = "0.12.31"
-	Latest013          = "0.13.7"
-	Latest014          = "0.14.11"
-	Latest015          = "0.15.5"
-	Latest_v1          = "1.0.11"
+	Latest011 = "0.11.15"
+	Latest012 = "0.12.31"
+	Latest013 = "0.13.7"
+	Latest014 = "0.14.11"
+	Latest015 = "0.15.5"
+
+	Latest_v1_0        = "1.0.11"
 	Latest_v1_1        = "1.1.9"
 	Latest_v1_2        = "1.2.9"
 	Latest_v1_3        = "1.3.10"
@@ -37,6 +38,8 @@
 	Latest_v1_11       = "1.11.4"
 	Latest_v1_12       = "1.12.2"
 	Latest_Alpha_v1_14 = "1.14.0-alpha20250903"
+
+	Latest_v1 = "1.14.0"
 )
 
 const appendUserAgent = "tfexec-testutil"
diff --git a/tfexec/options.go b/tfexec/options.go
index 145f10a..191db06 100644
--- a/tfexec/options.go
+++ b/tfexec/options.go
@@ -467,3 +467,12 @@
 func VerifyPlugins(verifyPlugins bool) *VerifyPluginsOption {
 	return &VerifyPluginsOption{verifyPlugins}
 }
+
+// LockFileOption represents the -lock-file flag.
+type LockFileOption struct {
+	useLockFile bool
+}
+
+func LockFile(useLockFile bool) *LockFileOption {
+	return &LockFileOption{useLockFile: useLockFile}
+}
diff --git a/tfexec/providers_mirror.go b/tfexec/providers_mirror.go
new file mode 100644
index 0000000..02188a9
--- /dev/null
+++ b/tfexec/providers_mirror.go
@@ -0,0 +1,85 @@
+// Copyright (c) HashiCorp, Inc.
+// SPDX-License-Identifier: MPL-2.0
+
+package tfexec
+
+import (
+	"context"
+	"fmt"
+	"os/exec"
+)
+
+type providersMirrorConfig struct {
+	lockFile  bool
+	platforms []string
+}
+
+var defaultProvidersMirrorOptions = providersMirrorConfig{
+	// defaults to true
+	// See https://github.com/hashicorp/terraform/blob/v1.14.0/internal/command/providers_mirror.go#L42
+	lockFile: true,
+}
+
+type ProvidersMirrorOption interface {
+	configureProvidersMirror(*providersMirrorConfig)
+}
+
+func (opt *LockFileOption) configureProvidersMirror(conf *providersMirrorConfig) {
+	conf.lockFile = opt.useLockFile
+}
+
+func (opt *PlatformOption) configureProvidersMirror(conf *providersMirrorConfig) {
+	conf.platforms = append(conf.platforms, opt.platform)
+}
+
+// ProvidersMirror represents the `terraform providers mirror` command
+func (tf *Terraform) ProvidersMirror(ctx context.Context, targetDir string, opts ...ProvidersMirrorOption) error {
+	err := tf.compatible(ctx, tf0_13_0, nil)
+	if err != nil {
+		return fmt.Errorf("terraform providers mirror was added in 0.13.0: %w", err)
+	}
+	if targetDir == "" {
+		return fmt.Errorf("targetDir argument needs to be set")
+	}
+
+	mirrorCmd, err := tf.providersMirrorCmd(ctx, targetDir, opts...)
+	if err != nil {
+		return err
+	}
+
+	err = tf.runTerraformCmd(ctx, mirrorCmd)
+	if err != nil {
+		return err
+	}
+
+	return err
+}
+
+func (tf *Terraform) providersMirrorCmd(ctx context.Context, targetDir string, opts ...ProvidersMirrorOption) (*exec.Cmd, error) {
+	c := defaultProvidersMirrorOptions
+
+	args := []string{"providers", "mirror"}
+
+	for _, o := range opts {
+		o.configureProvidersMirror(&c)
+	}
+
+	for _, p := range c.platforms {
+		args = append(args, "-platform="+p)
+	}
+
+	// lockFile is true by default, so only pass the flag if the caller has set it
+	// to false
+	if !c.lockFile {
+		err := tf.compatible(ctx, tf1_10_0, nil)
+		if err != nil {
+			return nil, fmt.Errorf("lock-file option was introduced in Terraform 1.10.0: %w", err)
+		}
+
+		args = append(args, "-lock-file=false")
+	}
+
+	args = append(args, targetDir)
+
+	return tf.buildTerraformCmd(ctx, nil, args...), nil
+}
diff --git a/tfexec/providers_mirror_test.go b/tfexec/providers_mirror_test.go
new file mode 100644
index 0000000..0bac1fa
--- /dev/null
+++ b/tfexec/providers_mirror_test.go
@@ -0,0 +1,67 @@
+// Copyright (c) HashiCorp, Inc.
+// SPDX-License-Identifier: MPL-2.0
+
+package tfexec
+
+import (
+	"context"
+	"testing"
+
+	"github.com/hashicorp/terraform-exec/tfexec/internal/testutil"
+)
+
+func TestProvidersMirrorCmd(t *testing.T) {
+	td := t.TempDir()
+
+	tf, err := NewTerraform(td, tfVersion(t, testutil.Latest_v1))
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// empty env, to avoid environ mismatch in testing
+	tf.SetEnv(map[string]string{})
+
+	t.Run("defaults", func(t *testing.T) {
+		mirrorCmd, err := tf.providersMirrorCmd(context.Background(), "path")
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		assertCmd(t, []string{
+			"providers",
+			"mirror",
+			"path",
+		}, nil, mirrorCmd)
+	})
+
+	t.Run("multiple platforms", func(t *testing.T) {
+		mirrorCmd, err := tf.providersMirrorCmd(context.Background(), "path", Platform("IBM-Z"), Platform("Solaris"), Platform("Commodore64"))
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		assertCmd(t, []string{
+			"providers",
+			"mirror",
+			"-platform=IBM-Z",
+			"-platform=Solaris",
+			"-platform=Commodore64",
+			"path",
+		}, nil, mirrorCmd)
+	})
+
+	t.Run("override all defaults", func(t *testing.T) {
+		mirrorCmd, err := tf.providersMirrorCmd(context.Background(), "path", LockFile(false), Platform("IBM-Z"))
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		assertCmd(t, []string{
+			"providers",
+			"mirror",
+			"-platform=IBM-Z",
+			"-lock-file=false",
+			"path",
+		}, nil, mirrorCmd)
+	})
+}
diff --git a/tfexec/version.go b/tfexec/version.go
index 5745ea9..481fd45 100644
--- a/tfexec/version.go
+++ b/tfexec/version.go
@@ -36,6 +36,7 @@
 	tf1_5_0  = version.Must(version.NewVersion("1.5.0"))
 	tf1_6_0  = version.Must(version.NewVersion("1.6.0"))
 	tf1_9_0  = version.Must(version.NewVersion("1.9.0"))
+	tf1_10_0 = version.Must(version.NewVersion("1.10.0"))
 	tf1_13_0 = version.Must(version.NewVersion("1.13.0"))
 	tf1_14_0 = version.Must(version.NewVersion("1.14.0"))
 )