Repository: ariga/atlas
Pull request #771 (2022-05-04 12:40:34)

cmd/atlascmd: support project file in inspect
This PR adds initial support for project files that define environments as proposed by @a8m on https://github.com/ariga/atlas/issues/709. 

Initially, an `atlas.hcl` file is searched for in the current working directory, it's format is: 

```hcl
env "local" {
	url = "mysql://root:pass@localhost:3306/"
	dev = "docker://mysql/8"
	src = "./app.hcl"
	schemas = ["hello", "world"]
}
```

With this PR, environments are supported with the `schema inspect` and `schema apply` commands, with more commands to follow incrementally until this feature is prepared. For the file above, this would be supported:

```text
atlas schema inspect local
# or
atlas schema apply local
```



Patch:
diff --git a/cmd/atlascmd/migrate.go b/cmd/atlascmd/migrate.go
index f1ff0b0a..2451e12a 100644
--- a/cmd/atlascmd/migrate.go
+++ b/cmd/atlascmd/migrate.go
@@ -103,11 +103,13 @@ This command should be used whenever a manual change in the migration directory
 	// MigrateValidateCmd represents the migrate validate command.
 	MigrateValidateCmd = &cobra.Command{
 		Use:   "validate",
-		Short: "Validates the migration directories checksum.",
+		Short: "Validates the migration directories checksum and SQL statements.",
 		Long: `'atlas migrate validate' computes the integrity hash sum of the migration directory and compares it to 
-the atlas.sum file. If there is a mismatch it will be reported.`,
+the atlas.sum file. If there is a mismatch it will be reported. If the --dev-url flag is given, the migration files are 
+executed on the connected database in order to validate SQL semantics.`,
 		Example: `  atlas migrate validate
-  atlas migrate validate --dir /path/to/migration/directory`,
+  atlas migrate validate --dir /path/to/migration/directory
+  atlas migrate validate --dir /path/to/migration/directory --dev-url mysql://user:pass@localhost:3306/dev`,
 		RunE: CmdMigrateValidateRun,
 	}
 )
diff --git a/cmd/atlascmd/project.go b/cmd/atlascmd/project.go
new file mode 100644
index 00000000..f9b1f9fb
--- /dev/null
+++ b/cmd/atlascmd/project.go
@@ -0,0 +1,75 @@
+// Copyright 2021-present The Atlas Authors. All rights reserved.
+// This source code is licensed under the Apache 2.0 license found
+// in the LICENSE file in the root directory of this source tree.
+
+package atlascmd
+
+import (
+	"fmt"
+	"os"
+
+	"ariga.io/atlas/schema/schemaspec"
+	"ariga.io/atlas/schema/schemaspec/schemahcl"
+)
+
+const projectFileName = "atlas.hcl"
+
+// projectFile represents an atlas.hcl file.
+type projectFile struct {
+	Envs []*Env `spec:"env"`
+}
+
+// Env represents an Atlas environment.
+type Env struct {
+	// Name for this environment.
+	Name string `spec:"name,name"`
+
+	// URL of the database.
+	URL string `spec:"url"`
+
+	// URL of the dev-database for this environment.
+	// See: https://atlasgo.io/dev-database
+	DevURL string `spec:"dev"`
+
+	// Path to the file containing the desired schema of the environment.
+	Source string `spec:"src"`
+
+	// List of schemas in this database that are managed by Atlas.
+	Schemas []string `spec:"schemas"`
+	schemaspec.DefaultExtension
+}
+
+// LoadEnv reads the project file in path, and loads the environment
+// with the provided name into env.
+func LoadEnv(path string, name string) (*Env, error) {
+	b, err := os.ReadFile(path)
+	if err != nil {
+		return nil, err
+	}
+	var project projectFile
+	if err := schemahcl.New().Eval(b, &project, nil); err != nil {
+		return nil, fmt.Errorf("error reading project file: %w", err)
+	}
+	projEnvs := make(map[string]*Env)
+	for _, e := range project.Envs {
+		if _, ok := projEnvs[e.Name]; ok {
+			return nil, fmt.Errorf("duplicate environment name %q", e.Name)
+		}
+		if e.Name == "" {
+			return nil, fmt.Errorf("all envs must have names on file %q", path)
+		}
+		if e.URL == "" {
+			return nil, fmt.Errorf("no url set for e %q", e.Name)
+		}
+		projEnvs[e.Name] = e
+	}
+	selected, ok := projEnvs[name]
+	if !ok {
+		return nil, fmt.Errorf("env %q not defined in project file", name)
+	}
+	return selected, nil
+}
+
+func init() {
+	schemaspec.Register("env", &Env{})
+}
diff --git a/cmd/atlascmd/schema.go b/cmd/atlascmd/schema.go
index e98614ba..4d82ab48 100644
--- a/cmd/atlascmd/schema.go
+++ b/cmd/atlascmd/schema.go
@@ -8,6 +8,7 @@ import (
 	"bytes"
 	"context"
 	"errors"
+	"fmt"
 	"io/fs"
 	"io/ioutil"
 	"os"
@@ -54,7 +55,7 @@ migration, Atlas will print the migration plan and prompt the user for approval.
 
 If run with the "--dry-run" flag, atlas will exit after printing out the planned
 migration.`,
-		Run: CmdApplyRun,
+		RunE: CmdApplyRun,
 		Example: `  atlas schema apply -u "mysql://user:pass@localhost/dbname" -f atlas.hcl
   atlas schema apply -u "mysql://localhost" -f atlas.hcl --schema prod --schema staging
   atlas schema apply -u "mysql://user:pass@localhost:3306/dbname" -f atlas.hcl --dry-run
@@ -114,6 +115,14 @@ const (
 	answerAbort = "Abort"
 )
 
+// selectEnv selects the environment config from the current directory project file.
+func selectEnv(args []string) (*Env, error) {
+	if len(args) == 0 {
+		return nil, nil
+	}
+	return LoadEnv(projectFileName, args[0])
+}
+
 func init() {
 	// Schema apply flags.
 	schemaCmd.AddCommand(SchemaApply)
@@ -129,8 +138,7 @@ func init() {
 	SchemaApply.Flags().BoolVarP(&ApplyFlags.Verbose, migrateDiffFlagVerbose, "", false, "enable verbose logging")
 	SchemaApply.Flags().StringToStringVarP(&ApplyFlags.Vars, "var", "", nil, "input variables")
 	cobra.CheckErr(SchemaApply.MarkFlagRequired("url"))
-	cobra.CheckErr(SchemaApply.MarkFlagRequired("file"))
-	dsn2url(SchemaApply, &ApplyFlags.URL)
+	fixURLFlag(SchemaApply, &ApplyFlags.URL)
 
 	// Schema inspect flags.
 	schemaCmd.AddCommand(SchemaInspect)
@@ -139,14 +147,14 @@ func init() {
 	SchemaInspect.Flags().StringVarP(&InspectFlags.Addr, "addr", "", ":5800", "Used with -w, local address to bind the server to")
 	SchemaInspect.Flags().StringSliceVarP(&InspectFlags.Schema, "schema", "s", nil, "Set schema name")
 	cobra.CheckErr(SchemaInspect.MarkFlagRequired("url"))
-	dsn2url(SchemaInspect, &InspectFlags.URL)
+	fixURLFlag(SchemaInspect, &InspectFlags.URL)
 
 	// Schema fmt.
 	schemaCmd.AddCommand(SchemaFmt)
 }
 
 // CmdInspectRun is the command used when running CLI.
-func CmdInspectRun(cmd *cobra.Command, _ []string) {
+func CmdInspectRun(cmd *cobra.Command, args []string) {
 	if InspectFlags.Web {
 		schemaCmd.PrintErrln("The Alas UI is not available in this release.")
 		return
@@ -155,6 +163,11 @@ func CmdInspectRun(cmd *cobra.Command, _ []string) {
 	cobra.CheckErr(err)
 	defer client.Close()
 	schemas := InspectFlags.Schema
+	activeEnv, err := selectEnv(args)
+	cobra.CheckErr(err)
+	if activeEnv != nil && len(activeEnv.Schemas) > 0 {
+		schemas = activeEnv.Schemas
+	}
 	if client.URL.Schema != "" {
 		schemas = append(schemas, client.URL.Schema)
 	}
@@ -168,15 +181,37 @@ func CmdInspectRun(cmd *cobra.Command, _ []string) {
 }
 
 // CmdApplyRun is the command used when running CLI.
-func CmdApplyRun(cmd *cobra.Command, _ []string) {
+func CmdApplyRun(cmd *cobra.Command, args []string) error {
 	if ApplyFlags.Web {
-		schemaCmd.PrintErrln("The Atlas UI is not available in this release.")
-		return
+		cmd.Println("The Atlas UI is not available in this release.")
+		return errors.New("unavailable")
 	}
 	c, err := sqlclient.Open(cmd.Context(), ApplyFlags.URL)
-	cobra.CheckErr(err)
+	if err != nil {
+		return err
+	}
 	defer c.Close()
-	applyRun(cmd.Context(), c, ApplyFlags.File, ApplyFlags.DryRun, ApplyFlags.AutoApprove, ApplyFlags.Vars)
+	devURL := ApplyFlags.DevURL
+	activeEnv, err := selectEnv(args)
+	if err != nil {
+		return err
+	}
+	if activeEnv != nil && activeEnv.DevURL != "" {
+		devURL = activeEnv.DevURL
+	}
+	var file string
+	switch {
+	case activeEnv != nil && activeEnv.Source != "":
+		file = activeEnv.Source
+	case ApplyFlags.File != "":
+		file = ApplyFlags.File
+	default:
+		return fmt.Errorf("source file must be set via -f or project file")
+	}
+	if activeEnv != nil && activeEnv.Source != "" {
+		file = activeEnv.Source
+	}
+	return applyRun(cmd.Context(), c, devURL, file, ApplyFlags.DryRun, ApplyFlags.AutoApprove, ApplyFlags.Vars)
 }
 
 // CmdFmtRun formats all HCL files in a given directory using canonical HCL formatting
@@ -190,7 +225,7 @@ func CmdFmtRun(cmd *cobra.Command, args []string) {
 	}
 }
 
-func applyRun(ctx context.Context, client *sqlclient.Client, file string, dryRun, autoApprove bool, input map[string]string) {
+func applyRun(ctx context.Context, client *sqlclient.Client, devURL string, file string, dryRun, autoApprove bool, input map[string]string) error {
 	schemas := ApplyFlags.Schema
 	if client.URL.Schema != "" {
 		schemas = append(schemas, client.URL.Schema)
@@ -198,11 +233,17 @@ func applyRun(ctx context.Context, client *sqlclient.Client, file string, dryRun
 	realm, err := client.InspectRealm(ctx, &schema.InspectRealmOption{
 		Schemas: schemas,
 	})
-	cobra.CheckErr(err)
+	if err != nil {
+		return err
+	}
 	f, err := ioutil.ReadFile(file)
-	cobra.CheckErr(err)
+	if err != nil {
+		return err
+	}
 	desired := &schema.Realm{}
-	cobra.CheckErr(client.Eval(f, desired, input))
+	if err := client.Eval(f, desired, input); err != nil {
+		return err
+	}
 	if len(schemas) > 0 {
 		// Validate all schemas in file were selected by user.
 		sm := make(map[string]bool, len(schemas))
@@ -211,26 +252,33 @@ func applyRun(ctx context.Context, client *sqlclient.Client, file string, dryRun
 		}
 		for _, s := range desired.Schemas {
 			if !sm[s.Name] {
-				schemaCmd.Printf("schema %q from file %q was not selected %q, all schemas defined in file must be selected\n", s.Name, file, schemas)
-				return
+				return fmt.Errorf("schema %q from file %q was not selected %q, all schemas defined in file must be selected", s.Name, file, schemas)
 			}
 		}
 	}
-	if _, ok := client.Driver.(schema.Normalizer); ok && ApplyFlags.DevURL != "" {
+	if _, ok := client.Driver.(schema.Normalizer); ok && devURL != "" {
 		dev, err := sqlclient.Open(ctx, ApplyFlags.DevURL)
-		cobra.CheckErr(err)
+		if err != nil {
+			return err
+		}
 		defer dev.Close()
 		desired, err = dev.Driver.(schema.Normalizer).NormalizeRealm(ctx, desired)
-		cobra.CheckErr(err)
+		if err != nil {
+			return err
+		}
 	}
 	changes, err := client.RealmDiff(realm, desired)
-	cobra.CheckErr(err)
+	if err != nil {
+		return err
+	}
 	if len(changes) == 0 {
 		schemaCmd.Println("Schema is synced, no changes to be made")
-		return
+		return nil
 	}
 	p, err := client.PlanChanges(ctx, "plan", changes)
-	cobra.CheckErr(err)
+	if err != nil {
+		return err
+	}
 	schemaCmd.Println("-- Planned Changes:")
 	for _, c := range p.Changes {
 		if c.Comment != "" {
@@ -239,11 +287,14 @@ func applyRun(ctx context.Context, client *sqlclient.Client, file string, dryRun
 		schemaCmd.Println(c.Cmd)
 	}
 	if dryRun {
-		return
+		return nil
 	}
 	if autoApprove || promptUser() {
-		cobra.CheckErr(client.ApplyChanges(ctx, changes))
+		if err := client.ApplyChanges(ctx, changes); err != nil {
+			return err
+		}
 	}
+	return nil
 }
 
 func promptUser() bool {
@@ -256,12 +307,23 @@ func promptUser() bool {
 	return result == answerApply
 }
 
-func dsn2url(cmd *cobra.Command, p *string) {
+// fixURLFlag fixes the url flag by pulling its value either from the flag itself,
+// the (deprecated) dsn flag, or from the active environment.
+func fixURLFlag(cmd *cobra.Command, p *string) {
 	cmd.Flags().StringVarP(p, "dsn", "d", "", "")
 	cobra.CheckErr(cmd.Flags().MarkHidden("dsn"))
 	cmd.PreRunE = func(cmd *cobra.Command, args []string) error {
+		activeEnv, err := selectEnv(args)
+		if err != nil {
+			return err
+		}
 		dsnF, urlF := cmd.Flag("dsn"), cmd.Flag("url")
 		switch {
+		case activeEnv != nil && activeEnv.URL != "":
+			urlF.Changed = true
+			if err := urlF.Value.Set(activeEnv.URL); err != nil {
+				return err
+			}
 		case !dsnF.Changed && !urlF.Changed:
 			return errors.New(`required flag "url" was not set`)
 		case dsnF.Changed && urlF.Changed:
diff --git a/doc/md/cli/reference.md b/doc/md/cli/reference.md
index 1df406bd..ba7b1b92 100644
--- a/doc/md/cli/reference.md
+++ b/doc/md/cli/reference.md
@@ -136,7 +136,7 @@ atlas migrate new
 
 ### atlas migrate validate
 
-Validates the migration directories checksum.
+Validates the migration directories checksum and SQL statements.
 
 #### Usage
 ```
@@ -145,13 +145,15 @@ atlas migrate validate [flags]
 
 #### Details
 'atlas migrate validate' computes the integrity hash sum of the migration directory and compares it to 
-the atlas.sum file. If there is a mismatch it will be reported.
+the atlas.sum file. If there is a mismatch it will be reported. If the --dev-url flag is given, the migration files are 
+executed on the connected database in order to validate SQL semantics.
 
 #### Example
 
 ```
   atlas migrate validate
   atlas migrate validate --dir /path/to/migration/directory
+  atlas migrate validate --dir /path/to/migration/directory --dev-url mysql://user:pass@localhost:3306/dev
 ```
 #### Flags
 ```


Test patch:
diff --git a/cmd/atlascmd/project_test.go b/cmd/atlascmd/project_test.go
new file mode 100644
index 00000000..0ab3dd21
--- /dev/null
+++ b/cmd/atlascmd/project_test.go
@@ -0,0 +1,58 @@
+// Copyright 2021-present The Atlas Authors. All rights reserved.
+// This source code is licensed under the Apache 2.0 license found
+// in the LICENSE file in the root directory of this source tree.
+
+package atlascmd
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestLoadEnv(t *testing.T) {
+	d := t.TempDir()
+	h := `
+env "local" {
+	url = "mysql://root:pass@localhost:3306/"
+	dev = "docker://mysql/8"
+	src = "./app.hcl"
+	schemas = ["hello", "world"]
+}
+`
+	err := os.WriteFile(filepath.Join(d, projectFileName), []byte(h), 0600)
+	require.NoError(t, err)
+	path := filepath.Join(d, projectFileName)
+	t.Run("ok", func(t *testing.T) {
+		env := &Env{}
+		env, err = LoadEnv(path, "local")
+		require.NoError(t, err)
+		require.EqualValues(t, &Env{
+			Name:    "local",
+			URL:     "mysql://root:pass@localhost:3306/",
+			DevURL:  "docker://mysql/8",
+			Source:  "./app.hcl",
+			Schemas: []string{"hello", "world"},
+		}, env)
+	})
+	t.Run("wrong env", func(t *testing.T) {
+		_, err = LoadEnv(path, "home")
+		require.EqualError(t, err, `env "home" not defined in project file`)
+	})
+	t.Run("wrong dir", func(t *testing.T) {
+		wd, err := os.Getwd()
+		require.NoError(t, err)
+		_, err = LoadEnv(filepath.Join(wd, projectFileName), "home")
+		require.ErrorContains(t, err, `no such file or directory`)
+	})
+	t.Run("duplicate env", func(t *testing.T) {
+		dup := h + "\n" + h
+		path := filepath.Join(d, "dup.hcl")
+		err = os.WriteFile(path, []byte(dup), 0600)
+		require.NoError(t, err)
+		_, err = LoadEnv(path, "local")
+		require.EqualError(t, err, `duplicate environment name "local"`)
+	})
+}
diff --git a/internal/integration/integration_test.go b/internal/integration/integration_test.go
index c2193621..107e53e7 100644
--- a/internal/integration/integration_test.go
+++ b/internal/integration/integration_test.go
@@ -11,6 +11,7 @@ import (
 	"os/exec"
 	"path/filepath"
 	"strings"
+	"sync"
 	"testing"
 	"text/template"
 	"time"
@@ -169,8 +170,7 @@ func testHCLIntegration(t T, full string, empty string) {
 }
 
 func testCLISchemaInspect(t T, h string, dsn string, unmarshaler schemaspec.Unmarshaler) {
-	// Required to have a clean "stderr" while running first time.
-	err := exec.Command("go", "run", "-mod=mod", "ariga.io/atlas/cmd/atlas").Run()
+	err := initCLI()
 	require.NoError(t, err)
 	t.dropTables("users")
 	var expected schema.Schema
@@ -194,9 +194,43 @@ func testCLISchemaInspect(t T, h string, dsn string, unmarshaler schemaspec.Unma
 	require.Equal(t, expected, actual)
 }
 
+func testCLISchemaInspectEnv(t T, h string, env string, unmarshaler schemaspec.Unmarshaler) {
+	err := initCLI()
+	require.NoError(t, err)
+	t.dropTables("users")
+	var expected schema.Schema
+	err = unmarshaler.UnmarshalSpec([]byte(h), &expected)
+	require.NoError(t, err)
+	t.applyHcl(h)
+	cmd := exec.Command("go", "run", "ariga.io/atlas/cmd/atlas",
+		"schema",
+		"inspect",
+		env,
+	)
+	stdout, stderr := bytes.NewBuffer(nil), bytes.NewBuffer(nil)
+	cmd.Stderr = stderr
+	cmd.Stdout = stdout
+	require.NoError(t, cmd.Run(), stderr.String())
+	var actual schema.Schema
+	err = unmarshaler.UnmarshalSpec(stdout.Bytes(), &actual)
+	require.NoError(t, err)
+	require.Empty(t, stderr.String())
+	require.Equal(t, expected, actual)
+}
+
+// initOnce controls that the cli will only be built once.
+var initOnce sync.Once
+
+func initCLI() error {
+	var err error
+	initOnce.Do(func() {
+		err = exec.Command("go", "run", "-mod=mod", "ariga.io/atlas/cmd/atlas").Run()
+	})
+	return err
+}
+
 func testCLIMultiSchemaApply(t T, h string, dsn string, schemas []string, unmarshaler schemaspec.Unmarshaler) {
-	// Required to have a clean "stderr" while running first time.
-	err := exec.Command("go", "run", "-mod=mod", "ariga.io/atlas/cmd/atlas").Run()
+	err := initCLI()
 	f := filepath.Join(t.TempDir(), "atlas.hcl")
 	err = ioutil.WriteFile(f, []byte(h), 0644)
 	require.NoError(t, err)
@@ -226,8 +260,8 @@ func testCLIMultiSchemaApply(t T, h string, dsn string, schemas []string, unmars
 }
 
 func testCLIMultiSchemaInspect(t T, h string, dsn string, schemas []string, unmarshaler schemaspec.Unmarshaler) {
-	// Required to have a clean "stderr" while running first time.
-	err := exec.Command("go", "run", "-mod=mod", "ariga.io/atlas/cmd/atlas").Run()
+
+	err := initCLI()
 	require.NoError(t, err)
 	var expected schema.Realm
 	err = unmarshaler.UnmarshalSpec([]byte(h), &expected)
@@ -253,8 +287,8 @@ func testCLIMultiSchemaInspect(t T, h string, dsn string, schemas []string, unma
 }
 
 func testCLISchemaApply(t T, h string, dsn string, args ...string) {
-	// Required to have a clean "stderr" while running first time.
-	err := exec.Command("go", "run", "-mod=mod", "ariga.io/atlas/cmd/atlas").Run()
+
+	err := initCLI()
 	require.NoError(t, err)
 	t.dropTables("users")
 	f := filepath.Join(t.TempDir(), "atlas.hcl")
@@ -289,8 +323,8 @@ func testCLISchemaApply(t T, h string, dsn string, args ...string) {
 }
 
 func testCLISchemaApplyDry(t T, h string, dsn string) {
-	// Required to have a clean "stderr" while running first time.
-	err := exec.Command("go", "run", "-mod=mod", "ariga.io/atlas/cmd/atlas").Run()
+
+	err := initCLI()
 	require.NoError(t, err)
 	t.dropTables("users")
 	f := filepath.Join(t.TempDir(), "atlas.hcl")
@@ -323,8 +357,8 @@ func testCLISchemaApplyDry(t T, h string, dsn string) {
 }
 
 func testCLISchemaApplyAutoApprove(t T, h string, dsn string) {
-	// Required to have a clean "stderr" while running first time.
-	err := exec.Command("go", "run", "-mod=mod", "ariga.io/atlas/cmd/atlas").Run()
+
+	err := initCLI()
 	require.NoError(t, err)
 	t.dropTables("users")
 	f := filepath.Join(t.TempDir(), "atlas.hcl")
@@ -351,8 +385,8 @@ func testCLISchemaApplyAutoApprove(t T, h string, dsn string) {
 }
 
 func testCLISchemaDiff(t T, dsn string) {
-	// Required to have a clean "stderr" while running first time.
-	err := exec.Command("go", "run", "-mod=mod", "ariga.io/atlas/cmd/atlas").Run()
+
+	err := initCLI()
 
 	require.NoError(t, err)
 	t.dropTables("users")
@@ -373,7 +407,7 @@ func testCLISchemaDiff(t T, dsn string) {
 }
 
 func TestCLI_Version(t *testing.T) {
-	// Required to have a clean "stderr" while running first time.
+
 	require.NoError(t, exec.Command("go", "run", "-mod=mod", "ariga.io/atlas/cmd/atlas").Run())
 	tests := []struct {
 		name     string
diff --git a/internal/integration/script_test.go b/internal/integration/script_test.go
index bdc00627..282f6f80 100644
--- a/internal/integration/script_test.go
+++ b/internal/integration/script_test.go
@@ -126,7 +126,6 @@ var (
 const cliPathKey = "cli"
 
 func (t *liteTest) setupScript(env *testscript.Env) error {
-
 	db, err := sql.Open("sqlite3", fmt.Sprintf("file:%s?cache=shared&_fk=1",
 		filepath.Join(env.WorkDir, "atlas.sqlite")))
 	require.NoError(t, err)
@@ -145,6 +144,14 @@ func (t *liteTest) setupScript(env *testscript.Env) error {
 		return err
 	}
 	env.Setenv(cliPathKey, cliPath)
+
+	// Set the workdir in the test atlas.hcl file.
+	projectFile := filepath.Join(env.WorkDir, "atlas.hcl")
+	if b, err := os.ReadFile(projectFile); err == nil {
+		rep := strings.ReplaceAll(string(b), "URL",
+			fmt.Sprintf("sqlite://file:%s/atlas.sqlite?cache=shared&_fk=1", env.WorkDir))
+		return os.WriteFile(projectFile, []byte(rep), 0600)
+	}
 	return nil
 }
 
@@ -321,23 +328,27 @@ func (t *liteTest) cmdExec(ts *testscript.TestScript, _ bool, args []string) {
 }
 
 func (t *liteTest) cmdCLI(ts *testscript.TestScript, _ bool, args []string) {
-	wd := ts.Getenv("WORK")
+	var (
+		workDir = ts.Getenv("WORK")
+		dbURL   = fmt.Sprintf("sqlite://file:%s/atlas.sqlite?cache=shared&_fk=1", workDir)
+		cliPath = ts.Getenv(cliPathKey)
+	)
 	for i, arg := range args {
-		u := fmt.Sprintf("sqlite://file:%s/atlas.sqlite?cache=shared&_fk=1", wd)
-		args[i] = strings.ReplaceAll(arg, "URL", u)
+		args[i] = strings.ReplaceAll(arg, "URL", dbURL)
 	}
 	switch l := len(args); {
 	// If command was run with a unix redirect-like suffix.
 	case l > 1 && args[l-2] == ">":
-		path := filepath.Join(wd, args[l-1])
-		out, err := os.Create(path)
+		outPath := filepath.Join(workDir, args[l-1])
+		f, err := os.Create(outPath)
 		ts.Check(err)
-		defer out.Close()
-		cmd := exec.Command(ts.Getenv(cliPathKey), args[0:l-2]...)
-		cmd.Stdout = out
+		defer f.Close()
+		cmd := exec.Command(cliPath, args[0:l-2]...)
+		cmd.Stdout = f
+		cmd.Dir = workDir
 		ts.Check(cmd.Run())
 	default:
-		ts.Check(ts.Exec(ts.Getenv(cliPathKey), args...))
+		ts.Check(ts.Exec(cliPath, args...))
 	}
 }
 
diff --git a/internal/integration/sqlite_test.go b/internal/integration/sqlite_test.go
index 75205206..7ab45668 100644
--- a/internal/integration/sqlite_test.go
+++ b/internal/integration/sqlite_test.go
@@ -9,7 +9,9 @@ import (
 	"database/sql"
 	"database/sql/driver"
 	"fmt"
+	"os"
 	"path"
+	"path/filepath"
 	"strings"
 	"testing"
 
@@ -446,6 +448,24 @@ func TestSQLite_CLI(t *testing.T) {
 					type = int
 				}
 			}`
+	t.Run("InspectFromEnv", func(t *testing.T) {
+		liteRun(t, func(t *liteTest) {
+			env := fmt.Sprintf(`
+env "hello" {
+	url = "%s"
+}
+`, t.dsn())
+			wd, _ := os.Getwd()
+			envfile := filepath.Join(wd, "atlas.hcl")
+			err := os.WriteFile(envfile, []byte(env), 0600)
+			t.Cleanup(func() {
+				os.Remove(envfile)
+			})
+			require.NoError(t, err)
+
+			testCLISchemaInspectEnv(t, h, "hello", sqlite.UnmarshalHCL)
+		})
+	})
 	t.Run("SchemaInspect", func(t *testing.T) {
 		liteRun(t, func(t *liteTest) {
 			testCLISchemaInspect(t, h, t.dsn(), sqlite.UnmarshalHCL)
diff --git a/internal/integration/testdata/sqlite/cli-schema-project-file.txt b/internal/integration/testdata/sqlite/cli-schema-project-file.txt
new file mode 100644
index 00000000..dbb6d041
--- /dev/null
+++ b/internal/integration/testdata/sqlite/cli-schema-project-file.txt
@@ -0,0 +1,19 @@
+atlas schema apply local --auto-approve
+atlas schema inspect local > inspected.hcl
+cmp 1.hcl inspected.hcl
+
+-- atlas.hcl --
+env "local" {
+    url = "URL"
+    src = "./1.hcl"
+}
+-- 1.hcl --
+table "users" {
+  schema = schema.main
+  column "id" {
+    null = false
+    type = int
+  }
+}
+schema "main" {
+}