Repository: onflow/flow-cli
Pull request #101 (2021-03-02 19:03:40)

Config compose file
Closes #82 #87 #70 

## Description

Implemented private files as `fromFile` syntax.

______

For contributor use:

- [ ] Targeted PR against `master` branch
- [ ] Linked to Github issue with discussion and accepted design OR link to spec that describes this work
- [ ] Code follows the [standards mentioned here](https://github.com/onflow/flow-cli/blob/master/CONTRIBUTING.md#styleguides)
- [ ] Updated relevant documentation
- [ ] Re-reviewed `Files changed` in the Github PR explorer
- [ ] Added appropriate labels



Patch:
diff --git a/flow/project/cli/config/composer.go b/flow/project/cli/config/composer.go
deleted file mode 100644
index 86e4d7b1..00000000
--- a/flow/project/cli/config/composer.go
+++ /dev/null
@@ -1,155 +0,0 @@
-/*
- * Flow CLI
- *
- * Copyright 2019-2021 Dapper Labs, Inc.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *   http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package config
-
-import (
-	"errors"
-	"os"
-	"path/filepath"
-
-	"github.com/spf13/afero"
-)
-
-// ErrDoesNotExist is error to be returned when config file does not exists
-var ErrDoesNotExist = errors.New("project config file does not exist")
-
-const DefaultConfigPath = "flow.json"
-
-// Exists checks if file exists on the specified path
-func Exists(path string) bool {
-	info, err := os.Stat(path)
-	if os.IsNotExist(err) {
-		return false
-	}
-	return !info.IsDir()
-}
-
-type ConfigParsers []Parser
-
-func (c *ConfigParsers) FindForFormat(extension string) Parser {
-	for _, parser := range *c {
-		if parser.SupportsFormat(extension) {
-			return parser
-		}
-	}
-
-	return nil
-}
-
-type Composer struct {
-	af            *afero.Afero
-	configParsers ConfigParsers
-}
-
-func NewComposer(filesystem afero.Fs) *Composer {
-	af := &afero.Afero{Fs: filesystem}
-	return &Composer{af: af}
-}
-
-func (c *Composer) AddConfigParser(format Parser) {
-	c.configParsers = append(c.configParsers, format)
-}
-
-// Load configuration
-func (c *Composer) loadSingle(path string) (*Config, error) {
-	raw, err := c.loadFile(path)
-
-	if err != nil {
-		return nil, err
-	}
-
-	preprocessor := NewPreprocessor(c.af.Fs)
-	preProcessed := preprocessor.Run(raw)
-
-	configFormat := c.configParsers.FindForFormat(
-		filepath.Ext(path),
-	)
-
-	return configFormat.Deserialize(preProcessed)
-}
-
-func (c *Composer) Save(conf *Config, path string) error {
-	configFormat := c.configParsers.FindForFormat(
-		filepath.Ext(path),
-	)
-
-	data, err := configFormat.Serialize(conf)
-	if err != nil {
-		return err
-	}
-
-	err = c.af.WriteFile(path, data, 0755)
-	if err != nil {
-		return err
-	}
-
-	return nil
-}
-
-// Load and compose multiple configurations
-func (c *Composer) Load(paths []string) (*Config, error) {
-	var config *Config
-
-	for _, path := range paths {
-		conf, err := c.loadSingle(path)
-		if err != nil {
-			return nil, err
-		}
-
-		// if first config just save it
-		if config == nil {
-			config = conf
-			continue
-		}
-
-		// if not first overwrite first with this one
-		for _, account := range conf.Accounts {
-			conf.Accounts.SetForName(account.Name, account)
-		}
-
-		for _, network := range conf.Networks {
-			conf.Networks.SetForName(network.Name, network)
-		}
-
-		for _, contract := range conf.Contracts {
-			conf.Contracts.SetForName(contract.Name, contract)
-		}
-
-		for _, deployment := range conf.Deployments {
-			conf.Deployments.AddIfMissing(deployment)
-		}
-	}
-
-	return config, nil
-}
-
-func (c *Composer) loadFile(path string) ([]byte, error) {
-	raw, err := c.af.ReadFile(path)
-
-	// TODO: better handle
-	if err != nil {
-		if os.IsNotExist(err) {
-			return nil, ErrDoesNotExist
-		}
-
-		return nil, err
-	}
-
-	return raw, nil
-}
diff --git a/flow/project/cli/config/config.go b/flow/project/cli/config/config.go
index 8d3c2583..ecb93979 100644
--- a/flow/project/cli/config/config.go
+++ b/flow/project/cli/config/config.go
@@ -93,13 +93,6 @@ const (
 	KeyTypeShell     KeyType = "shell"      // Exec out to a shell script
 )
 
-// Parser is interface for any configuration format parser to implement
-type Parser interface {
-	Serialize(*Config) ([]byte, error)
-	Deserialize([]byte) (*Config, error)
-	SupportsFormat(string) bool
-}
-
 // IsAlias checks if contract has an alias
 func (c *Contract) IsAlias() bool {
 	return c.Alias != ""
@@ -149,9 +142,9 @@ func (c *Contracts) GetByNetwork(network string) Contracts {
 }
 
 func (c *Contracts) SetForName(name string, contract Contract) {
-	for _, con := range *c {
-		if con.Name == name {
-			con = contract
+	for i := range *c {
+		if (*c)[i].Name == name {
+			(*c)[i] = contract
 			return
 		}
 	}
@@ -182,9 +175,9 @@ func (a *Accounts) GetByAddress(address string) *Account {
 }
 
 func (a *Accounts) SetForName(name string, account Account) {
-	for _, acc := range *a {
-		if acc.Name == name {
-			acc = account
+	for i := range *a {
+		if (*a)[i].Name == name {
+			(*a)[i] = account
 			return
 		}
 	}
@@ -240,9 +233,9 @@ func (n *Networks) GetByName(name string) *Network {
 }
 
 func (n *Networks) SetForName(name string, network Network) {
-	for _, net := range *n {
-		if net.Name == name {
-			net = network
+	for i := range *n {
+		if (*n)[i].Name == name {
+			(*n)[i] = network
 			return
 		}
 	}
diff --git a/flow/project/cli/config/json/config.go b/flow/project/cli/config/json/config.go
index 174e9bdb..3d415989 100644
--- a/flow/project/cli/config/json/config.go
+++ b/flow/project/cli/config/json/config.go
@@ -20,9 +20,7 @@ package json
 
 import (
 	"encoding/json"
-	"fmt"
 	"github.com/onflow/flow-cli/flow/project/cli/config"
-	"os"
 )
 
 // TODO: implement interface for all specific config implementation
@@ -55,7 +53,7 @@ func transformConfigToJSON(config *config.Config) jsonConfig {
 	}
 }
 
-func NewParser() config.Parser {
+func NewParser() *jsonConfig {
 	return new(jsonConfig)
 }
 
@@ -76,8 +74,7 @@ func (j *jsonConfig) Deserialize(raw []byte) (*config.Config, error) {
 	err := json.Unmarshal(raw, &jsonConf)
 
 	if err != nil {
-		fmt.Printf("Invalid json: %s\n", err.Error())
-		os.Exit(1)
+		return nil, err
 	}
 
 	return jsonConf.transformToConfig(), nil
diff --git a/flow/project/cli/config/manipulators/composer.go b/flow/project/cli/config/manipulators/composer.go
new file mode 100644
index 00000000..07970ba4
--- /dev/null
+++ b/flow/project/cli/config/manipulators/composer.go
@@ -0,0 +1,210 @@
+/*
+ * Flow CLI
+ *
+ * Copyright 2019-2021 Dapper Labs, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package manipulators
+
+import (
+	"errors"
+	"fmt"
+	"github.com/onflow/flow-cli/flow/project/cli/config"
+	"os"
+	"path/filepath"
+
+	"github.com/spf13/afero"
+)
+
+// ErrDoesNotExist is error to be returned when config file does not exists
+var ErrDoesNotExist = errors.New("project config file does not exist")
+
+// Exists checks if file exists on the specified path
+func Exists(path string) bool {
+	info, err := os.Stat(path)
+	if os.IsNotExist(err) {
+		return false
+	}
+	return !info.IsDir()
+}
+
+// Parser is interface for any configuration format parser to implement
+type Parser interface {
+	Serialize(*config.Config) ([]byte, error)
+	Deserialize([]byte) (*config.Config, error)
+	SupportsFormat(string) bool
+}
+
+type ConfigParsers []Parser
+
+func (c *ConfigParsers) FindForFormat(extension string) Parser {
+	for _, parser := range *c {
+		if parser.SupportsFormat(extension) {
+			return parser
+		}
+	}
+
+	return nil
+}
+
+type Composer struct {
+	af               *afero.Afero
+	configParsers    ConfigParsers
+	composedMultiple bool
+	composedFromFile map[string]string
+}
+
+func NewComposer(filesystem afero.Fs) *Composer {
+	af := &afero.Afero{Fs: filesystem}
+	return &Composer{
+		af:               af,
+		composedFromFile: map[string]string{},
+	}
+}
+
+func (c *Composer) AddConfigParser(format Parser) {
+	c.configParsers = append(c.configParsers, format)
+}
+
+func (c *Composer) Save(conf *config.Config, path string) error {
+	if c.composedMultiple || c.composedFromFile != nil {
+		return errors.New("Saving configuration to multiple files currently not supported")
+	}
+
+	configFormat := c.configParsers.FindForFormat(
+		filepath.Ext(path),
+	)
+
+	data, err := configFormat.Serialize(conf)
+	if err != nil {
+		return err
+	}
+
+	err = c.af.WriteFile(path, data, 0644)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// Load and compose multiple configurations
+func (c *Composer) Load(paths []string) (*config.Config, error) {
+	var baseConf *config.Config
+
+	for _, path := range paths {
+		raw, err := c.loadFile(path)
+		if err != nil {
+			return nil, err
+		}
+
+		preProcessed := c.preprocess(raw)
+		configParser := c.configParsers.FindForFormat(filepath.Ext(path))
+		if configParser == nil {
+			return nil, fmt.Errorf("Parser not found for config: %s", path)
+		}
+
+		conf, err := configParser.Deserialize(preProcessed)
+		if err != nil {
+			return nil, err
+		}
+
+		// if first conf just assign as baseConf
+		if baseConf == nil {
+			baseConf = conf
+			continue
+		}
+
+		c.composeConfig(baseConf, conf)
+	}
+
+	baseConf, err := c.postprocess(baseConf)
+	if err != nil {
+		return nil, err
+	}
+
+	return baseConf, nil
+}
+
+func (c *Composer) AddAccountFromFile(path string, name string) {
+	c.composedFromFile[name] = path
+}
+
+func (c *Composer) preprocess(raw []byte) []byte {
+	preprocessor := NewPreprocessor(c)
+	return preprocessor.Run(raw)
+}
+
+func (c *Composer) postprocess(baseConf *config.Config) (*config.Config, error) {
+	for name, path := range c.composedFromFile {
+		raw, err := c.loadFile(path)
+		if err != nil {
+			return nil, err
+		}
+
+		configParser := c.configParsers.FindForFormat(filepath.Ext(path))
+		if configParser == nil {
+			return nil, fmt.Errorf("Parser not found for config: %s", path)
+		}
+
+		conf, err := configParser.Deserialize(raw)
+		if err != nil {
+			return nil, err
+		}
+
+		// create an empty config with single account so we don't include all accounts in file
+		accountConf := &config.Config{
+			Accounts: []config.Account{*conf.Accounts.GetByName(name)},
+		}
+
+		c.composeConfig(baseConf, accountConf)
+	}
+
+	return baseConf, nil
+}
+
+func (c *Composer) composeConfig(baseConf *config.Config, conf *config.Config) {
+	// flag for saving
+	c.composedMultiple = true
+
+	// if not first overwrite first with this one
+	for _, account := range conf.Accounts {
+		baseConf.Accounts.SetForName(account.Name, account)
+	}
+	for _, network := range conf.Networks {
+		baseConf.Networks.SetForName(network.Name, network)
+	}
+	for _, contract := range conf.Contracts {
+		baseConf.Contracts.SetForName(contract.Name, contract)
+	}
+	for _, deployment := range conf.Deployments {
+		baseConf.Deployments.AddIfMissing(deployment)
+	}
+}
+
+func (c *Composer) loadFile(path string) ([]byte, error) {
+	raw, err := c.af.ReadFile(path)
+
+	// TODO: better handle
+	if err != nil {
+		if os.IsNotExist(err) {
+			return nil, ErrDoesNotExist
+		}
+
+		return nil, err
+	}
+
+	return raw, nil
+}
diff --git a/flow/project/cli/config/processor.go b/flow/project/cli/config/manipulators/processor.go
similarity index 72%
rename from flow/project/cli/config/processor.go
rename to flow/project/cli/config/manipulators/processor.go
index bcaae0cd..f68c0d5f 100644
--- a/flow/project/cli/config/processor.go
+++ b/flow/project/cli/config/manipulators/processor.go
@@ -16,32 +16,30 @@
  * limitations under the License.
  */
 
-package config
+package manipulators
 
 import (
-	"fmt"
 	"os"
 	"regexp"
 	"strings"
-
-	"github.com/spf13/afero"
 )
 
 var (
-	envRegex  *regexp.Regexp = regexp.MustCompile(`\$\{env\:(.+)\}`)
-	fileRegex *regexp.Regexp = regexp.MustCompile(`\"\$\{file\:(.+)\}\"`)
+	envRegex      *regexp.Regexp = regexp.MustCompile(`\$\{env\:(.+)\}`)
+	fileRegex     *regexp.Regexp = regexp.MustCompile(`"([^"]*)"\s*:\s*{\s*"fromFile"\s*:\s*"([^"]*)"\s*},?`)
+	trailingComma *regexp.Regexp = regexp.MustCompile(`\,\s*}`)
 )
 
 // Preprocessor is used to pre-process configuration file
 type Preprocessor struct {
-	af *afero.Afero
+	composer *Composer
 }
 
 // NewPreprocessor creates new instance of preprocessor
-func NewPreprocessor(filesystem afero.Fs) *Preprocessor {
-	af := &afero.Afero{Fs: filesystem}
-
-	return &Preprocessor{af: af}
+func NewPreprocessor(composer *Composer) *Preprocessor {
+	return &Preprocessor{
+		composer: composer,
+	}
 }
 
 // Run all pre-processors
@@ -73,18 +71,17 @@ func (p *Preprocessor) processFile(raw string) string {
 	fileMatches := fileRegex.FindAllStringSubmatch(raw, -1)
 
 	for _, match := range fileMatches {
-		configFileRaw, err := p.af.ReadFile(match[1])
-
-		if err != nil {
-			fmt.Printf("Config file %s not found. \n", match[1])
-			os.Exit(1)
+		if len(match) < 3 {
+			continue
 		}
 
-		raw = strings.ReplaceAll(
-			raw,
-			match[0],
-			string(configFileRaw),
-		)
+		p.composer.AddAccountFromFile(match[2], match[1])
+
+		// remove from config
+		raw = strings.ReplaceAll(raw, match[0], "")
+
+		// remove possible trailing comma
+		raw = trailingComma.ReplaceAllString(raw, "}")
 	}
 
 	return raw
diff --git a/flow/project/cli/project.go b/flow/project/cli/project.go
index 59cb76f3..b2227c2a 100644
--- a/flow/project/cli/project.go
+++ b/flow/project/cli/project.go
@@ -21,6 +21,7 @@ package cli
 import (
 	"errors"
 	"github.com/onflow/flow-cli/flow/project/cli/config/json"
+	"github.com/onflow/flow-cli/flow/project/cli/config/manipulators"
 	"path"
 	"strings"
 
@@ -35,21 +36,21 @@ import (
 
 // Project has all the funcionality to manage project
 type Project struct {
-	composer *config.Composer
+	composer *manipulators.Composer
 	conf     *config.Config
 	accounts []*Account
 }
 
 // LoadProject loads configuration and setup the project
 func LoadProject(configFilePath []string) *Project {
-	composer := config.NewComposer(afero.NewOsFs())
+	composer := manipulators.NewComposer(afero.NewOsFs())
 
 	// here we add all available parsers (more to add yaml etc...)
 	composer.AddConfigParser(json.NewParser())
 	conf, err := composer.Load(configFilePath)
 
 	if err != nil {
-		if errors.Is(err, config.ErrDoesNotExist) {
+		if errors.Is(err, manipulators.ErrDoesNotExist) {
 			Exitf(
 				1,
 				"Project config file %s does not exist. Please initialize first\n",
@@ -73,7 +74,7 @@ func LoadProject(configFilePath []string) *Project {
 
 // ProjectExists checks if project exists
 func ProjectExists(path string) bool {
-	return config.Exists(path)
+	return manipulators.Exists(path)
 }
 
 // InitProject initializes the project
@@ -81,7 +82,7 @@ func InitProject() *Project {
 	emulatorServiceAccount := generateEmulatorServiceAccount()
 
 	return &Project{
-		composer: config.NewComposer(afero.NewOsFs()),
+		composer: manipulators.NewComposer(afero.NewOsFs()),
 		conf:     defaultConfig(emulatorServiceAccount),
 		accounts: []*Account{emulatorServiceAccount},
 	}
@@ -131,7 +132,7 @@ func generateEmulatorServiceAccount() *Account {
 	}
 }
 
-func newProject(conf *config.Config, composer *config.Composer) (*Project, error) {
+func newProject(conf *config.Config, composer *manipulators.Composer) (*Project, error) {
 	accounts, err := accountsFromConfig(conf)
 	if err != nil {
 		return nil, err


Test patch:
diff --git a/flow/project/cli/config/composer_test.go b/flow/project/cli/config/composer_test.go
deleted file mode 100644
index e05cc0a3..00000000
--- a/flow/project/cli/config/composer_test.go
+++ /dev/null
@@ -1,106 +0,0 @@
-/*
-* Flow CLI
-*
-* Copyright 2019-2021 Dapper Labs, Inc.
-*
-* Licensed under the Apache License, Version 2.0 (the "License");
-* you may not use this file except in compliance with the License.
-* You may obtain a copy of the License at
-*
-*   http://www.apache.org/licenses/LICENSE-2.0
-*
-* Unless required by applicable law or agreed to in writing, software
-* distributed under the License is distributed on an "AS IS" BASIS,
-* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-* See the License for the specific language governing permissions and
-* limitations under the License.
- */
-package config
-
-import (
-	"github.com/spf13/afero"
-	"github.com/stretchr/testify/require"
-	"os"
-
-	"testing"
-
-	"github.com/stretchr/testify/assert"
-)
-
-func Test_JSONSimple(t *testing.T) {
-	b := []byte(`{
-		"emulators": {
-			"default": {
-				"port": 3569,
-				"serviceAccount": "emulator-account"
-			}
-		},
-		"contracts": {},
-		"networks": {
-			"emulator": {
-				"host": "127.0.0.1:3569",
-				"chain": "flow-emulator"
-			}
-		},
-		"accounts": {
-			"emulator-account": {
-				"address": "f8d6e0586b0a20c7",
-				"keys": "123123123123123123123123123123",
-				"chain": "flow-emulator"
-			}
-		},
-		"deploys": {}
-	}`)
-
-	mockFS := afero.NewMemMapFs()
-	err := afero.WriteFile(mockFS, "test2-flow.json", b, 0644)
-
-	require.NoError(t, err)
-
-	composer := NewComposer(mockFS)
-	conf, loadErr := composer.Load([]string{"test2-flow.json"})
-
-	require.NoError(t, loadErr)
-	assert.Equal(t, 1, len(conf.Accounts))
-	assert.Equal(t, "21c5dfdeb0ff03a7a73ef39788563b62c89adea67bbb21ab95e5f710bd1d40b7", conf.Accounts[0].Keys[0].Context["privateKey"])
-}
-
-func Test_JSONEnv(t *testing.T) {
-	b := []byte(`{
-		"emulators": {
-			"default": {
-				"port": 3569,
-				"serviceAccount": "emulator-account"
-			}
-		},
-		"contracts": {},
-		"networks": {
-			"emulator": {
-				"host": "127.0.0.1:3569",
-				"chain": "flow-emulator"
-			}
-		},
-		"accounts": {
-			"emulator-account": {
-				"address": "f8d6e0586b0a20c7",
-				"keys": "${env:EMULATOR_KEY}",
-				"chain": "flow-emulator"
-			}
-		},
-		"deploys": {}
-	}`)
-
-	os.Setenv("EMULATOR_KEY", "21c5dfdeb0ff03a7a73ef39788563b62c89adea67bbb21ab95e5f710bd1d40b7")
-
-	mockFS := afero.NewMemMapFs()
-	err := afero.WriteFile(mockFS, "test2-flow.json", b, 0644)
-
-	require.NoError(t, err)
-
-	composer := NewComposer(mockFS)
-	conf, loadErr := composer.Load([]string{"test2-flow.json"})
-
-	require.NoError(t, loadErr)
-	assert.Equal(t, 1, len(conf.Accounts))
-	assert.Equal(t, "21c5dfdeb0ff03a7a73ef39788563b62c89adea67bbb21ab95e5f710bd1d40b7", conf.Accounts[0].Keys[0].Context["privateKey"])
-}
diff --git a/flow/project/cli/config/json/deploy_test.go b/flow/project/cli/config/json/deploy_test.go
index 32ceb6d5..b83f3c9a 100644
--- a/flow/project/cli/config/json/deploy_test.go
+++ b/flow/project/cli/config/json/deploy_test.go
@@ -47,8 +47,8 @@ func Test_ConfigDeploymentsSimple(t *testing.T) {
 	assert.Equal(t, []string{"FungibleToken", "NonFungibleToken", "Kibble", "KittyItems"}, deployments.GetByNetwork("testnet")[0].Contracts)
 
 	assert.Equal(t, 2, len(deployments.GetByNetwork("emulator")))
-	assert.Equal(t, "account-3", deployments.GetByNetwork("emulator")[0].Account)
-	assert.Equal(t, "account-4", deployments.GetByNetwork("emulator")[1].Account)
+	assert.Equal(t, "account-3", deployments.GetByAccountAndNetwork("account-3", "emulator")[0].Account)
+	assert.Equal(t, "account-4", deployments.GetByAccountAndNetwork("account-4", "emulator")[0].Account)
 	assert.Equal(t, []string{"KittyItems", "KittyItemsMarket"}, deployments.GetByNetwork("emulator")[0].Contracts)
 	assert.Equal(t, []string{"FungibleToken", "NonFungibleToken", "Kibble", "KittyItems", "KittyItemsMarket"}, deployments.GetByNetwork("emulator")[1].Contracts)
 }
diff --git a/flow/project/cli/config/manipulators/composer_test.go b/flow/project/cli/config/manipulators/composer_test.go
new file mode 100644
index 00000000..4be85830
--- /dev/null
+++ b/flow/project/cli/config/manipulators/composer_test.go
@@ -0,0 +1,280 @@
+/*
+* Flow CLI
+*
+* Copyright 2019-2021 Dapper Labs, Inc.
+*
+* Licensed under the Apache License, Version 2.0 (the "License");
+* you may not use this file except in compliance with the License.
+* You may obtain a copy of the License at
+*
+*   http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing, software
+* distributed under the License is distributed on an "AS IS" BASIS,
+* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+* See the License for the specific language governing permissions and
+* limitations under the License.
+ */
+package manipulators
+
+import (
+	"github.com/onflow/flow-cli/flow/project/cli/config/json"
+	"github.com/spf13/afero"
+	"github.com/stretchr/testify/require"
+	"os"
+
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func Test_JSONSimple(t *testing.T) {
+	b := []byte(`{
+		"emulators": {
+			"default": {
+				"port": 3569,
+				"serviceAccount": "emulator-account"
+			}
+		},
+		"contracts": {},
+		"networks": {
+			"emulator": {
+				"host": "127.0.0.1:3569",
+				"chain": "flow-emulator"
+			}
+		},
+		"accounts": {
+			"emulator-account": {
+				"address": "f8d6e0586b0a20c7",
+				"keys": "21c5dfdeb0ff03a7a73ef39788563b62c89adea67bbb21ab95e5f710bd1d40b7",
+				"chain": "flow-emulator"
+			}
+		},
+		"deployments": {}
+	}`)
+
+	mockFS := afero.NewMemMapFs()
+	err := afero.WriteFile(mockFS, "test2-flow.json", b, 0644)
+
+	require.NoError(t, err)
+
+	composer := NewComposer(mockFS)
+	composer.AddConfigParser(json.NewParser())
+	conf, loadErr := composer.Load([]string{"test2-flow.json"})
+
+	require.NoError(t, loadErr)
+	assert.Equal(t, 1, len(conf.Accounts))
+	assert.Equal(t, "21c5dfdeb0ff03a7a73ef39788563b62c89adea67bbb21ab95e5f710bd1d40b7", conf.Accounts[0].Keys[0].Context["privateKey"])
+}
+
+func Test_ComposeJSON(t *testing.T) {
+	b := []byte(`{
+		"accounts": {
+			"emulator-account": {
+				"address": "f8d6e0586b0a20c7",
+				"keys": "21c5dfdeb0ff03a7a73ef39788563b62c89adea67bbb21ab95e5f710bd1d40b7"
+			}
+		}
+	}`)
+
+	b2 := []byte(`{
+		 "accounts":{
+				"admin-account":{
+					 "address":"f1d6e0586b0a20c7",
+					 "keys":"3335dfdeb0ff03a7a73ef39788563b62c89adea67bbb21ab95e5f710bd1d40b7"
+				}
+		 }
+	}`)
+
+	mockFS := afero.NewMemMapFs()
+	err := afero.WriteFile(mockFS, "flow.json", b, 0644)
+	err2 := afero.WriteFile(mockFS, "flow-testnet.json", b2, 0644)
+
+	require.NoError(t, err)
+	require.NoError(t, err2)
+
+	composer := NewComposer(mockFS)
+	composer.AddConfigParser(json.NewParser())
+
+	conf, loadErr := composer.Load([]string{"flow.json", "flow-testnet.json"})
+
+	require.NoError(t, loadErr)
+	assert.Equal(t, 2, len(conf.Accounts))
+	assert.Equal(t, "21c5dfdeb0ff03a7a73ef39788563b62c89adea67bbb21ab95e5f710bd1d40b7",
+		conf.Accounts.GetByName("emulator-account").Keys[0].Context["privateKey"],
+	)
+	assert.NotNil(t, conf.Accounts.GetByName("admin-account"))
+	assert.Equal(t, "3335dfdeb0ff03a7a73ef39788563b62c89adea67bbb21ab95e5f710bd1d40b7",
+		conf.Accounts.GetByName("admin-account").Keys[0].Context["privateKey"],
+	)
+}
+
+func Test_ComposeJSONOverwrite(t *testing.T) {
+	b := []byte(`{
+		"accounts": {
+			"admin-account": {
+				"address": "f8d6e0586b0a20c7",
+				"keys": "21c5dfdeb0ff03a7a73ef39788563b62c89adea67bbb21ab95e5f710bd1d40b7"
+			}
+		}
+	}`)
+
+	b2 := []byte(`{
+		 "accounts":{
+				"admin-account":{
+					 "address":"f1d6e0586b0a20c7",
+					 "keys":"3335dfdeb0ff03a7a73ef39788563b62c89adea67bbb21ab95e5f710bd1d40b7"
+				}
+		 }
+	}`)
+
+	mockFS := afero.NewMemMapFs()
+	err := afero.WriteFile(mockFS, "flow.json", b, 0644)
+	err2 := afero.WriteFile(mockFS, "flow-testnet.json", b2, 0644)
+
+	require.NoError(t, err)
+	require.NoError(t, err2)
+
+	composer := NewComposer(mockFS)
+	composer.AddConfigParser(json.NewParser())
+
+	conf, loadErr := composer.Load([]string{"flow.json", "flow-testnet.json"})
+
+	require.NoError(t, loadErr)
+	assert.Equal(t, 1, len(conf.Accounts))
+	assert.NotNil(t, conf.Accounts.GetByName("admin-account"))
+	assert.Equal(t, "3335dfdeb0ff03a7a73ef39788563b62c89adea67bbb21ab95e5f710bd1d40b7",
+		conf.Accounts.GetByName("admin-account").Keys[0].Context["privateKey"],
+	)
+}
+
+func Test_FromFileAccountSimple(t *testing.T) {
+	b := []byte(`{
+		"accounts": {
+			"service-account": {
+				"address": "f8d6e0586b0a20c7",
+				"keys": "21c5dfdeb0ff03a7a73ef39788563b62c89adea67bbb21ab95e5f710bd1d40b7"
+			},
+			"admin-account": { "fromFile": "private.json" }
+		}
+	}`)
+
+	b2 := []byte(`{
+		 "accounts":{
+				"admin-account":{
+					 "address":"f1d6e0586b0a20c7",
+					 "keys":"3335dfdeb0ff03a7a73ef39788563b62c89adea67bbb21ab95e5f710bd1d40b7"
+				}
+		 }
+	}`)
+
+	mockFS := afero.NewMemMapFs()
+	err := afero.WriteFile(mockFS, "flow.json", b, 0644)
+	err2 := afero.WriteFile(mockFS, "private.json", b2, 0644)
+
+	require.NoError(t, err)
+	require.NoError(t, err2)
+
+	composer := NewComposer(mockFS)
+	composer.AddConfigParser(json.NewParser())
+
+	conf, loadErr := composer.Load([]string{"flow.json", "private.json"})
+
+	require.NoError(t, loadErr)
+	assert.Equal(t, 2, len(conf.Accounts))
+	assert.NotNil(t, conf.Accounts.GetByName("admin-account"))
+	assert.Equal(t, conf.Accounts.GetByName("admin-account").Address.String(), "f1d6e0586b0a20c7")
+	assert.Equal(t, conf.Accounts.GetByName("service-account").Address.String(), "f8d6e0586b0a20c7")
+}
+
+func Test_FromFileAccountComplex(t *testing.T) {
+	b := []byte(`{
+		"accounts": {
+			"service-account": {
+				"address": "f8d6e0586b0a20c7",
+				"keys": "21c5dfdeb0ff03a7a73ef39788563b62c89adea67bbb21ab95e5f710bd1d40b7"
+			},
+			"admin-account-1": { "fromFile": "private.json" },
+			"admin-account-3": { "fromFile": "private.json" },
+			"admin-account-5": { "fromFile": "private.testnet.json" }
+		}
+	}`)
+
+	b2 := []byte(`{
+		 "accounts":{
+				"admin-account-1":{
+					 "address":"f1d6e0586b0a20c7",
+					 "keys":"3335dfdeb0ff03a7a73ef39788563b62c89adea67bbb21ab95e5f710bd1d40b7"
+				},
+				"admin-account-2":{
+					 "address":"f2d6e0586b0a20c7",
+					 "keys":"3335dfdeb0ff03a7a73ef39788563b62c89adea67bbb21ab95e5f710bd1d40b7"
+				},
+				"admin-account-3":{
+					 "address":"f3d6e0586b0a20c7",
+					 "keys":"3335dfdeb0ff03a7a73ef39788563b62c89adea67bbb21ab95e5f710bd1d40b7"
+				}
+		 }
+	}`)
+
+	b3 := []byte(`{
+		 "accounts":{
+				"admin-account-5":{
+					 "address":"f5d6e0586b0a20c7",
+					 "keys":"3335dfdeb0ff03a7a73ef39788563b62c89adea67bbb21ab95e5f710bd1d40b7"
+				}
+		 }
+	}`)
+
+	mockFS := afero.NewMemMapFs()
+	err := afero.WriteFile(mockFS, "flow.json", b, 0644)
+	err2 := afero.WriteFile(mockFS, "private.json", b2, 0644)
+	err3 := afero.WriteFile(mockFS, "private.testnet.json", b3, 0644)
+
+	require.NoError(t, err)
+	require.NoError(t, err2)
+	require.NoError(t, err3)
+
+	composer := NewComposer(mockFS)
+	composer.AddConfigParser(json.NewParser())
+
+	conf, loadErr := composer.Load([]string{"flow.json"})
+
+	require.NoError(t, loadErr)
+	assert.Equal(t, 4, len(conf.Accounts))
+	assert.NotNil(t, conf.Accounts.GetByName("service-account"))
+	assert.NotNil(t, conf.Accounts.GetByName("admin-account-1"))
+	assert.NotNil(t, conf.Accounts.GetByName("admin-account-3"))
+	assert.NotNil(t, conf.Accounts.GetByName("admin-account-5"))
+	assert.Equal(t, conf.Accounts.GetByName("service-account").Address.String(), "f8d6e0586b0a20c7")
+	assert.Equal(t, conf.Accounts.GetByName("admin-account-1").Address.String(), "f1d6e0586b0a20c7")
+	assert.Equal(t, conf.Accounts.GetByName("admin-account-3").Address.String(), "f3d6e0586b0a20c7")
+	assert.Equal(t, conf.Accounts.GetByName("admin-account-5").Address.String(), "f5d6e0586b0a20c7")
+}
+
+func Test_JSONEnv(t *testing.T) {
+	b := []byte(`{
+		"accounts": {
+			"emulator-account": {
+				"address": "f8d6e0586b0a20c7",
+				"keys": "${env:EMULATOR_KEY}",
+				"chain": "flow-emulator"
+			}
+		}
+	}`)
+
+	os.Setenv("EMULATOR_KEY", "21c5dfdeb0ff03a7a73ef39788563b62c89adea67bbb21ab95e5f710bd1d40b7")
+
+	mockFS := afero.NewMemMapFs()
+	err := afero.WriteFile(mockFS, "test2-flow.json", b, 0644)
+
+	require.NoError(t, err)
+
+	composer := NewComposer(mockFS)
+	composer.AddConfigParser(json.NewParser())
+	conf, loadErr := composer.Load([]string{"test2-flow.json"})
+
+	require.NoError(t, loadErr)
+	assert.Equal(t, 1, len(conf.Accounts))
+	assert.Equal(t, "21c5dfdeb0ff03a7a73ef39788563b62c89adea67bbb21ab95e5f710bd1d40b7", conf.Accounts[0].Keys[0].Context["privateKey"])
+}
diff --git a/flow/project/cli/config/processor_test.go b/flow/project/cli/config/manipulators/processor_test.go
similarity index 54%
rename from flow/project/cli/config/processor_test.go
rename to flow/project/cli/config/manipulators/processor_test.go
index 21fa60f7..f82249db 100644
--- a/flow/project/cli/config/processor_test.go
+++ b/flow/project/cli/config/manipulators/processor_test.go
@@ -16,7 +16,7 @@
 * limitations under the License.
  */
 
-package config
+package manipulators
 
 import (
 	"os"
@@ -24,7 +24,6 @@ import (
 
 	"github.com/spf13/afero"
 	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/require"
 )
 
 func Test_PrivateKeyEnv(t *testing.T) {
@@ -40,7 +39,7 @@ func Test_PrivateKeyEnv(t *testing.T) {
 		}
 	}`)
 
-	preprocessor := NewPreprocessor(new(afero.MemMapFs))
+	preprocessor := NewPreprocessor(NewComposer(afero.NewMemMapFs()))
 	result := preprocessor.Run(test)
 
 	assert.JSONEq(t, `{
@@ -68,7 +67,7 @@ func Test_PrivateKeyEnvMultipleAccounts(t *testing.T) {
 		}
 	}`)
 
-	preprocessor := NewPreprocessor(new(afero.MemMapFs))
+	preprocessor := NewPreprocessor(NewComposer(afero.NewMemMapFs()))
 	result := preprocessor.Run(test)
 
 	assert.JSONEq(t, `{
@@ -90,66 +89,16 @@ func Test_PrivateConfigFileAccounts(t *testing.T) {
 				"keys": "11c5dfdeb0ff03a7a73ef39788563b62c89adea67bbb21ab95e5f710bd1d40b7",
 				"chain": "flow-emulator"
 			},
-			"admin-account": "${file:test.flow.json}"
-		}
-	}`)
-
-	f := []byte(`{
-		"address": "f669cb8d41ce0c74",
-		"keys": "17a616e230d38c04fb887dd83283a45f9a3082579db512c96eb84c5c562ac054",
-		"chain": "flow-emulator"  
-	}`)
-
-	mockFS := afero.NewMemMapFs()
-	err := afero.WriteFile(mockFS, "test.flow.json", f, 0644)
-
-	require.NoError(t, err)
-
-	preprocessor := NewPreprocessor(mockFS)
-	result := preprocessor.Run(b)
+			"admin-account": { "fromFile": "test.json" },
+			"admin-account": { "fromFile": "test.json" },
 
-	assert.JSONEq(t, `{
-			"accounts": {
-				"emulator-account": {
-					"address": "f8d6e0586b0a20c7",
-					"keys": "11c5dfdeb0ff03a7a73ef39788563b62c89adea67bbb21ab95e5f710bd1d40b7",
-					"chain": "flow-emulator"
-				}, 
-				"admin-account": {
-					"address": "f669cb8d41ce0c74",
-					"keys": "17a616e230d38c04fb887dd83283a45f9a3082579db512c96eb84c5c562ac054",
-					"chain": "flow-emulator" 
-				}
+			"admin-account":{ 
+				"fromFile": "test.json" 
 			}
-		}`, string(result))
-}
-
-func Test_PrivateConfigFileAndEnvAccounts(t *testing.T) {
-	b := []byte(`{
-		"accounts": {
-			"emulator-account": {
-				"address": "f8d6e0586b0a20c7",
-				"keys": "11c5dfdeb0ff03a7a73ef39788563b62c89adea67bbb21ab95e5f710bd1d40b7",
-				"chain": "flow-emulator"
-			},
-			"admin-account": "${env:ADMIN_FILE}"
 		}
 	}`)
 
-	f := []byte(`{
-		"address": "f669cb8d41ce0c74",
-		"keys": "17a616e230d38c04fb887dd83283a45f9a3082579db512c96eb84c5c562ac054",
-		"chain": "flow-emulator"  
-	}`)
-
-	os.Setenv("ADMIN_FILE", "${file:test.flow.json}")
-
-	mockFS := afero.NewMemMapFs()
-	err := afero.WriteFile(mockFS, "test.flow.json", f, 0644)
-
-	require.NoError(t, err)
-
-	preprocessor := NewPreprocessor(mockFS)
+	preprocessor := NewPreprocessor(NewComposer(afero.NewMemMapFs()))
 	result := preprocessor.Run(b)
 
 	assert.JSONEq(t, `{
@@ -158,11 +107,6 @@ func Test_PrivateConfigFileAndEnvAccounts(t *testing.T) {
 					"address": "f8d6e0586b0a20c7",
 					"keys": "11c5dfdeb0ff03a7a73ef39788563b62c89adea67bbb21ab95e5f710bd1d40b7",
 					"chain": "flow-emulator"
-				}, 
-				"admin-account": {
-					"address": "f669cb8d41ce0c74",
-					"keys": "17a616e230d38c04fb887dd83283a45f9a3082579db512c96eb84c5c562ac054",
-					"chain": "flow-emulator" 
 				}
 			}
 		}`, string(result))
diff --git a/flow/project/cli/project_test.go b/flow/project/cli/project_test.go
index acbe18d1..e793a6f6 100644
--- a/flow/project/cli/project_test.go
+++ b/flow/project/cli/project_test.go
@@ -20,6 +20,7 @@ package cli
 
 import (
 	"fmt"
+	"github.com/onflow/flow-cli/flow/project/cli/config/manipulators"
 	"github.com/spf13/afero"
 	"sort"
 	"testing"
@@ -31,7 +32,7 @@ import (
 	"github.com/thoas/go-funk"
 )
 
-var composer = config.NewComposer(afero.NewOsFs())
+var composer = manipulators.NewComposer(afero.NewOsFs())
 
 func generateComplexProject() Project {
 	config := config.Config{