Repository: jetpack-io/devbox
Pull request #1850 (2024-02-27 05:38:34)

[plugins] Implement plugins v2
## Summary

Apologies in advance for the size of this PR. Was not really sure how to split it up, since it's a fairly large step change.

This PR makes 2 major changes:

* Change how plugin fields are processed. Instead of being processed independently in several parts of the code (`Env`, `Scripts`, etc) they are now combined inside of `devconfig` in a simple, predictable way. 
* Plugins are now allowed to have all fields that devbox.json have. In fact the plugin struct now embeds a ConfigFile.

All fields from included plugins are added to the environment. The merge algorithm is as follows:

* includes are evaluated in order first. Base config is evaluated last.
* Slices are concatenated (init hooks, etc)
* maps are merged with later evaluation replacing previous one on conflict.
* Packages are deduped by name and concatenated. (i.e. multiple versions not allowed)

**Known issues/limitations:**

* Github plugins will be slow. When using remote imports, every devbox command does 1 or more HTTP requests to load the imports.
* central lock file. All dependencies are stored there. 
* While recursive includes are allowed, relative paths are not handled correctly. That means if project A includes plugin 1 and plugin 1 includes a relative local plugin, it will not work. Including github plugins recursively works as expected (but will be slow).
* Cycle detection is not implemented. A user can cause infinite include.
* Plugins are json only. They don't support hujson. (This is inline with current plugins, but we should build support to match devbox.json)

**Additional requires testing:**

- [x] Test create_files works as expected (for both local and github)
- [x] Test process compose works as expected
- [x] Test global push pull (config changes may have affected that)
- [x] Ensure that we are able to `add` a package that is already added by plugin.


Follow up cleanup:

* Remove tyson support (this will simplify config code)
* Plugin manager can be removed or greatly reduced.
* Fix some of the limitations (relative paths, cycle detection)
* Cache included remote plugins for performance (and to avoid github limits). 

## How was it tested?

* New example tests
* Manual testing
* Existing tests

savil: From reading the summary, a couple of callouts:

 **Design of github plugins**
> "Github plugins will be slow. When using remote imports, every devbox command does 1 or more HTTP requests to load the imports."
"Cache included remote plugins for performance (and to avoid github limits).". 

Two concerns:
1. Reproducibility: if we are pulling from Github every time, then every change to that Github repo will be instantly reflected. Instead, could we lock the current version in time in the lockfile (perhaps resolve to a git commit)? Then `devbox update` can fetch the very latest version.
2. Denial of service: due to the github limits being hit, especially for folks using Devbox in CICD. As a mitigation, we can add support for GITHUB_TOKEN that has higher limits. In CICD, users should be recommended to set that as well.

**Plugins evaluation order**
> "includes are evaluated in order first. Base config is evaluated last."

This makes sense. How are we handling plugins that are applied on packages added in the base-config? For example, a Rust plugin that must be applied to the version of Rust included in the base config.
mikeland73: > is there a way to disable a builtin plugin by having an includable plugin supersede it? For example, the builtin python plugin may be doing something a user feels is undesirable and would like to either disable it completely, or override it with their own python plugin

Currently you have to use `--disable-plugin` when adding a package and then add your own plugin. It's not perfect, but seems OK since this is a bit of an edge case.

> A concern with this design is that scripts must be fully embedded in the plugin's devbox.json file, rather than moved into their own my_script.sh file where they can be properly handled by tooling (e.g. syntax-hightlighting, linting, etc.). Enabling that scenario would be a big win IMO.

> One idea for a follow up PR is to add special-case support for detecting script files in plugin-configs, and downloading them as well.

This use case is doable, but requires a bit more work from the user.

If you want script files they must be copied over using `create_files` field. Copying over all files is a bit tricky because we need to figure out the file structure of the repo (we can do this with `git` or maybe `github` has an API, not sure. Either way, a decent amount of more work).

mikeland73: @savil made requested changes (here and in https://github.com/jetpack-io/devbox/pull/1867). PTAL

Patch:
diff --git a/devbox.json b/devbox.json
index 4aa9ffea..cebe8837 100644
--- a/devbox.json
+++ b/devbox.json
@@ -32,13 +32,13 @@
         "devbox run build-linux-arm64",
       ],
       // Open VSCode
-      "code":            "code .",
-      "lint":            "golangci-lint run --timeout 5m && scripts/gofumpt.sh",
-      "fmt":             "scripts/gofumpt.sh",
-      "test":            "go test -race -cover ./...",
+      "code":               "code .",
+      "lint":               "golangci-lint run --timeout 5m && scripts/gofumpt.sh",
+      "fmt":                "scripts/gofumpt.sh",
+      "test":               "go test -race -cover ./...",
       "test-projects-only": "DEVBOX_RUN_PROJECT_TESTS=1 go test -v -timeout ${DEVBOX_GOLANG_TEST_TIMEOUT:-30m} ./... -run \"TestExamples|TestScriptsWithProjects\"",
-      "update-examples": "devbox run build && go run testscripts/testrunner/updater/main.go",
-      "tidy":            "go mod tidy",
+      "update-examples":    "devbox run build && go run testscripts/testrunner/updater/main.go",
+      "tidy":               "go mod tidy",
     },
   },
 }
diff --git a/examples/plugins/builtin/devbox.d/php8/php-fpm.conf b/examples/plugins/builtin/devbox.d/php82/php-fpm.conf
similarity index 100%
rename from examples/plugins/builtin/devbox.d/php8/php-fpm.conf
rename to examples/plugins/builtin/devbox.d/php82/php-fpm.conf
diff --git a/examples/plugins/builtin/devbox.d/php8/php.ini b/examples/plugins/builtin/devbox.d/php82/php.ini
similarity index 100%
rename from examples/plugins/builtin/devbox.d/php8/php.ini
rename to examples/plugins/builtin/devbox.d/php82/php.ini
diff --git a/examples/plugins/builtin/devbox.json b/examples/plugins/builtin/devbox.json
index 49968600..5718936f 100644
--- a/examples/plugins/builtin/devbox.json
+++ b/examples/plugins/builtin/devbox.json
@@ -5,12 +5,13 @@
       "echo 'Welcome to devbox!' > /dev/null"
     ],
     "scripts": {
-      "test": [
+      "run_test": [
         "test -n \"$PHPRC\" || exit 1"
       ]
     }
   },
   "include": [
-    "plugin:php8"
+    // Installs the php plugin using php82 as trigger package
+    "plugin:php82"
   ]
 }
diff --git a/examples/plugins/local/devbox.json b/examples/plugins/local/devbox.json
index 0b1987e2..b537e30e 100644
--- a/examples/plugins/local/devbox.json
+++ b/examples/plugins/local/devbox.json
@@ -11,6 +11,6 @@
     }
   },
   "include": [
-    "path:my-plugin/my-plugin.json"
+    "path:my-plugin/plugin.json"
   ]
 }
diff --git a/examples/plugins/local/my-plugin/my-plugin.json b/examples/plugins/local/my-plugin/plugin.json
similarity index 100%
rename from examples/plugins/local/my-plugin/my-plugin.json
rename to examples/plugins/local/my-plugin/plugin.json
diff --git a/examples/plugins/v2-github/devbox.d/jetpack-io-devbox-plugin-example/some-file.txt b/examples/plugins/v2-github/devbox.d/jetpack-io-devbox-plugin-example/some-file.txt
new file mode 100644
index 00000000..42686328
--- /dev/null
+++ b/examples/plugins/v2-github/devbox.d/jetpack-io-devbox-plugin-example/some-file.txt
@@ -0,0 +1,1 @@
+some data
diff --git a/examples/plugins/v2-github/devbox.json b/examples/plugins/v2-github/devbox.json
new file mode 100644
index 00000000..c98ca2fd
--- /dev/null
+++ b/examples/plugins/v2-github/devbox.json
@@ -0,0 +1,18 @@
+{
+  "packages": [],
+  "shell": {
+    "init_hook": [
+      "echo 'Welcome to devbox!' > /dev/null"
+    ],
+    "scripts": {
+      "run_test": [
+        "./test.sh"
+      ]
+    }
+  },
+  "include": [
+    // TODO, make these more interesting by adding v2 capabilities
+    "github:jetpack-io/devbox-plugin-example",
+    "github:jetpack-io/devbox-plugin-example?dir=custom-dir"
+  ]
+}
diff --git a/examples/plugins/v2-github/devbox.lock b/examples/plugins/v2-github/devbox.lock
new file mode 100644
index 00000000..4206f37d
--- /dev/null
+++ b/examples/plugins/v2-github/devbox.lock
@@ -0,0 +1,4 @@
+{
+  "lockfile_version": "1",
+  "packages": {}
+}
diff --git a/examples/plugins/v2-local/devbox.d/plugin1/foo.txt b/examples/plugins/v2-local/devbox.d/plugin1/foo.txt
new file mode 100644
index 00000000..deba01fc
--- /dev/null
+++ b/examples/plugins/v2-local/devbox.d/plugin1/foo.txt
@@ -0,0 +1,1 @@
+something
diff --git a/examples/plugins/v2-local/devbox.json b/examples/plugins/v2-local/devbox.json
new file mode 100644
index 00000000..7d997158
--- /dev/null
+++ b/examples/plugins/v2-local/devbox.json
@@ -0,0 +1,26 @@
+{
+  "$schema": "https://raw.githubusercontent.com/jetpack-io/devbox/main/.schema/devbox.schema.json",
+  "packages": [],
+  "env": {
+    "PLUGIN1_ENV2": "override"
+  },
+  "shell": {
+    "init_hook": [
+      "echo 'Welcome to devbox!' > /dev/null"
+    ],
+    "scripts": {
+      "run_test": [
+        "test -n \"$PLUGIN1_INIT_HOOK\" || exit 1",
+        "test -n \"$PLUGIN1_ENV\" || exit 1",
+        "if [ \"$PLUGIN1_ENV2\" != \"override\" ]; then exit 1; fi;",
+        "devbox run plugin_1_script",
+        "hello"
+      ]
+    }
+  },
+  "include": [
+    "./plugin1",
+    "path:plugin2",
+    "./plugin3/plugin.json",
+  ]
+}
diff --git a/examples/plugins/v2-local/devbox.lock b/examples/plugins/v2-local/devbox.lock
new file mode 100644
index 00000000..ba83c225
--- /dev/null
+++ b/examples/plugins/v2-local/devbox.lock
@@ -0,0 +1,49 @@
+{
+  "lockfile_version": "1",
+  "packages": {
+    "hello@latest": {
+      "last_modified": "2024-02-24T23:06:34Z",
+      "resolved": "github:NixOS/nixpkgs/9a9dae8f6319600fa9aebde37f340975cab4b8c0#hello",
+      "source": "devbox-search",
+      "version": "2.12.1",
+      "systems": {
+        "aarch64-darwin": {
+          "outputs": [
+            {
+              "name": "out",
+              "path": "/nix/store/0dqk50ap0jsh0wz4y6n6kqfl8ynhz84f-hello-2.12.1",
+              "default": true
+            }
+          ]
+        },
+        "aarch64-linux": {
+          "outputs": [
+            {
+              "name": "out",
+              "path": "/nix/store/218n5svnni1y5nm0iq0nir3jpz8yxbsb-hello-2.12.1",
+              "default": true
+            }
+          ]
+        },
+        "x86_64-darwin": {
+          "outputs": [
+            {
+              "name": "out",
+              "path": "/nix/store/v1mbicfpyp9mnps4i8iccfzas53if4p6-hello-2.12.1",
+              "default": true
+            }
+          ]
+        },
+        "x86_64-linux": {
+          "outputs": [
+            {
+              "name": "out",
+              "path": "/nix/store/ki1s92r0524hj416rh20v0msxmi4pjvn-hello-2.12.1",
+              "default": true
+            }
+          ]
+        }
+      }
+    }
+  }
+}
diff --git a/examples/plugins/v2-local/plugin1/foo.txt b/examples/plugins/v2-local/plugin1/foo.txt
new file mode 100644
index 00000000..deba01fc
--- /dev/null
+++ b/examples/plugins/v2-local/plugin1/foo.txt
@@ -0,0 +1,1 @@
+something
diff --git a/examples/plugins/v2-local/plugin1/plugin.json b/examples/plugins/v2-local/plugin1/plugin.json
new file mode 100644
index 00000000..f3ffcf34
--- /dev/null
+++ b/examples/plugins/v2-local/plugin1/plugin.json
@@ -0,0 +1,22 @@
+{
+  "name": "plugin1",
+  "packages": ["hello@latest"],
+  "env": {
+    "PLUGIN1_ENV": "success",
+    "PLUGIN1_ENV2": "success"
+  },
+  "shell": {
+    "init_hook": [
+      "export PLUGIN1_INIT_HOOK=success"
+    ],
+    "scripts": {
+      "plugin_1_script": [
+        "echo success"
+      ]
+    }
+  },
+  "create_files": {
+    "{{ .DevboxDir }}/foo.txt": "foo.txt",
+    "{{ .Virtenv }}/process-compose.yaml": "process-compose.yaml"
+  }
+}
diff --git a/examples/plugins/v2-local/plugin1/process-compose.yaml b/examples/plugins/v2-local/plugin1/process-compose.yaml
new file mode 100644
index 00000000..35f321ef
--- /dev/null
+++ b/examples/plugins/v2-local/plugin1/process-compose.yaml
@@ -0,0 +1,8 @@
+version: "0.5"
+
+processes:
+  plugin1-service:
+    command: "echo 'Hello from plugin1' && sleep 1000"
+    availability:
+      restart: "always"
+  
diff --git a/examples/plugins/v2-local/plugin2/plugin.json b/examples/plugins/v2-local/plugin2/plugin.json
new file mode 100644
index 00000000..5f8023f2
--- /dev/null
+++ b/examples/plugins/v2-local/plugin2/plugin.json
@@ -0,0 +1,6 @@
+{
+  "name": "plugin2",
+  "create_files": {
+    "{{ .Virtenv }}/process-compose.yaml": "process-compose.yaml"
+  }
+}
diff --git a/examples/plugins/v2-local/plugin2/process-compose.yaml b/examples/plugins/v2-local/plugin2/process-compose.yaml
new file mode 100644
index 00000000..b819ca12
--- /dev/null
+++ b/examples/plugins/v2-local/plugin2/process-compose.yaml
@@ -0,0 +1,8 @@
+version: "0.5"
+
+processes:
+  plugin2-service:
+    command: "echo 'Hello from plugin2' && sleep 1000"
+    availability:
+      restart: "always"
+  
diff --git a/examples/plugins/v2-local/plugin3/plugin.json b/examples/plugins/v2-local/plugin3/plugin.json
new file mode 100644
index 00000000..7fe5865d
--- /dev/null
+++ b/examples/plugins/v2-local/plugin3/plugin.json
@@ -0,0 +1,3 @@
+{
+  "name": "plugin3"
+}
diff --git a/internal/boxcli/multi/multi.go b/internal/boxcli/multi/multi.go
index 8af9a185..0d004edc 100644
--- a/internal/boxcli/multi/multi.go
+++ b/internal/boxcli/multi/multi.go
@@ -7,7 +7,7 @@
 	"go.jetpack.io/devbox/internal/debug"
 	"go.jetpack.io/devbox/internal/devbox"
 	"go.jetpack.io/devbox/internal/devbox/devopt"
-	"go.jetpack.io/devbox/internal/devconfig"
+	"go.jetpack.io/devbox/internal/devconfig/configfile"
 )
 
 func Open(opts *devopt.Opts) ([]*devbox.Devbox, error) {
@@ -21,7 +21,7 @@ func(path string, dirEntry fs.DirEntry, err error) error {
 				return err
 			}
 
-			if !dirEntry.IsDir() && devconfig.IsConfigName(filepath.Base(path)) {
+			if !dirEntry.IsDir() && configfile.IsConfigName(filepath.Base(path)) {
 				optsCopy := *opts
 				optsCopy.Dir = path
 				box, err := devbox.Open(&optsCopy)
diff --git a/internal/cachehash/hash.go b/internal/cachehash/hash.go
index 38be290b..0e470ebe 100644
--- a/internal/cachehash/hash.go
+++ b/internal/cachehash/hash.go
@@ -19,6 +19,7 @@
 )
 
 // Bytes returns a hex-encoded hash of b.
+// TODO: This doesn't need to return an error.
 func Bytes(b []byte) (string, error) {
 	h := newHash()
 	h.Write(b)
diff --git a/internal/debug/debug.go b/internal/debug/debug.go
index cc42fdb8..78d4edc2 100644
--- a/internal/debug/debug.go
+++ b/internal/debug/debug.go
@@ -51,10 +51,10 @@ func Recover() {
 
 	sentry.CurrentHub().Recover(r)
 	if enabled {
-		log.Println("Allowing panic because debug mode is enabled.")
+		fmt.Fprintln(os.Stderr, "Allowing panic because debug mode is enabled.")
 		panic(r)
 	}
-	fmt.Println("Error:", r)
+	fmt.Fprintln(os.Stderr, "Error:", r)
 }
 
 func EarliestStackTrace(err error) error {
diff --git a/internal/devbox/devbox.go b/internal/devbox/devbox.go
index d6754430..657ea819 100644
--- a/internal/devbox/devbox.go
+++ b/internal/devbox/devbox.go
@@ -113,6 +113,11 @@ func Open(opts *devopt.Opts) (*Devbox, error) {
 	if err != nil {
 		return nil, err
 	}
+
+	if err := cfg.LoadRecursive(lock); err != nil {
+		return nil, err
+	}
+
 	// if lockfile has any allow insecure, we need to set the env var to ensure
 	// all nix commands work.
 	if err := box.moveAllowInsecureFromLockfile(box.stderr, lock, cfg); err != nil {
@@ -174,8 +179,12 @@ func (d *Devbox) ConfigHash() (string, error) {
 	for _, pkg := range d.ConfigPackages() {
 		buf.WriteString(pkg.Hash())
 	}
-	for _, inc := range d.Includes() {
-		buf.WriteString(inc.Hash())
+	for _, pluginConfig := range d.cfg.IncludedPluginConfigs() {
+		h, err := pluginConfig.Hash()
+		if err != nil {
+			return "", err
+		}
+		buf.WriteString(h)
 	}
 	return cachehash.Bytes(buf.Bytes())
 }
@@ -528,7 +537,7 @@ func (d *Devbox) saveCfg() error {
 }
 
 func (d *Devbox) Services() (services.Services, error) {
-	pluginSvcs, err := d.pluginManager.GetServices(d.InstallablePackages(), d.cfg.Include())
+	pluginSvcs, err := plugin.GetServices(d.cfg.IncludedPluginConfigs())
 	if err != nil {
 		return nil, err
 	}
@@ -901,17 +910,6 @@ func (d *Devbox) computeEnv(ctx context.Context, usePrintDevEnvCache bool) (map[
 
 	debug.Log("nix environment PATH is: %s", env)
 
-	// Add any vars defined in plugins.
-	// We still need to be able to add env variables to non-service binaries
-	// (e.g. ruby). This would involve understanding what binaries are associated
-	// to a given plugin.
-	pluginEnv, err := d.pluginManager.Env(d.InstallablePackages(), d.cfg.Include(), env)
-	if err != nil {
-		return nil, err
-	}
-
-	addEnvIfNotPreviouslySetByDevbox(env, pluginEnv)
-
 	env["PATH"] = envpath.JoinPathLists(
 		nix.ProfileBinPath(d.projectDir),
 		env["PATH"],
@@ -929,7 +927,7 @@ func (d *Devbox) computeEnv(ctx context.Context, usePrintDevEnvCache bool) (map[
 	}
 	addEnvIfNotPreviouslySetByDevbox(env, configEnv)
 
-	markEnvsAsSetByDevbox(pluginEnv, configEnv)
+	markEnvsAsSetByDevbox(configEnv)
 
 	// devboxEnvPath starts with the initial PATH from print-dev-env, and is
 	// transformed to be the "PATH of the Devbox environment"
@@ -1038,7 +1036,7 @@ func (d *Devbox) PackageNames() []string {
 // ConfigPackages returns the packages that are defined in devbox.json
 // NOTE: the return type is different from devconfig.Packages
 func (d *Devbox) ConfigPackages() []*devpkg.Package {
-	return devpkg.PackagesFromConfig(d.cfg, d.lockfile)
+	return devpkg.PackagesFromConfig(d.cfg.Packages(), d.lockfile)
 }
 
 // InstallablePackages returns the packages that are to be installed
@@ -1048,23 +1046,6 @@ func (d *Devbox) InstallablePackages() []*devpkg.Package {
 	})
 }
 
-// AllInstallablePackages returns installable user packages and plugin
-// packages concatenated in correct order
-func (d *Devbox) AllInstallablePackages() ([]*devpkg.Package, error) {
-	userPackages := d.InstallablePackages()
-	return d.PluginManager().ProcessPluginPackages(userPackages)
-}
-
-func (d *Devbox) Includes() []plugin.Includable {
-	includes := []plugin.Includable{}
-	for _, includePath := range d.cfg.Include() {
-		if include, err := d.pluginManager.ParseInclude(includePath); err == nil {
-			includes = append(includes, include)
-		}
-	}
-	return includes
-}
-
 func (d *Devbox) HasDeprecatedPackages() bool {
 	for _, pkg := range d.ConfigPackages() {
 		if pkg.IsLegacy() {
diff --git a/internal/devbox/dir.go b/internal/devbox/dir.go
index e30a063f..3dcfff63 100644
--- a/internal/devbox/dir.go
+++ b/internal/devbox/dir.go
@@ -10,7 +10,7 @@
 	"github.com/pkg/errors"
 	"go.jetpack.io/devbox/internal/boxcli/usererr"
 	"go.jetpack.io/devbox/internal/debug"
-	"go.jetpack.io/devbox/internal/devconfig"
+	"go.jetpack.io/devbox/internal/devconfig/configfile"
 	"go.jetpack.io/devbox/internal/fileutil"
 )
 
@@ -109,7 +109,7 @@ func missingConfigError(path string, didCheckParents bool) error {
 }
 
 func configExistsIn(path string) bool {
-	for _, name := range devconfig.ValidConfigNames() {
+	for _, name := range configfile.ValidConfigNames() {
 		if fileutil.Exists(filepath.Join(path, name)) {
 			return true
 		}
diff --git a/internal/devbox/packages.go b/internal/devbox/packages.go
index 1242382c..44b1ff93 100644
--- a/internal/devbox/packages.go
+++ b/internal/devbox/packages.go
@@ -386,8 +386,8 @@ func resetProfileDirForFlakes(profileDir string) (err error) {
 
 func (d *Devbox) installPackages(ctx context.Context) error {
 	// Create plugin directories first because packages might need them
-	for _, pkg := range d.InstallablePackages() {
-		if err := d.PluginManager().Create(pkg); err != nil {
+	for _, pluginConfig := range d.Config().IncludedPluginConfigs() {
+		if err := d.PluginManager().CreateFilesForConfig(pluginConfig); err != nil {
 			return err
 		}
 	}
@@ -462,11 +462,8 @@ func (d *Devbox) installNixPackagesToStore(ctx context.Context) error {
 
 func (d *Devbox) packagesToInstallInStore(ctx context.Context) ([]*devpkg.Package, error) {
 	// First, get and prepare all the packages that must be installed in this project
-	packages, err := d.AllInstallablePackages()
-	if err != nil {
-		return nil, err
-	}
-	packages = lo.Filter(packages, devpkg.IsNix) // Remove non-nix packages from the list
+	// and remove non-nix packages from the list
+	packages := lo.Filter(d.InstallablePackages(), devpkg.IsNix)
 	if err := devpkg.FillNarInfoCache(ctx, packages...); err != nil {
 		return nil, err
 	}
diff --git a/internal/devconfig/config.go b/internal/devconfig/config.go
index 1aa46e54..382bbe20 100644
--- a/internal/devconfig/config.go
+++ b/internal/devconfig/config.go
@@ -1,39 +1,195 @@
 package devconfig
 
 import (
+	"context"
+	"fmt"
+	"io"
+	"maps"
+	"net/http"
 	"os"
 
+	"github.com/pkg/errors"
+	"github.com/samber/lo"
+	"go.jetpack.io/devbox/internal/build"
+	"go.jetpack.io/devbox/internal/cachehash"
 	"go.jetpack.io/devbox/internal/devbox/shellcmd"
+	"go.jetpack.io/devbox/internal/devconfig/configfile"
+	"go.jetpack.io/devbox/internal/lock"
+	"go.jetpack.io/devbox/internal/plugin"
 )
 
-// Config represents a base devbox.json as well as any imports it may have.
-// TODO: All the functions below will be modified to include all imported configs.
+// Config represents a base devbox.json as well as any included plugins it may have.
 type Config struct {
-	Root ConfigFile
+	Root configfile.ConfigFile
 
-	// This will support imports in the future.
-	// imported []*Config
+	pluginData *plugin.PluginOnlyData // pointer by design, to allow for nil
+
+	included []*Config
+}
+
+const defaultInitHook = "echo 'Welcome to devbox!' > /dev/null"
+
+func DefaultConfig() *Config {
+	cfg, err := loadBytes([]byte(fmt.Sprintf(`{
+		"$schema": "https://raw.githubusercontent.com/jetpack-io/devbox/%s/.schema/devbox.schema.json",
+		"packages": [],
+		"shell": {
+			"init_hook": [
+				"%s"
+			],
+			"scripts": {
+				"test": [
+					"echo \"Error: no test specified\" && exit 1"
+				]
+			}
+		}
+	}
+	`,
+		lo.Ternary(build.IsDev, "main", build.Version),
+		defaultInitHook,
+	)))
+	if err != nil {
+		panic("default devbox.json is invalid: " + err.Error())
+	}
+	return cfg
 }
 
-// Load reads a devbox config file, and validates it.
-func Load(path string) (*Config, error) {
+func IsNotDefault(path string) bool {
+	cfg, err := readFromFile(path)
+	if err != nil {
+		return false
+	}
+	return !cfg.Root.Equals(&DefaultConfig().Root)
+}
+
+func LoadForTest(path string) (*Config, error) {
+	return readFromFile(path)
+}
+
+func readFromFile(path string) (*Config, error) {
 	b, err := os.ReadFile(path)
 	if err != nil {
 		return nil, err
 	}
-	baseConfig, err := loadBytes(b)
+	return loadBytes(b)
+}
+
+func LoadConfigFromURL(ctx context.Context, url string) (*Config, error) {
+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
+	if err != nil {
+		return nil, errors.WithStack(err)
+	}
+	res, err := http.DefaultClient.Do(req)
+	if err != nil {
+		return nil, err
+	}
+	defer res.Body.Close()
+
+	data, err := io.ReadAll(res.Body)
+	if err != nil {
+		return nil, errors.WithStack(err)
+	}
+	return loadBytes(data)
+}
+
+func loadBytes(b []byte) (*Config, error) {
+	root, err := configfile.LoadBytes(b)
 	if err != nil {
 		return nil, err
 	}
-	return &Config{Root: *baseConfig}, nil
+
+	return &Config{
+		Root: *root,
+	}, nil
+}
+
+func (c *Config) LoadRecursive(lockfile *lock.File) error {
+	included := make([]*Config, 0, len(c.Root.Include))
+
+	for _, includeRef := range c.Root.Include {
+		pluginConfig, err := plugin.LoadConfigFromInclude(includeRef, lockfile)
+		if err != nil {
+			return errors.WithStack(err)
+		}
+
+		includable := &Config{
+			Root:       pluginConfig.ConfigFile,
+			pluginData: &pluginConfig.PluginOnlyData,
+		}
+		if err := includable.LoadRecursive(lockfile); err != nil {
+			return errors.WithStack(err)
+		}
+
+		included = append(included, includable)
+	}
+
+	builtIns, err := plugin.GetBuiltinsForPackages(
+		c.Root.TopLevelPackages(),
+		lockfile,
+	)
+	if err != nil {
+		return errors.WithStack(err)
+	}
+
+	for _, builtIn := range builtIns {
+		includable := &Config{
+			Root:       builtIn.ConfigFile,
+			pluginData: &builtIn.PluginOnlyData,
+		}
+		if err := includable.LoadRecursive(lockfile); err != nil {
+			return errors.WithStack(err)
+		}
+		included = append(included, includable)
+	}
+
+	c.included = included
+	return nil
 }
 
-func (c *Config) PackageMutator() *packagesMutator {
+func (c *Config) PackageMutator() *configfile.PackagesMutator {
 	return &c.Root.PackagesMutator
 }
 
-func (c *Config) Packages() []Package {
-	return c.Root.PackagesMutator.collection
+func (c *Config) IncludedPluginConfigs() []*plugin.Config {
+	configs := []*plugin.Config{}
+	for _, i := range c.included {
+		configs = append(configs, i.IncludedPluginConfigs()...)
+	}
+	if c.pluginData != nil {
+		configs = append(configs, &plugin.Config{
+			ConfigFile:     c.Root,
+			PluginOnlyData: *c.pluginData,
+		})
+	}
+	return configs
+}
+
+func (c *Config) Packages() []configfile.Package {
+	packages := []configfile.Package{}
+	packagesToRemove := map[string]bool{}
+
+	for _, i := range c.included {
+		packages = append(packages, i.Packages()...)
+		if i.pluginData.RemoveTriggerPackage {
+			if pkg, ok := i.pluginData.Source.(interface{ LockfileKey() string }); ok {
+				packagesToRemove[pkg.LockfileKey()] = true
+			}
+		}
+	}
+
+	// Packages to remove in built ins only affect the devbox.json where they are defined.
+	// They should not remove packages that are part of other imports.
+	for _, pkg := range c.Root.TopLevelPackages() {
+		if !packagesToRemove[pkg.VersionedName()] {
+			packages = append(packages, pkg)
+		}
+	}
+
+	// Keep only the last occurrence of each package (by name).
+	return lo.Reverse(lo.UniqBy(
+		lo.Reverse(packages),
+		func(p configfile.Package) string { return p.Name },
+	))
 }
 
 // PackagesVersionedNames returns a list of package names with versions.
@@ -42,8 +198,8 @@ func (c *Config) Packages() []Package {
 // example:
 // ["package1", "package2@latest", "package3@1.20"]
 func (c *Config) PackagesVersionedNames() []string {
-	result := make([]string, 0, len(c.Root.PackagesMutator.collection))
-	for _, p := range c.Root.PackagesMutator.collection {
+	result := make([]string, 0, len(c.Root.TopLevelPackages()))
+	for _, p := range c.Root.TopLevelPackages() {
 		result = append(result, p.VersionedName())
 	}
 	return result
@@ -54,25 +210,54 @@ func (c *Config) NixPkgsCommitHash() string {
 }
 
 func (c *Config) Env() map[string]string {
-	return c.Root.Env
+	env := map[string]string{}
+	for _, i := range c.included {
+		maps.Copy(env, i.Env())
+	}
+	maps.Copy(env, c.Root.Env)
+	return env
 }
 
 func (c *Config) InitHook() *shellcmd.Commands {
-	return c.Root.InitHook()
+	commands := shellcmd.Commands{}
+	for _, i := range c.included {
+		commands.Cmds = append(commands.Cmds, i.InitHook().Cmds...)
+	}
+	commands.Cmds = append(commands.Cmds, c.Root.InitHook().Cmds...)
+	return &commands
 }
 
-func (c *Config) Scripts() scripts {
-	return c.Root.Scripts()
+func (c *Config) Scripts() configfile.Scripts {
+	scripts := configfile.Scripts{}
+	for _, i := range c.included {
+		maps.Copy(scripts, i.Scripts())
+	}
+	maps.Copy(scripts, c.Root.Scripts())
+	return scripts
 }
 
 func (c *Config) Hash() (string, error) {
-	return c.Root.Hash()
-}
-
-func (c *Config) Include() []string {
-	return c.Root.Include
+	data := []byte{}
+	for _, i := range c.included {
+		hash, err := i.Hash()
+		if err != nil {
+			return "", err
+		}
+		data = append(data, hash...)
+	}
+	hash, err := c.Root.Hash()
+	if err != nil {
+		return "", err
+	}
+	data = append(data, hash...)
+	return cachehash.Bytes(data)
 }
 
 func (c *Config) IsEnvsecEnabled() bool {
+	for _, i := range c.included {
+		if i.IsEnvsecEnabled() {
+			return true
+		}
+	}
 	return c.Root.IsEnvsecEnabled()
 }
diff --git a/internal/devconfig/ast.go b/internal/devconfig/configfile/ast.go
similarity index 99%
rename from internal/devconfig/ast.go
rename to internal/devconfig/configfile/ast.go
index bf5f9ca1..840504c4 100644
--- a/internal/devconfig/ast.go
+++ b/internal/devconfig/configfile/ast.go
@@ -1,4 +1,4 @@
-package devconfig
+package configfile
 
 import (
 	"bytes"
diff --git a/internal/devconfig/env.go b/internal/devconfig/configfile/env.go
similarity index 87%
rename from internal/devconfig/env.go
rename to internal/devconfig/configfile/env.go
index 7ee09009..fc62d1e1 100644
--- a/internal/devconfig/env.go
+++ b/internal/devconfig/configfile/env.go
@@ -1,4 +1,4 @@
-package devconfig
+package configfile
 
 func (c *ConfigFile) IsEnvsecEnabled() bool {
 	// envsec for legacy.
diff --git a/internal/devconfig/field.go b/internal/devconfig/configfile/field.go
similarity index 98%
rename from internal/devconfig/field.go
rename to internal/devconfig/configfile/field.go
index 8e30237c..6f29f292 100644
--- a/internal/devconfig/field.go
+++ b/internal/devconfig/configfile/field.go
@@ -1,4 +1,4 @@
-package devconfig
+package configfile
 
 import (
 	"reflect"
diff --git a/internal/devconfig/file.go b/internal/devconfig/configfile/file.go
similarity index 76%
rename from internal/devconfig/file.go
rename to internal/devconfig/configfile/file.go
index c58476ff..525b7519 100644
--- a/internal/devconfig/file.go
+++ b/internal/devconfig/configfile/file.go
@@ -1,14 +1,11 @@
 // Copyright 2023 Jetpack Technologies Inc and contributors. All rights reserved.
 // Use of this source code is governed by the license in the LICENSE file.
 
-package devconfig
+package configfile
 
 import (
 	"bytes"
 	"encoding/json"
-	"fmt"
-	"io"
-	"net/http"
 	"os"
 	"path/filepath"
 	"regexp"
@@ -16,23 +13,21 @@
 	"strings"
 
 	"github.com/pkg/errors"
-	"github.com/samber/lo"
 	"github.com/tailscale/hujson"
 	"go.jetpack.io/devbox/internal/boxcli/featureflag"
 	"go.jetpack.io/devbox/internal/boxcli/usererr"
-	"go.jetpack.io/devbox/internal/build"
 	"go.jetpack.io/devbox/internal/cachehash"
 	"go.jetpack.io/devbox/internal/devbox/shellcmd"
 )
 
 const (
-	defaultName      = "devbox.json"
-	defaultTySONName = "devbox.tson"
+	DefaultName      = "devbox.json"
+	DefaultTySONName = "devbox.tson"
 )
 
 const (
-	jsonFormat = iota
-	tsonFormat
+	JSONFormat = iota
+	TSONFormat
 )
 
 // ConfigFile defines a devbox environment as JSON.
@@ -42,7 +37,7 @@ type ConfigFile struct {
 
 	// PackagesMutator is the slice of Nix packages that devbox makes available in
 	// its environment. Deliberately do not omitempty.
-	PackagesMutator packagesMutator `json:"packages"`
+	PackagesMutator PackagesMutator `json:"packages"`
 
 	// Env allows specifying env variables
 	Env map[string]string `json:"env,omitempty"`
@@ -63,8 +58,9 @@ type ConfigFile struct {
 	// This is a similar format to nix inputs
 	Include []string `json:"include,omitempty"`
 
-	ast    *configAST
-	format int
+	ast *configAST
+	// TODO Remove tyson support and this field.
+	Format int
 }
 
 type shellConfig struct {
@@ -82,39 +78,15 @@ type Stage struct {
 	Command string `json:"command"`
 }
 
-const DefaultInitHook = "echo 'Welcome to devbox!' > /dev/null"
-
-func DefaultConfig() *ConfigFile {
-	cfg, err := loadBytes([]byte(fmt.Sprintf(`{
-  "$schema": "https://raw.githubusercontent.com/jetpack-io/devbox/%s/.schema/devbox.schema.json",
-  "packages": [],
-  "shell": {
-    "init_hook": [
-      "%s"
-    ],
-    "scripts": {
-      "test": [
-        "echo \"Error: no test specified\" && exit 1"
-      ]
-    }
-  }
-}
-`,
-		lo.Ternary(build.IsDev, "main", build.Version),
-		DefaultInitHook,
-	)))
-	if err != nil {
-		panic("default devbox.json is invalid: " + err.Error())
-	}
-	return cfg
-}
-
 func (c *ConfigFile) Bytes() []byte {
 	b := c.ast.root.Pack()
 	return bytes.ReplaceAll(b, []byte("\t"), []byte("  "))
 }
 
 func (c *ConfigFile) Hash() (string, error) {
+	if c.ast == nil {
+		return cachehash.JSON(c)
+	}
 	ast := c.ast.root.Clone()
 	ast.Minimize()
 	return cachehash.Bytes(ast.Pack())
@@ -137,7 +109,7 @@ func (c *ConfigFile) NixPkgsCommitHash() string {
 }
 
 func (c *ConfigFile) InitHook() *shellcmd.Commands {
-	if c == nil || c.Shell == nil {
+	if c == nil || c.Shell == nil || c.Shell.InitHook == nil {
 		return &shellcmd.Commands{}
 	}
 	return c.Shell.InitHook
@@ -145,10 +117,10 @@ func (c *ConfigFile) InitHook() *shellcmd.Commands {
 
 // SaveTo writes the config to a file.
 func (c *ConfigFile) SaveTo(path string) error {
-	if c.format != jsonFormat {
+	if c.Format != JSONFormat {
 		return errors.New("cannot save config to non-json format")
 	}
-	return os.WriteFile(filepath.Join(path, defaultName), c.Bytes(), 0o644)
+	return os.WriteFile(filepath.Join(path, DefaultName), c.Bytes(), 0o644)
 }
 
 // Get returns the package with the given versionedName
@@ -161,7 +133,13 @@ func (c *ConfigFile) GetPackage(versionedName string) (*Package, bool) {
 	return &c.PackagesMutator.collection[i], true
 }
 
-func loadBytes(b []byte) (*ConfigFile, error) {
+// TopLevelPackages returns the packages in the config file, but not the included ones.
+// Semi-awkwardly named to avoid confusion with the Packages method on Config.
+func (c *ConfigFile) TopLevelPackages() []Package {
+	return c.PackagesMutator.collection
+}
+
+func LoadBytes(b []byte) (*ConfigFile, error) {
 	jsonb, err := hujson.Standardize(slices.Clone(b))
 	if err != nil {
 		return nil, err
@@ -172,7 +150,7 @@ func loadBytes(b []byte) (*ConfigFile, error) {
 		return nil, err
 	}
 	cfg := &ConfigFile{
-		PackagesMutator: packagesMutator{ast: ast},
+		PackagesMutator: PackagesMutator{ast: ast},
 		ast:             ast,
 	}
 	if err := json.Unmarshal(jsonb, cfg); err != nil {
@@ -181,20 +159,6 @@ func loadBytes(b []byte) (*ConfigFile, error) {
 	return cfg, validateConfig(cfg)
 }
 
-func LoadConfigFromURL(url string) (*ConfigFile, error) {
-	res, err := http.Get(url)
-	if err != nil {
-		return nil, errors.WithStack(err)
-	}
-	defer res.Body.Close()
-
-	data, err := io.ReadAll(res.Body)
-	if err != nil {
-		return nil, errors.WithStack(err)
-	}
-	return loadBytes(data)
-}
-
 func validateConfig(cfg *ConfigFile) error {
 	fns := []func(cfg *ConfigFile) error{
 		ValidateNixpkg,
@@ -251,9 +215,9 @@ func IsConfigName(name string) bool {
 }
 
 func ValidConfigNames() []string {
-	names := []string{defaultName}
+	names := []string{DefaultName}
 	if featureflag.TySON.Enabled() {
-		names = append(names, defaultTySONName)
+		names = append(names, DefaultTySONName)
 	}
 	return names
 }
diff --git a/internal/devconfig/packages.go b/internal/devconfig/configfile/packages.go
similarity index 90%
rename from internal/devconfig/packages.go
rename to internal/devconfig/configfile/packages.go
index 215341a5..7b50d139 100644
--- a/internal/devconfig/packages.go
+++ b/internal/devconfig/configfile/packages.go
@@ -1,5 +1,5 @@
 //nolint:dupl // add/exclude platforms needs refactoring
-package devconfig
+package configfile
 
 import (
 	"encoding/json"
@@ -15,7 +15,7 @@
 	"go.jetpack.io/devbox/internal/ux"
 )
 
-type packagesMutator struct {
+type PackagesMutator struct {
 	// collection contains the set of package definitions
 	collection []Package
 
@@ -23,7 +23,7 @@ type packagesMutator struct {
 }
 
 // Add adds a package to the list of packages
-func (pkgs *packagesMutator) Add(versionedName string) {
+func (pkgs *PackagesMutator) Add(versionedName string) {
 	name, version := parseVersionedName(versionedName)
 	if pkgs.index(name, version) != -1 {
 		return
@@ -33,7 +33,7 @@ func (pkgs *packagesMutator) Add(versionedName string) {
 }
 
 // Remove removes a package from the list of packages
-func (pkgs *packagesMutator) Remove(versionedName string) {
+func (pkgs *PackagesMutator) Remove(versionedName string) {
 	name, version := parseVersionedName(versionedName)
 	i := pkgs.index(name, version)
 	if i == -1 {
@@ -44,7 +44,7 @@ func (pkgs *packagesMutator) Remove(versionedName string) {
 }
 
 // AddPlatforms adds a platform to the list of platforms for a given package
-func (pkgs *packagesMutator) AddPlatforms(writer io.Writer, versionedname string, platforms []string) error {
+func (pkgs *PackagesMutator) AddPlatforms(writer io.Writer, versionedname string, platforms []string) error {
 	if len(platforms) == 0 {
 		return nil
 	}
@@ -77,7 +77,7 @@ func (pkgs *packagesMutator) AddPlatforms(writer io.Writer, versionedname string
 		}
 	}
 	if len(pkg.Platforms) > oldLen {
-		pkgs.ast.appendPlatforms(pkg.name, "platforms", pkg.Platforms[oldLen:])
+		pkgs.ast.appendPlatforms(pkg.Name, "platforms", pkg.Platforms[oldLen:])
 		ux.Finfo(writer,
 			"Added platform %s to package %s\n", strings.Join(platforms, ", "),
 			pkg.VersionedName(),
@@ -87,7 +87,7 @@ func (pkgs *packagesMutator) AddPlatforms(writer io.Writer, versionedname string
 }
 
 // ExcludePlatforms adds a platform to the list of excluded platforms for a given package
-func (pkgs *packagesMutator) ExcludePlatforms(writer io.Writer, versionedName string, platforms []string) error {
+func (pkgs *PackagesMutator) ExcludePlatforms(writer io.Writer, versionedName string, platforms []string) error {
 	if len(platforms) == 0 {
 		return nil
 	}
@@ -117,14 +117,14 @@ func (pkgs *packagesMutator) ExcludePlatforms(writer io.Writer, versionedName st
 		}
 	}
 	if len(pkg.ExcludedPlatforms) > oldLen {
-		pkgs.ast.appendPlatforms(pkg.name, "excluded_platforms", pkg.ExcludedPlatforms[oldLen:])
+		pkgs.ast.appendPlatforms(pkg.Name, "excluded_platforms", pkg.ExcludedPlatforms[oldLen:])
 		ux.Finfo(writer, "Excluded platform %s for package %s\n", strings.Join(platforms, ", "),
 			pkg.VersionedName())
 	}
 	return nil
 }
 
-func (pkgs *packagesMutator) UnmarshalJSON(data []byte) error {
+func (pkgs *PackagesMutator) UnmarshalJSON(data []byte) error {
 	// First, attempt to unmarshal as a list of strings (legacy format)
 	var packages []string
 	if err := json.Unmarshal(data, &packages); err == nil {
@@ -147,14 +147,14 @@ func (pkgs *packagesMutator) UnmarshalJSON(data []byte) error {
 	packagesList := []Package{}
 	for pair := orderedMap.Oldest(); pair != nil; pair = pair.Next() {
 		pkg := pair.Value
-		pkg.name = pair.Key
+		pkg.Name = pair.Key
 		packagesList = append(packagesList, pkg)
 	}
 	pkgs.collection = packagesList
 	return nil
 }
 
-func (pkgs *packagesMutator) SetPatchGLibc(versionedName string, v bool) error {
+func (pkgs *PackagesMutator) SetPatchGLibc(versionedName string, v bool) error {
 	name, version := parseVersionedName(versionedName)
 	i := pkgs.index(name, version)
 	if i == -1 {
@@ -167,7 +167,7 @@ func (pkgs *packagesMutator) SetPatchGLibc(versionedName string, v bool) error {
 	return nil
 }
 
-func (pkgs *packagesMutator) SetDisablePlugin(versionedName string, v bool) error {
+func (pkgs *PackagesMutator) SetDisablePlugin(versionedName string, v bool) error {
 	name, version := parseVersionedName(versionedName)
 	i := pkgs.index(name, version)
 	if i == -1 {
@@ -180,7 +180,7 @@ func (pkgs *packagesMutator) SetDisablePlugin(versionedName string, v bool) erro
 	return nil
 }
 
-func (pkgs *packagesMutator) SetOutputs(writer io.Writer, versionedName string, outputs []string) error {
+func (pkgs *PackagesMutator) SetOutputs(writer io.Writer, versionedName string, outputs []string) error {
 	name, version := parseVersionedName(versionedName)
 	i := pkgs.index(name, version)
 	if i == -1 {
@@ -196,13 +196,13 @@ func (pkgs *packagesMutator) SetOutputs(writer io.Writer, versionedName string,
 
 	if len(toAdd) > 0 {
 		pkg := &pkgs.collection[i]
-		pkgs.ast.appendOutputs(pkg.name, "outputs", toAdd)
+		pkgs.ast.appendOutputs(pkg.Name, "outputs", toAdd)
 		ux.Finfo(writer, "Added outputs %s to package %s\n", strings.Join(toAdd, ", "), versionedName)
 	}
 	return nil
 }
 
-func (pkgs *packagesMutator) SetAllowInsecure(writer io.Writer, versionedName string, whitelist []string) error {
+func (pkgs *PackagesMutator) SetAllowInsecure(writer io.Writer, versionedName string, whitelist []string) error {
 	name, version := parseVersionedName(versionedName)
 	i := pkgs.index(name, version)
 	if i == -1 {
@@ -218,21 +218,21 @@ func (pkgs *packagesMutator) SetAllowInsecure(writer io.Writer, versionedName st
 
 	if len(toAdd) > 0 {
 		pkg := &pkgs.collection[i]
-		pkgs.ast.appendAllowInsecure(pkg.name, "allow_insecure", toAdd)
+		pkgs.ast.appendAllowInsecure(pkg.Name, "allow_insecure", toAdd)
 		pkg.AllowInsecure = append(pkg.AllowInsecure, toAdd...)
 		ux.Finfo(writer, "Allowed insecure %s for package %s\n", strings.Join(toAdd, ", "), versionedName)
 	}
 	return nil
 }
 
-func (pkgs *packagesMutator) index(name, version string) int {
+func (pkgs *PackagesMutator) index(name, version string) int {
 	return slices.IndexFunc(pkgs.collection, func(p Package) bool {
-		return p.name == name && p.Version == version
+		return p.Name == name && p.Version == version
 	})
 }
 
 type Package struct {
-	name    string
+	Name    string
 	Version string `json:"version,omitempty"`
 
 	DisablePlugin     bool     `json:"disable_plugin,omitempty"`
@@ -254,7 +254,7 @@ type Package struct {
 
 func NewVersionOnlyPackage(name, version string) Package {
 	return Package{
-		name:    name,
+		Name:    name,
 		Version: version,
 	}
 }
@@ -286,7 +286,7 @@ func NewPackage(name string, values map[string]any) Package {
 	}
 
 	return Package{
-		name:              name,
+		Name:              name,
 		Version:           version.(string),
 		Platforms:         platforms,
 		ExcludedPlatforms: excludedPlatforms,
@@ -318,7 +318,7 @@ func (p *Package) IsEnabledOnPlatform() bool {
 }
 
 func (p *Package) VersionedName() string {
-	name := p.name
+	name := p.Name
 	if p.Version != "" {
 		name += "@" + p.Version
 	}
diff --git a/internal/devconfig/scripts.go b/internal/devconfig/configfile/scripts.go
similarity index 53%
rename from internal/devconfig/scripts.go
rename to internal/devconfig/configfile/scripts.go
index d1bfb4dd..951ded0c 100644
--- a/internal/devconfig/scripts.go
+++ b/internal/devconfig/configfile/scripts.go
@@ -1,4 +1,4 @@
-package devconfig
+package configfile
 
 import "go.jetpack.io/devbox/internal/devbox/shellcmd"
 
@@ -7,17 +7,21 @@ type script struct {
 	Comments string
 }
 
-type scripts map[string]*script
+type Scripts map[string]*script
 
-func (c *ConfigFile) Scripts() scripts {
+func (c *ConfigFile) Scripts() Scripts {
 	if c == nil || c.Shell == nil {
 		return nil
 	}
-	result := make(scripts)
+	result := make(Scripts)
 	for name, commands := range c.Shell.Scripts {
+		comments := ""
+		if c.ast != nil {
+			comments = string(c.ast.beforeComment("shell", "scripts", name))
+		}
 		result[name] = &script{
 			Commands: *commands,
-			Comments: string(c.ast.beforeComment("shell", "scripts", name)),
+			Comments: comments,
 		}
 	}
 
diff --git a/internal/devconfig/init.go b/internal/devconfig/init.go
index acbabe04..855e5736 100644
--- a/internal/devconfig/init.go
+++ b/internal/devconfig/init.go
@@ -16,13 +16,14 @@
 	"github.com/fatih/color"
 
 	"go.jetpack.io/devbox/internal/boxcli/featureflag"
+	"go.jetpack.io/devbox/internal/devconfig/configfile"
 	"go.jetpack.io/devbox/internal/devpkg/pkgtype"
 	"go.jetpack.io/devbox/internal/fileutil"
 	"go.jetpack.io/devbox/internal/initrec"
 )
 
 func Init(dir string, writer io.Writer) (created bool, err error) {
-	created, err = initConfigFile(filepath.Join(dir, defaultName))
+	created, err = initConfigFile(filepath.Join(dir, configfile.DefaultName))
 	if err != nil || !created {
 		return created, err
 	}
@@ -57,7 +58,7 @@ func initConfigFile(path string) (created bool, err error) {
 		}
 	}()
 
-	_, err = file.Write(DefaultConfig().Bytes())
+	_, err = file.Write(DefaultConfig().Root.Bytes())
 	if err != nil {
 		file.Close()
 		return false, err
@@ -69,13 +70,13 @@ func initConfigFile(path string) (created bool, err error) {
 }
 
 func Open(projectDir string) (*Config, error) {
-	cfgPath := filepath.Join(projectDir, defaultName)
+	cfgPath := filepath.Join(projectDir, configfile.DefaultName)
 
 	if !featureflag.TySON.Enabled() {
-		return Load(cfgPath)
+		return readFromFile(cfgPath)
 	}
 
-	tysonCfgPath := filepath.Join(projectDir, defaultTySONName)
+	tysonCfgPath := filepath.Join(projectDir, configfile.DefaultTySONName)
 
 	// If tyson config exists use it. Otherwise fallback to json config.
 	// In the future we may error out if both configs exist, but for now support
@@ -97,13 +98,13 @@ func Open(projectDir string) (*Config, error) {
 			return nil, err
 		}
 		cfgPath = tmpFile.Name()
-		config, err := Load(cfgPath)
+		config, err := readFromFile(cfgPath)
 		if err != nil {
 			return nil, err
 		}
-		config.Root.format = tsonFormat
+		config.Root.Format = configfile.TSONFormat
 		return config, nil
 	}
 
-	return Load(cfgPath)
+	return readFromFile(cfgPath)
 }
diff --git a/internal/devpkg/narinfo_cache.go b/internal/devpkg/narinfo_cache.go
index f519f25f..47f654f5 100644
--- a/internal/devpkg/narinfo_cache.go
+++ b/internal/devpkg/narinfo_cache.go
@@ -177,7 +177,7 @@ func (p *Package) fetchNarInfoStatus(outputName string) (bool, error) {
 // the package to query it from the binary cache.
 func (p *Package) isEligibleForBinaryCache() (bool, error) {
 	// Patched glibc packages are not in the binary cache.
-	if p.PatchGlibc {
+	if p.PatchGlibc() {
 		return false, nil
 	}
 	sysInfo, err := p.sysInfoIfExists()
diff --git a/internal/devpkg/package.go b/internal/devpkg/package.go
index a67269cb..a1e2140d 100644
--- a/internal/devpkg/package.go
+++ b/internal/devpkg/package.go
@@ -17,7 +17,7 @@
 	"go.jetpack.io/devbox/internal/boxcli/usererr"
 	"go.jetpack.io/devbox/internal/cachehash"
 	"go.jetpack.io/devbox/internal/devbox/devopt"
-	"go.jetpack.io/devbox/internal/devconfig"
+	"go.jetpack.io/devbox/internal/devconfig/configfile"
 	"go.jetpack.io/devbox/internal/devpkg/pkgtype"
 	"go.jetpack.io/devbox/internal/lock"
 	"go.jetpack.io/devbox/internal/nix"
@@ -78,16 +78,18 @@ type Package struct {
 	// Outputs is a list of outputs to build from the package's derivation.
 	outputs outputs
 
-	// PatchGlibc applies a function to the package's derivation that
+	// patchGlibc applies a function to the package's derivation that
 	// patches any ELF binaries to use the latest version of nixpkgs#glibc.
-	PatchGlibc bool
+	// It's a function to allow deferring nix System call until it's needed.
+	patchGlibc func() bool
 
 	// AllowInsecure are a list of nix packages that are whitelisted to be
 	// installed even if they are marked as insecure.
 	AllowInsecure []string
 
 	// isInstallable is true if the package may be enabled on the current platform.
-	isInstallable bool
+	// It's a function to allow deferring nix System call until it's needed.
+	isInstallable func() bool
 
 	normalizedPackageAttributePathCache string // memoized value from normalizedPackageAttributePath()
 }
@@ -100,12 +102,14 @@ func PackagesFromStringsWithOptions(rawNames []string, l lock.Locker, opts devop
 	return packages
 }
 
-func PackagesFromConfig(config *devconfig.Config, l lock.Locker) []*Package {
+func PackagesFromConfig(packages []configfile.Package, l lock.Locker) []*Package {
 	result := []*Package{}
-	for _, cfgPkg := range config.Packages() {
-		pkg := newPackage(cfgPkg.VersionedName(), cfgPkg.IsEnabledOnPlatform(), l)
+	for _, cfgPkg := range packages {
+		pkg := newPackage(cfgPkg.VersionedName(), cfgPkg.IsEnabledOnPlatform, l)
 		pkg.DisablePlugin = cfgPkg.DisablePlugin
-		pkg.PatchGlibc = cfgPkg.PatchGlibc && nix.SystemIsLinux()
+		pkg.patchGlibc = sync.OnceValue(func() bool {
+			return cfgPkg.PatchGlibc && nix.SystemIsLinux()
+		})
 		pkg.outputs.selectedNames = lo.Uniq(append(pkg.outputs.selectedNames, cfgPkg.Outputs...))
 		pkg.AllowInsecure = cfgPkg.AllowInsecure
 		result = append(result, pkg)
@@ -114,23 +118,23 @@ func PackagesFromConfig(config *devconfig.Config, l lock.Locker) []*Package {
 }
 
 func PackageFromStringWithDefaults(raw string, locker lock.Locker) *Package {
-	return newPackage(raw, true /*isInstallable*/, locker)
+	return newPackage(raw, func() bool { return true } /*isInstallable*/, locker)
 }
 
 func PackageFromStringWithOptions(raw string, locker lock.Locker, opts devopt.AddOpts) *Package {
 	pkg := PackageFromStringWithDefaults(raw, locker)
 	pkg.DisablePlugin = opts.DisablePlugin
-	pkg.PatchGlibc = opts.PatchGlibc
+	pkg.patchGlibc = sync.OnceValue(func() bool { return opts.PatchGlibc })
 	pkg.outputs.selectedNames = lo.Uniq(append(pkg.outputs.selectedNames, opts.Outputs...))
 	pkg.AllowInsecure = opts.AllowInsecure
 	return pkg
 }
 
-func newPackage(raw string, isInstallable bool, locker lock.Locker) *Package {
+func newPackage(raw string, isInstallable func() bool, locker lock.Locker) *Package {
 	pkg := &Package{
 		Raw:           raw,
 		lockfile:      locker,
-		isInstallable: isInstallable,
+		isInstallable: sync.OnceValue(isInstallable),
 	}
 
 	// The raw string is either a Devbox package ("name" or "name@version")
@@ -258,7 +262,11 @@ func (p *Package) URLForFlakeInput() string {
 // IsInstallable returns whether this package is installable. Not to be confused
 // with the Installable() method which returns the corresponding nix concept.
 func (p *Package) IsInstallable() bool {
-	return p.isInstallable
+	return p.isInstallable()
+}
+
+func (p *Package) PatchGlibc() bool {
+	return p.patchGlibc != nil && p.patchGlibc()
 }
 
 // Installables for this package. Installables is a nix concept defined here:
@@ -672,6 +680,10 @@ func (p *Package) String() string {
 	return p.Raw
 }
 
+func (p *Package) LockfileKey() string {
+	return p.Raw
+}
+
 func IsNix(p *Package, _ int) bool {
 	return !p.IsRunX()
 }
diff --git a/internal/nix/nix.go b/internal/nix/nix.go
index 9190cad6..0f858d17 100644
--- a/internal/nix/nix.go
+++ b/internal/nix/nix.go
@@ -131,7 +131,10 @@ func System() string {
 		// While this should have been initialized, we do a best-effort to avoid
 		// a panic.
 		if err := ComputeSystem(); err != nil {
-			panic("System called before being initialized by ComputeSystem")
+			panic(fmt.Sprintf(
+				"System called before being initialized by ComputeSystem: %v",
+				err,
+			))
 		}
 	}
 	return cachedSystem
diff --git a/internal/plugin/files.go b/internal/plugin/files.go
index 8621692a..4efc5be5 100644
--- a/internal/plugin/files.go
+++ b/internal/plugin/files.go
@@ -8,11 +8,13 @@
 	"os"
 
 	"github.com/pkg/errors"
+	"go.jetpack.io/devbox/internal/devconfig/configfile"
 	"go.jetpack.io/devbox/internal/devpkg"
+	"go.jetpack.io/devbox/internal/lock"
 	"go.jetpack.io/devbox/plugins"
 )
 
-func getConfigIfAny(pkg Includable, projectDir string) (*config, error) {
+func getConfigIfAny(pkg Includable, projectDir string) (*Config, error) {
 	switch pkg := pkg.(type) {
 	case *devpkg.Package:
 		return getBuiltinPluginConfigIfExists(pkg, projectDir)
@@ -23,7 +25,7 @@ func getConfigIfAny(pkg Includable, projectDir string) (*config, error) {
 		}
 		return buildConfig(pkg, projectDir, string(content))
 	case *localPlugin:
-		content, err := os.ReadFile(pkg.ref.Path)
+		content, err := os.ReadFile(pkg.Path())
 		if err != nil && !os.IsNotExist(err) {
 			return nil, errors.WithStack(err)
 		}
@@ -35,7 +37,7 @@ func getConfigIfAny(pkg Includable, projectDir string) (*config, error) {
 func getBuiltinPluginConfigIfExists(
 	pkg *devpkg.Package,
 	projectDir string,
-) (*config, error) {
+) (*Config, error) {
 	if pkg.DisablePlugin {
 		return nil, nil
 	}
@@ -48,3 +50,20 @@ func getBuiltinPluginConfigIfExists(
 	}
 	return buildConfig(pkg, projectDir, string(content))
 }
+
+func GetBuiltinsForPackages(
+	packages []configfile.Package,
+	lockfile *lock.File,
+) ([]*Config, error) {
+	builtIns := []*Config{}
+	for _, pkg := range devpkg.PackagesFromConfig(packages, lockfile) {
+		config, err := getBuiltinPluginConfigIfExists(pkg, lockfile.ProjectDir())
+		if err != nil {
+			return nil, err
+		}
+		if config != nil {
+			builtIns = append(builtIns, config)
+		}
+	}
+	return builtIns, nil
+}
diff --git a/internal/plugin/github.go b/internal/plugin/github.go
index a39ed76c..f2f40445 100644
--- a/internal/plugin/github.go
+++ b/internal/plugin/github.go
@@ -62,3 +62,7 @@ func (p *githubPlugin) url(subpath string) (string, error) {
 		subpath,
 	)
 }
+
+func (p *githubPlugin) LockfileKey() string {
+	return p.ref.raw
+}
diff --git a/internal/plugin/hooks.go b/internal/plugin/hooks.go
deleted file mode 100644
index 9c6c9ca0..00000000
--- a/internal/plugin/hooks.go
+++ /dev/null
@@ -1,37 +0,0 @@
-// Copyright 2023 Jetpack Technologies Inc and contributors. All rights reserved.
-// Use of this source code is governed by the license in the LICENSE file.
-
-package plugin
-
-import (
-	"go.jetpack.io/devbox/internal/devpkg"
-)
-
-func (m *Manager) InitHooks(
-	pkgs []*devpkg.Package,
-	includes []string,
-) ([]string, error) {
-	hooks := []string{}
-	allPkgs := []Includable{}
-	for _, pkg := range pkgs {
-		allPkgs = append(allPkgs, pkg)
-	}
-	for _, include := range includes {
-		name, err := m.ParseInclude(include)
-		if err != nil {
-			return nil, err
-		}
-		allPkgs = append(allPkgs, name)
-	}
-	for _, pkg := range allPkgs {
-		c, err := getConfigIfAny(pkg, m.ProjectDir())
-		if err != nil {
-			return nil, err
-		}
-		if c == nil {
-			continue
-		}
-		hooks = append(hooks, c.InitHook().Cmds...)
-	}
-	return hooks, nil
-}
diff --git a/internal/plugin/includes.go b/internal/plugin/includes.go
index f21bb107..82b5e3fb 100644
--- a/internal/plugin/includes.go
+++ b/internal/plugin/includes.go
@@ -4,11 +4,22 @@
 	"strings"
 
 	"go.jetpack.io/devbox/internal/devpkg"
+	"go.jetpack.io/devbox/internal/lock"
 )
 
-func (m *Manager) ParseInclude(include string) (Includable, error) {
+func LoadConfigFromInclude(include string, lockfile *lock.File) (*Config, error) {
+	var includable Includable
+	var err error
 	if t, name, _ := strings.Cut(include, ":"); t == "plugin" {
-		return devpkg.PackageFromStringWithDefaults(name, m.lockfile), nil
+		includable = devpkg.PackageFromStringWithDefaults(
+			name,
+			lockfile,
+		)
+	} else {
+		includable, err = parseReflike(include, lockfile.ProjectDir())
+		if err != nil {
+			return nil, err
+		}
 	}
-	return parseReflike(include)
+	return getConfigIfAny(includable, lockfile.ProjectDir())
 }
diff --git a/internal/plugin/info.go b/internal/plugin/info.go
index 59b79883..8e3d62e2 100644
--- a/internal/plugin/info.go
+++ b/internal/plugin/info.go
@@ -59,7 +59,7 @@ func Readme(ctx context.Context,
 	return buf.String(), nil
 }
 
-func printReadme(cfg *config, w io.Writer, markdown bool) error {
+func printReadme(cfg *Config, w io.Writer, markdown bool) error {
 	if cfg.Description() == "" {
 		return nil
 	}
@@ -73,7 +73,7 @@ func printReadme(cfg *config, w io.Writer, markdown bool) error {
 	return errors.WithStack(err)
 }
 
-func printServices(cfg *config, pkg *devpkg.Package, w io.Writer, markdown bool) error {
+func printServices(cfg *Config, pkg *devpkg.Package, w io.Writer, markdown bool) error {
 	_, contentPath := cfg.ProcessComposeYaml()
 	if contentPath == "" {
 		return nil
@@ -103,7 +103,7 @@ func printServices(cfg *config, pkg *devpkg.Package, w io.Writer, markdown bool)
 	return errors.WithStack(err)
 }
 
-func printCreateFiles(cfg *config, w io.Writer, markdown bool) error {
+func printCreateFiles(cfg *Config, w io.Writer, markdown bool) error {
 	if len(cfg.CreateFiles) == 0 {
 		return nil
 	}
@@ -124,7 +124,7 @@ func printCreateFiles(cfg *config, w io.Writer, markdown bool) error {
 	return errors.WithStack(err)
 }
 
-func printEnv(cfg *config, w io.Writer, markdown bool) error {
+func printEnv(cfg *Config, w io.Writer, markdown bool) error {
 	if len(cfg.Env) == 0 {
 		return nil
 	}
diff --git a/internal/plugin/local.go b/internal/plugin/local.go
index 79a5d99d..a401097c 100644
--- a/internal/plugin/local.go
+++ b/internal/plugin/local.go
@@ -5,20 +5,22 @@
 	"os"
 	"path/filepath"
 	"regexp"
+	"strings"
 
 	"go.jetpack.io/devbox/internal/boxcli/usererr"
 	"go.jetpack.io/devbox/internal/cachehash"
 )
 
 type localPlugin struct {
-	ref  RefLike
-	name string
+	ref        RefLike
+	name       string
+	projectDir string
 }
 
 var nameRegex = regexp.MustCompile(`^[a-zA-Z0-9_\- ]+$`)
 
-func newLocalPlugin(ref RefLike) (*localPlugin, error) {
-	plugin := &localPlugin{ref: ref}
+func newLocalPlugin(ref RefLike, projectDir string) (*localPlugin, error) {
+	plugin := &localPlugin{ref: ref, projectDir: projectDir}
 	content, err := plugin.Fetch()
 	if err != nil {
 		return nil, err
@@ -30,12 +32,12 @@ func newLocalPlugin(ref RefLike) (*localPlugin, error) {
 	name, ok := m["name"].(string)
 	if !ok || name == "" {
 		return nil,
-			usererr.New("plugin %s is missing a required field 'name'", plugin.ref.Path)
+			usererr.New("plugin %s is missing a required field 'name'", plugin.Path())
 	}
 	if !nameRegex.MatchString(name) {
 		return nil, usererr.New(
 			"plugin %s has an invalid name %q. Name must match %s",
-			plugin.ref.Path, name, nameRegex,
+			plugin.Path(), name, nameRegex,
 		)
 	}
 	plugin.name = name
@@ -43,7 +45,7 @@ func newLocalPlugin(ref RefLike) (*localPlugin, error) {
 }
 
 func (l *localPlugin) Fetch() ([]byte, error) {
-	return os.ReadFile(l.ref.withFilename(l.ref.Path))
+	return os.ReadFile(l.ref.withFilename(l.Path()))
 }
 
 func (l *localPlugin) CanonicalName() string {
@@ -55,10 +57,25 @@ func (l *localPlugin) IsLocal() bool {
 }
 
 func (l *localPlugin) Hash() string {
-	h, _ := cachehash.Bytes([]byte(l.ref.Path))
+	h, _ := cachehash.Bytes([]byte(l.Path()))
 	return h
 }
 
 func (l *localPlugin) FileContent(subpath string) ([]byte, error) {
-	return os.ReadFile(filepath.Join(filepath.Dir(l.ref.Path), subpath))
+	return os.ReadFile(filepath.Join(filepath.Dir(l.Path()), subpath))
+}
+
+func (l *localPlugin) LockfileKey() string {
+	return l.ref.raw
+}
+
+func (l *localPlugin) Path() string {
+	path := l.ref.Ref.Path
+	if !strings.HasSuffix(path, l.ref.filename) {
+		path = filepath.Join(path, l.ref.filename)
+	}
+	if filepath.IsAbs(path) {
+		return path
+	}
+	return filepath.Join(l.projectDir, path)
 }
diff --git a/internal/plugin/manager.go b/internal/plugin/manager.go
index e900e5b3..50339a8b 100644
--- a/internal/plugin/manager.go
+++ b/internal/plugin/manager.go
@@ -4,12 +4,6 @@
 package plugin
 
 import (
-	"path/filepath"
-	"strings"
-
-	"github.com/samber/lo"
-	"go.jetpack.io/devbox/internal/devconfig"
-	"go.jetpack.io/devbox/internal/devpkg"
 	"go.jetpack.io/devbox/internal/lock"
 )
 
@@ -49,37 +43,3 @@ func (m *Manager) ApplyOptions(opts ...managerOption) {
 		opt(m)
 	}
 }
-
-// ProcessPluginPackages adds and removes packages as indicated by plugins
-func (m *Manager) ProcessPluginPackages(
-	userPackages []*devpkg.Package,
-) ([]*devpkg.Package, error) {
-	pluginPackages := []*devpkg.Package{}
-	packagesToRemove := []*devpkg.Package{}
-	for _, pkg := range userPackages {
-		config, err := getConfigIfAny(pkg, m.ProjectDir())
-		if err != nil {
-			return nil, err
-		} else if config == nil {
-			continue
-		}
-		pluginPackages = append(
-			pluginPackages,
-			devpkg.PackagesFromConfig(
-				&devconfig.Config{Root: config.ConfigFile}, m.lockfile)...,
-		)
-		if config.RemoveTriggerPackage {
-			packagesToRemove = append(packagesToRemove, pkg)
-		}
-	}
-
-	netUserPackages, _ := lo.Difference(userPackages, packagesToRemove)
-	// We prioritize plugin packages so that the php plugin works. Not sure
-	// if this is behavior we want for user plugins. We may need to add an optional
-	// priority field to the config.
-	return append(pluginPackages, netUserPackages...), nil
-}
-
-func (m *Manager) PathIsInVirtenv(absPath string) bool {
-	return strings.HasPrefix(absPath, filepath.Join(m.ProjectDir(), VirtenvPath))
-}
diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go
index 7fa05c73..8219c081 100644
--- a/internal/plugin/plugin.go
+++ b/internal/plugin/plugin.go
@@ -14,10 +14,9 @@
 	"text/template"
 
 	"github.com/pkg/errors"
-	"go.jetpack.io/devbox/internal/devconfig"
+	"go.jetpack.io/devbox/internal/devconfig/configfile"
 	"go.jetpack.io/devbox/internal/devpkg"
 
-	"go.jetpack.io/devbox/internal/conf"
 	"go.jetpack.io/devbox/internal/debug"
 	"go.jetpack.io/devbox/internal/lock"
 	"go.jetpack.io/devbox/internal/nix"
@@ -35,19 +34,25 @@
 	VirtenvBinPath = filepath.Join(VirtenvPath, "bin")
 )
 
-type config struct {
-	devconfig.ConfigFile
-
-	CreateFiles map[string]string `json:"create_files"`
+type Config struct {
+	configfile.ConfigFile
+	PluginOnlyData
+}
 
-	DeprecatedDescription string `json:"readme"`
+type PluginOnlyData struct {
+	CreateFiles           map[string]string `json:"create_files"`
+	DeprecatedDescription string            `json:"readme"`
 	// If true, we remove the package that triggered this plugin from the environment
 	// Useful when we want to replace with flake
 	RemoveTriggerPackage bool   `json:"__remove_trigger_package,omitempty"`
 	Version              string `json:"version"`
+	// Source is the includable that triggered this plugin. There are two ways to include a plugin:
+	// 1. Built-in plugins are triggered by packages (See plugins.builtInMap)
+	// 2. Plugins can be added via the "include" field in devbox.json or plugin.json
+	Source Includable
 }
 
-func (c *config) ProcessComposeYaml() (string, string) {
+func (c *Config) ProcessComposeYaml() (string, string) {
 	for file, contentPath := range c.CreateFiles {
 		if strings.HasSuffix(file, "process-compose.yaml") || strings.HasSuffix(file, "process-compose.yml") {
 			return file, contentPath
@@ -56,40 +61,22 @@ func (c *config) ProcessComposeYaml() (string, string) {
 	return "", ""
 }
 
-func (c *config) Services() (services.Services, error) {
+func (c *Config) Services() (services.Services, error) {
 	if file, _ := c.ProcessComposeYaml(); file != "" {
 		return services.FromProcessCompose(file)
 	}
 	return nil, nil
 }
 
-func (m *Manager) Include(included string) error {
-	name, err := m.ParseInclude(included)
-	if err != nil {
-		return err
-	}
-	err = m.create(name, m.lockfile.Packages[included])
-	return err
-}
-
-func (m *Manager) Create(pkg *devpkg.Package) error {
-	return m.create(pkg, m.lockfile.Packages[pkg.Raw])
-}
-
-func (m *Manager) create(pkg Includable, locked *lock.Package) error {
+func (m *Manager) CreateFilesForConfig(cfg *Config) error {
 	virtenvPath := filepath.Join(m.ProjectDir(), VirtenvPath)
-	cfg, err := getConfigIfAny(pkg, m.ProjectDir())
-	if err != nil {
-		return err
-	}
-	if cfg == nil {
-		return nil
-	}
+	pkg := cfg.Source
+	locked := m.lockfile.Packages[pkg.LockfileKey()]
 
 	name := pkg.CanonicalName()
 
 	// Always create this dir because some plugins depend on it.
-	if err = createDir(filepath.Join(virtenvPath, name)); err != nil {
+	if err := createDir(filepath.Join(virtenvPath, name)); err != nil {
 		return err
 	}
 
@@ -103,7 +90,7 @@ func (m *Manager) create(pkg Includable, locked *lock.Package) error {
 		if contentPath == "" {
 			dirPath = filePath
 		}
-		if err = createDir(dirPath); err != nil {
+		if err := createDir(dirPath); err != nil {
 			return errors.WithStack(err)
 		}
 
@@ -111,7 +98,7 @@ func (m *Manager) create(pkg Includable, locked *lock.Package) error {
 			continue
 		}
 
-		if err = m.createFile(pkg, filePath, contentPath, virtenvPath); err != nil {
+		if err := m.createFile(pkg, filePath, contentPath, virtenvPath); err != nil {
 			return err
 		}
 
@@ -178,44 +165,8 @@ func (m *Manager) createFile(
 	return nil
 }
 
-// Env returns the environment variables for the given plugins.
-// TODO: this should have PluginManager as receiver so we can build once with
-// pkgs, includes, etc
-func (m *Manager) Env(
-	pkgs []*devpkg.Package,
-	includes []string,
-	computedEnv map[string]string,
-) (map[string]string, error) {
-	allPkgs := []Includable{}
-	for _, pkg := range pkgs {
-		allPkgs = append(allPkgs, pkg)
-	}
-	for _, included := range includes {
-		input, err := m.ParseInclude(included)
-		if err != nil {
-			return nil, err
-		}
-		allPkgs = append(allPkgs, input)
-	}
-
-	env := map[string]string{}
-	for _, pkg := range allPkgs {
-		cfg, err := getConfigIfAny(pkg, m.ProjectDir())
-		if err != nil {
-			return nil, err
-		}
-		if cfg == nil {
-			continue
-		}
-		for k, v := range cfg.Env {
-			env[k] = v
-		}
-	}
-	return conf.OSExpandEnvMap(env, computedEnv, m.ProjectDir()), nil
-}
-
-func buildConfig(pkg Includable, projectDir, content string) (*config, error) {
-	cfg := &config{}
+func buildConfig(pkg Includable, projectDir, content string) (*Config, error) {
+	cfg := &Config{PluginOnlyData: PluginOnlyData{Source: pkg}}
 	name := pkg.CanonicalName()
 	t, err := template.New(name + "-template").Parse(content)
 	if err != nil {
@@ -279,7 +230,7 @@ func (m *Manager) shouldCreateFile(
 	return errors.Is(err, fs.ErrNotExist)
 }
 
-func (c *config) Description() string {
+func (c *Config) Description() string {
 	if c == nil {
 		return ""
 	}
diff --git a/internal/plugin/reflike.go b/internal/plugin/reflike.go
index d41e1218..20ea2e94 100644
--- a/internal/plugin/reflike.go
+++ b/internal/plugin/reflike.go
@@ -13,23 +13,25 @@
 type RefLike struct {
 	flake.Ref
 	filename string
+	raw      string
 }
 
 type Includable interface {
 	CanonicalName() string
 	Hash() string
 	FileContent(subpath string) ([]byte, error)
+	LockfileKey() string
 }
 
-func parseReflike(s string) (Includable, error) {
+func parseReflike(s, projectDir string) (Includable, error) {
 	ref, err := flake.ParseRef(s)
 	if err != nil {
 		return nil, err
 	}
-	reflike := RefLike{ref, pluginConfigName}
+	reflike := RefLike{ref, pluginConfigName, s}
 	switch ref.Type {
 	case flake.TypePath:
-		return newLocalPlugin(reflike)
+		return newLocalPlugin(reflike, projectDir)
 	case flake.TypeGitHub:
 		return &githubPlugin{ref: reflike}, nil
 	default:
diff --git a/internal/plugin/services.go b/internal/plugin/services.go
index 7dfaa825..2d8959d3 100644
--- a/internal/plugin/services.go
+++ b/internal/plugin/services.go
@@ -7,40 +7,19 @@
 	"fmt"
 	"os"
 
-	"go.jetpack.io/devbox/internal/devpkg"
 	"go.jetpack.io/devbox/internal/services"
 )
 
-func (m *Manager) GetServices(
-	pkgs []*devpkg.Package,
-	includes []string,
-) (services.Services, error) {
+func GetServices(configs []*Config) (services.Services, error) {
 	allSvcs := services.Services{}
-
-	allPkgs := []Includable{}
-	for _, pkg := range pkgs {
-		allPkgs = append(allPkgs, pkg)
-	}
-	for _, include := range includes {
-		name, err := m.ParseInclude(include)
-		if err != nil {
-			return nil, err
-		}
-		allPkgs = append(allPkgs, name)
-	}
-
-	for _, pkg := range allPkgs {
-		conf, err := getConfigIfAny(pkg, m.ProjectDir())
-		if err != nil {
-			return nil, err
-		}
-		if conf == nil {
-			continue
-		}
-
+	for _, conf := range configs {
 		svcs, err := conf.Services()
 		if err != nil {
-			fmt.Fprintf(os.Stderr, "error reading services in plugin \"%s\", skipping", conf.Name)
+			fmt.Fprintf(
+				os.Stderr,
+				"error reading services in plugin \"%s\", skipping",
+				conf.Name,
+			)
 			continue
 		}
 		for name, svc := range svcs {
diff --git a/internal/pullbox/config.go b/internal/pullbox/config.go
index e6165554..251cc685 100644
--- a/internal/pullbox/config.go
+++ b/internal/pullbox/config.go
@@ -4,6 +4,7 @@
 package pullbox
 
 import (
+	"context"
 	"net/url"
 	"os"
 	"path/filepath"
@@ -23,12 +24,12 @@ func (p *pullbox) IsTextDevboxConfig() bool {
 	return cuecfg.IsSupportedExtension(ext)
 }
 
-func (p *pullbox) pullTextDevboxConfig() error {
+func (p *pullbox) pullTextDevboxConfig(ctx context.Context) error {
 	if p.isLocalConfig() {
 		return p.copyToProfile(p.URL)
 	}
 
-	cfg, err := devconfig.LoadConfigFromURL(p.URL)
+	cfg, err := devconfig.LoadConfigFromURL(ctx, p.URL)
 	if err != nil {
 		return err
 	}
@@ -37,7 +38,7 @@ func (p *pullbox) pullTextDevboxConfig() error {
 	if err != nil {
 		return err
 	}
-	if err = cfg.SaveTo(tmpDir); err != nil {
+	if err = cfg.Root.SaveTo(tmpDir); err != nil {
 		return err
 	}
 
diff --git a/internal/pullbox/files.go b/internal/pullbox/files.go
index 8658161d..18b619d3 100644
--- a/internal/pullbox/files.go
+++ b/internal/pullbox/files.go
@@ -13,6 +13,7 @@
 	"github.com/pkg/errors"
 	"go.jetpack.io/devbox/internal/cmdutil"
 	"go.jetpack.io/devbox/internal/devconfig"
+	"go.jetpack.io/devbox/internal/devconfig/configfile"
 	"go.jetpack.io/devbox/internal/fileutil"
 )
 
@@ -62,7 +63,7 @@ func profileIsNotEmpty(path string) (bool, error) {
 		return false, errors.WithStack(err)
 	}
 	for _, entry := range entries {
-		if !devconfig.IsConfigName(entry.Name()) ||
+		if !configfile.IsConfigName(entry.Name()) ||
 			isModifiedConfig(filepath.Join(path, entry.Name())) {
 			return true, nil
 		}
@@ -71,12 +72,8 @@ func profileIsNotEmpty(path string) (bool, error) {
 }
 
 func isModifiedConfig(path string) bool {
-	if devconfig.IsConfigName(filepath.Base(path)) {
-		cfg, err := devconfig.Load(path)
-		if err != nil {
-			return false
-		}
-		return !cfg.Root.Equals(devconfig.DefaultConfig())
+	if configfile.IsConfigName(filepath.Base(path)) {
+		return devconfig.IsNotDefault(path)
 	}
 	return false
 }
diff --git a/internal/pullbox/pullbox.go b/internal/pullbox/pullbox.go
index 96d41b8f..e89c38d2 100644
--- a/internal/pullbox/pullbox.go
+++ b/internal/pullbox/pullbox.go
@@ -80,7 +80,7 @@ func (p *pullbox) Pull(ctx context.Context) error {
 	}
 
 	if p.IsTextDevboxConfig() {
-		return p.pullTextDevboxConfig()
+		return p.pullTextDevboxConfig(ctx)
 	}
 
 	if isArchive, err := urlIsArchive(p.URL); err != nil {
diff --git a/internal/shellgen/flake_input.go b/internal/shellgen/flake_input.go
index 8c02fea5..77271d3f 100644
--- a/internal/shellgen/flake_input.go
+++ b/internal/shellgen/flake_input.go
@@ -85,7 +85,7 @@ func (f *flakeInput) BuildInputsForSymlinkJoin() ([]*SymlinkJoin, error) {
 			return nil, err
 		}
 
-		if pkg.PatchGlibc {
+		if pkg.PatchGlibc() {
 			return nil, errors.New("patch_glibc is not yet supported for packages with non-default outputs")
 		}
 
@@ -126,7 +126,7 @@ func (f *flakeInput) BuildInputs() ([]string, error) {
 		if attributePathErr != nil {
 			err = attributePathErr
 		}
-		if pkg.PatchGlibc {
+		if pkg.PatchGlibc() {
 			// When the package comes from the glibc flake, the
 			// "legacyPackages" portion of the attribute path
 			// becomes just "packages" (matching the standard flake
@@ -182,7 +182,7 @@ func flakeInputs(ctx context.Context, packages []*devpkg.Package) []flakeInput {
 		// Packages that need a glibc patch are assigned to the special
 		// glibc-patched flake input. This input refers to the
 		// glibc-patch.nix flake.
-		if pkg.PatchGlibc {
+		if pkg.PatchGlibc() {
 			nixpkgsGlibc := flakeInputs.getOrAppend(glibcPatchFlakeRef)
 			nixpkgsGlibc.Name = "glibc-patch"
 			nixpkgsGlibc.URL = glibcPatchFlakeRef
diff --git a/internal/shellgen/flake_plan.go b/internal/shellgen/flake_plan.go
index 4bdc9917..19d4db9a 100644
--- a/internal/shellgen/flake_plan.go
+++ b/internal/shellgen/flake_plan.go
@@ -25,22 +25,13 @@ func newFlakePlan(ctx context.Context, devbox devboxer) (*flakePlan, error) {
 	ctx, task := trace.NewTask(ctx, "devboxFlakePlan")
 	defer task.End()
 
-	for _, included := range devbox.Config().Include() {
-		// This is a slightly weird place to put this, but since includes can't be
-		// added via command and we need them to be added before we call
-		// plugin manager.Include
-		if err := devbox.Lockfile().Add(included); err != nil {
-			return nil, err
-		}
-		if err := devbox.PluginManager().Include(included); err != nil {
+	for _, pluginConfig := range devbox.Config().IncludedPluginConfigs() {
+		if err := devbox.PluginManager().CreateFilesForConfig(pluginConfig); err != nil {
 			return nil, err
 		}
 	}
 
-	packages, err := devbox.AllInstallablePackages()
-	if err != nil {
-		return nil, err
-	}
+	packages := devbox.InstallablePackages()
 
 	// Fill the NarInfo Cache concurrently as a perf-optimization, prior to invoking
 	// IsInBinaryCache in flakeInputs() and in the flake.nix template.
@@ -98,7 +89,7 @@ type glibcPatchFlake struct {
 func newGlibcPatchFlake(nixpkgsGlibcRev string, packages []*devpkg.Package) (glibcPatchFlake, error) {
 	flake := glibcPatchFlake{NixpkgsGlibcFlakeRef: "flake:nixpkgs/" + nixpkgsGlibcRev}
 	for _, pkg := range packages {
-		if !pkg.PatchGlibc {
+		if !pkg.PatchGlibc() {
 			continue
 		}
 
diff --git a/internal/shellgen/scripts.go b/internal/shellgen/scripts.go
index 5b8d307b..9f5f84ad 100644
--- a/internal/shellgen/scripts.go
+++ b/internal/shellgen/scripts.go
@@ -39,7 +39,6 @@
 type devboxer interface {
 	Config() *devconfig.Config
 	Lockfile() *lock.File
-	AllInstallablePackages() ([]*devpkg.Package, error)
 	InstallablePackages() []*devpkg.Package
 	PluginManager() *plugin.Manager
 	ProjectDir() string
@@ -63,16 +62,8 @@ func WriteScriptsToFiles(devbox devboxer) error {
 
 	// Write all hooks to a file.
 	written := map[string]struct{}{} // set semantics; value is irrelevant
-	pluginHooks, err := devbox.PluginManager().InitHooks(
-		devbox.InstallablePackages(),
-		devbox.Config().Include(),
-	)
-	if err != nil {
-		return errors.WithStack(err)
-	}
-	hooks := strings.Join(append(pluginHooks, devbox.Config().InitHook().String()), "\n\n")
 	// always write it, even if there are no hooks, because scripts will source it.
-	err = writeRawInitHookFile(devbox, hooks)
+	err = writeRawInitHookFile(devbox, devbox.Config().InitHook().String())
 	if err != nil {
 		return errors.WithStack(err)
 	}


Test patch:
diff --git a/examples/plugins/v2-github/test.sh b/examples/plugins/v2-github/test.sh
new file mode 100755
index 00000000..2353ad15
--- /dev/null
+++ b/examples/plugins/v2-github/test.sh
@@ -0,0 +1,11 @@
+#!/bin/bash
+
+expected="I AM SET (new value)"
+custom_expected="I AM SET TO CUSTOM (new value)"
+if [ "$MY_ENV_VAR" == "$expected" ] && [ "$MY_ENV_VAR_CUSTOM" == "$custom_expected" ]; then
+  echo "Success! MY_ENV_VAR is set to '$MY_ENV_VAR'"
+  echo "Success! MY_ENV_VAR_CUSTOM is set to '$MY_ENV_VAR_CUSTOM'"
+else
+  echo "ERROR: MY_ENV_VAR environment variable is not set to '$expected' OR MY_ENV_VAR_CUSTOM variable is not set to '$custom_expected'"
+  exit 1
+fi
diff --git a/internal/devbox/dir_test.go b/internal/devbox/dir_test.go
index 950c4774..8a88ad71 100644
--- a/internal/devbox/dir_test.go
+++ b/internal/devbox/dir_test.go
@@ -9,7 +9,7 @@
 	"testing"
 
 	"github.com/stretchr/testify/assert"
-	"go.jetpack.io/devbox/internal/devconfig"
+	"go.jetpack.io/devbox/internal/devconfig/configfile"
 )
 
 func TestFindProjectDirFromParentDirSearch(t *testing.T) {
@@ -53,7 +53,7 @@ func TestFindProjectDirFromParentDirSearch(t *testing.T) {
 			err = os.MkdirAll(filepath.Join(root, testCase.allDirs), 0o777)
 			assert.NoError(err)
 
-			absProjectPath, err := filepath.Abs(filepath.Join(root, testCase.projectDir, devconfig.ValidConfigNames()[0]))
+			absProjectPath, err := filepath.Abs(filepath.Join(root, testCase.projectDir, configfile.ValidConfigNames()[0]))
 			assert.NoError(err)
 			err = os.WriteFile(absProjectPath, []byte("{}"), 0o666)
 			assert.NoError(err)
@@ -97,14 +97,14 @@ func TestFindParentDirAtPath(t *testing.T) {
 			name:        "flag_path_is_file_has_config",
 			allDirs:     "a/b/c",
 			projectDir:  "a/b",
-			flagPath:    "a/b/" + devconfig.ValidConfigNames()[0],
+			flagPath:    "a/b/" + configfile.ValidConfigNames()[0],
 			expectError: false,
 		},
 		{
 			name:        "flag_path_is_file_missing_config",
 			allDirs:     "a/b/c",
 			projectDir:  "", // missing config
-			flagPath:    "a/b/" + devconfig.ValidConfigNames()[0],
+			flagPath:    "a/b/" + configfile.ValidConfigNames()[0],
 			expectError: true,
 		},
 	}
@@ -121,7 +121,7 @@ func TestFindParentDirAtPath(t *testing.T) {
 
 			var absProjectPath string
 			if testCase.projectDir != "" {
-				absProjectPath, err = filepath.Abs(filepath.Join(root, testCase.projectDir, devconfig.ValidConfigNames()[0]))
+				absProjectPath, err = filepath.Abs(filepath.Join(root, testCase.projectDir, configfile.ValidConfigNames()[0]))
 				assert.NoError(err)
 				err = os.WriteFile(absProjectPath, []byte("{}"), 0o666)
 				assert.NoError(err)
diff --git a/internal/devconfig/config_test.go b/internal/devconfig/config_test.go
index 797858d5..5792e503 100644
--- a/internal/devconfig/config_test.go
+++ b/internal/devconfig/config_test.go
@@ -1,707 +1,31 @@
-//nolint:varnamelen
 package devconfig
 
 import (
-	"encoding/json"
-	"io"
 	"path/filepath"
-	"strings"
 	"testing"
 
 	"github.com/google/go-cmp/cmp"
 	"github.com/google/go-cmp/cmp/cmpopts"
-	"github.com/stretchr/testify/assert"
 	"github.com/tailscale/hujson"
-	"golang.org/x/tools/txtar"
+	"go.jetpack.io/devbox/internal/devconfig/configfile"
 )
 
-/*
-The tests in this file use txtar to define test input and expected output.
-This makes the JSON a lot easier to read vs. defining it in variables or structs
-with weird indentation.
-
-Tests begin by defining their JSON with:
-
-  in, want := parseConfigTxtarTest(t, `an optional comment that will be logged with t.Log
-  -- in --
-  { }
-  -- want --
-  { "packages": { "go": "latest" } }`)
-*/
-
-func parseConfigTxtarTest(t *testing.T, test string) (in *ConfigFile, want []byte) {
-	t.Helper()
-
-	ar := txtar.Parse([]byte(test))
-	if comment := strings.TrimSpace(string(ar.Comment)); comment != "" {
-		t.Log(comment)
-	}
-	for _, f := range ar.Files {
-		switch f.Name {
-		case "in":
-			var err error
-			in, err = loadBytes(f.Data)
-			if err != nil {
-				t.Fatalf("input devbox.json is invalid: %v\n%s", err, f.Data)
-			}
-
-		case "want":
-			want = f.Data
-		}
-	}
-	return in, want
-}
-
-func optBytesToStrings() cmp.Option {
-	return cmp.Transformer("bytesToStrings", func(b []byte) string {
-		return string(b)
-	})
-}
-
-func optParseHujson() cmp.Option {
-	f := func(b []byte) map[string]any {
-		gotMin, err := hujson.Minimize(b)
-		if err != nil {
-			return nil
-		}
-		var m map[string]any
-		if err := json.Unmarshal(gotMin, &m); err != nil {
-			return nil
-		}
-		return m
-	}
-	return cmp.Transformer("parseHujson", f)
-}
-
-func TestNoChanges(t *testing.T) {
-	in, want := parseConfigTxtarTest(t, `a config that's loaded and saved without any changes should have unchanged json
--- in --
-{ "packages": { "go": "latest" } }
--- want --
-{ "packages": { "go": "latest" } }`)
-
-	if diff := cmp.Diff(want, in.Bytes(), optParseHujson()); diff != "" {
-		t.Errorf("wrong parsed config json (-want +got):\n%s", diff)
-	}
-	if diff := cmp.Diff(want, in.Bytes()); diff != "" {
-		t.Errorf("wrong raw config hujson (-want +got):\n%s", diff)
-	}
-}
-
-func TestAddPackageEmptyConfig(t *testing.T) {
-	in, want := parseConfigTxtarTest(t, `
--- in --
-{}
--- want --
-{
-  "packages": {
-    "go": "latest"
-  }
-}`)
-
-	in.PackagesMutator.Add("go@latest")
-	if diff := cmp.Diff(want, in.Bytes(), optParseHujson()); diff != "" {
-		t.Errorf("wrong parsed config json (-want +got):\n%s", diff)
-	}
-	if diff := cmp.Diff(want, in.Bytes()); diff != "" {
-		t.Errorf("wrong raw config hujson (-want +got):\n%s", diff)
-	}
-}
-
-func TestAddPackageEmptyConfigWhitespace(t *testing.T) {
-	in, want := parseConfigTxtarTest(t, `
--- in --
-{
-
-}
--- want --
-{
-  "packages": {
-    "go": "latest"
-  }
-}`)
-
-	in.PackagesMutator.Add("go@latest")
-	if diff := cmp.Diff(want, in.Bytes(), optParseHujson()); diff != "" {
-		t.Errorf("wrong parsed config json (-want +got):\n%s", diff)
-	}
-	if diff := cmp.Diff(want, in.Bytes()); diff != "" {
-		t.Errorf("wrong raw config hujson (-want +got):\n%s", diff)
-	}
-}
-
-func TestAddPackageEmptyConfigComment(t *testing.T) {
-	in, want := parseConfigTxtarTest(t, `
--- in --
-// Comment
-{}
--- want --
-// Comment
-{
-  "packages": {
-    "go": "latest",
-  },
-}`)
-
-	in.PackagesMutator.Add("go@latest")
-	if diff := cmp.Diff(want, in.Bytes(), optParseHujson()); diff != "" {
-		t.Errorf("wrong parsed config json (-want +got):\n%s", diff)
-	}
-	if diff := cmp.Diff(want, in.Bytes()); diff != "" {
-		t.Errorf("wrong raw config hujson (-want +got):\n%s", diff)
-	}
-}
-
-func TestAddPackageNull(t *testing.T) {
-	in, want := parseConfigTxtarTest(t, `
--- in --
-{ "packages": null }
--- want --
-{
-  "packages": {
-    "go": "latest"
-  }
-}`)
-
-	in.PackagesMutator.Add("go@latest")
-	if diff := cmp.Diff(want, in.Bytes(), optParseHujson()); diff != "" {
-		t.Errorf("wrong parsed config json (-want +got):\n%s", diff)
-	}
-	if diff := cmp.Diff(want, in.Bytes()); diff != "" {
-		t.Errorf("wrong raw config hujson (-want +got):\n%s", diff)
-	}
-}
-
-func TestAddPackageObject(t *testing.T) {
-	in, want := parseConfigTxtarTest(t, `
--- in --
-{
-  "packages": {
-    "go": "latest"
-  }
-}
--- want --
-{
-  "packages": {
-    "go":     "latest",
-    "python": "3.10"
-  }
-}`)
-
-	in.PackagesMutator.Add("python@3.10")
-	if diff := cmp.Diff(want, in.Bytes(), optParseHujson()); diff != "" {
-		t.Errorf("wrong parsed config json (-want +got):\n%s", diff)
-	}
-	if diff := cmp.Diff(want, in.Bytes()); diff != "" {
-		t.Errorf("wrong raw config hujson (-want +got):\n%s", diff)
-	}
-}
-
-func TestAddPackageObjectComment(t *testing.T) {
-	in, want := parseConfigTxtarTest(t, `
--- in --
-{
-  "packages": {
-    // Package comment
-    "go": "latest"
-  }
-}
--- want --
-{
-  "packages": {
-    // Package comment
-    "go":     "latest",
-    "python": "3.10",
-  },
-}`)
-
-	in.PackagesMutator.Add("python@3.10")
-	if diff := cmp.Diff(want, in.Bytes(), optParseHujson()); diff != "" {
-		t.Errorf("wrong parsed config json (-want +got):\n%s", diff)
-	}
-	if diff := cmp.Diff(want, in.Bytes()); diff != "" {
-		t.Errorf("wrong raw config hujson (-want +got):\n%s", diff)
-	}
-}
-
-func TestAddPackageEmptyArray(t *testing.T) {
-	in, want := parseConfigTxtarTest(t, `
--- in --
-{
-  "packages": []
-}
--- want --
-{
-  "packages": ["go@latest"]
-}`)
-
-	in.PackagesMutator.Add("go@latest")
-	if diff := cmp.Diff(want, in.Bytes(), optParseHujson()); diff != "" {
-		t.Errorf("wrong parsed config json (-want +got):\n%s", diff)
-	}
-	if diff := cmp.Diff(want, in.Bytes()); diff != "" {
-		t.Errorf("wrong raw config hujson (-want +got):\n%s", diff)
-	}
-}
-
-func TestAddPackageOneLineArray(t *testing.T) {
-	in, want := parseConfigTxtarTest(t, `
--- in --
-{
-  "packages": ["go"]
-}
--- want --
-{
-  "packages": [
-    "go",
-    "python@3.10"
-  ]
-}`)
-
-	in.PackagesMutator.Add("python@3.10")
-	if diff := cmp.Diff(want, in.Bytes(), optParseHujson()); diff != "" {
-		t.Errorf("wrong parsed config json (-want +got):\n%s", diff)
-	}
-	if diff := cmp.Diff(want, in.Bytes()); diff != "" {
-		t.Errorf("wrong raw config hujson (-want +got):\n%s", diff)
-	}
-}
-
-func TestAddPackageMultiLineArray(t *testing.T) {
-	in, want := parseConfigTxtarTest(t, `
--- in --
-{
-  "packages": [
-    "go"
-  ]
-}
--- want --
-{
-  "packages": [
-    "go",
-    "python@3.10"
-  ]
-}`)
-
-	in.PackagesMutator.Add("python@3.10")
-	if diff := cmp.Diff(want, in.Bytes(), optParseHujson()); diff != "" {
-		t.Errorf("wrong parsed config json (-want +got):\n%s", diff)
-	}
-	if diff := cmp.Diff(want, in.Bytes()); diff != "" {
-		t.Errorf("wrong raw config hujson (-want +got):\n%s", diff)
-	}
-}
-
-func TestAddPackageArrayComments(t *testing.T) {
-	in, want := parseConfigTxtarTest(t, `
--- in --
-{
-  "packages": [
-    // Go package comment
-    "go",
-
-    // Python package comment
-    "python@3.10"
-  ]
-}
--- want --
-{
-  "packages": [
-    // Go package comment
-    "go",
-
-    // Python package comment
-    "python@3.10",
-    "hello@latest",
-  ],
-}`)
-
-	in.PackagesMutator.Add("hello@latest")
-	if diff := cmp.Diff(want, in.Bytes(), optParseHujson()); diff != "" {
-		t.Errorf("wrong parsed config json (-want +got):\n%s", diff)
-	}
-	if diff := cmp.Diff(want, in.Bytes()); diff != "" {
-		t.Errorf("wrong raw config hujson (-want +got):\n%s", diff)
-	}
-}
-
-func TestRemovePackageObject(t *testing.T) {
-	in, want := parseConfigTxtarTest(t, `
--- in --
-{
-  "packages": {
-    "go": "latest",
-    "python": "3.10"
-  }
-}
--- want --
-{
-  "packages": {
-    "python": "3.10"
-  }
-}`)
-
-	in.PackagesMutator.Remove("go@latest")
-	if diff := cmp.Diff(want, in.Bytes(), optParseHujson()); diff != "" {
-		t.Errorf("wrong parsed config json (-want +got):\n%s", diff)
-	}
-	if diff := cmp.Diff(want, in.Bytes()); diff != "" {
-		t.Errorf("wrong raw config hujson (-want +got):\n%s", diff)
-	}
-}
-
-func TestRemovePackageLastMember(t *testing.T) {
-	in, want := parseConfigTxtarTest(t, `
--- in --
-{
-  "env": {"NAME": "value"},
-  "packages": {
-    "go": "latest"
-  }
-}
--- want --
-{
-  "env":      {"NAME": "value"},
-  "packages": {}
-}`)
-
-	in.PackagesMutator.Remove("go@latest")
-	if diff := cmp.Diff(want, in.Bytes(), optParseHujson()); diff != "" {
-		t.Errorf("wrong parsed config json (-want +got):\n%s", diff)
-	}
-	if diff := cmp.Diff(want, in.Bytes(), optBytesToStrings()); diff != "" {
-		t.Errorf("wrong raw config hujson (-want +got):\n%s", diff)
-	}
-}
-
-func TestRemovePackageArray(t *testing.T) {
-	in, want := parseConfigTxtarTest(t, `
--- in --
-{
-  "packages": ["go@latest", "python@3.10"]
-}
--- want --
-{
-  "packages": ["python@3.10"]
-}`)
-
-	in.PackagesMutator.Remove("go@latest")
-	if diff := cmp.Diff(want, in.Bytes(), optParseHujson()); diff != "" {
-		t.Errorf("wrong parsed config json (-want +got):\n%s", diff)
-	}
-	if diff := cmp.Diff(want, in.Bytes()); diff != "" {
-		t.Errorf("wrong raw config hujson (-want +got):\n%s", diff)
-	}
-}
-
-func TestRemovePackageLastElement(t *testing.T) {
-	in, want := parseConfigTxtarTest(t, `
--- in --
-{
-  "packages": ["go@latest"],
-  "env": {
-    "NAME": "value"
-  }
-}
--- want --
-{
-  "packages": [],
-  "env": {
-    "NAME": "value"
-  }
-}`)
-
-	in.PackagesMutator.Remove("go@latest")
-	if diff := cmp.Diff(want, in.Bytes(), optParseHujson()); diff != "" {
-		t.Errorf("wrong parsed config json (-want +got):\n%s", diff)
-	}
-	if diff := cmp.Diff(want, in.Bytes()); diff != "" {
-		t.Errorf("wrong raw config hujson (-want +got):\n%s", diff)
-	}
-}
-
-func TestAddPlatforms(t *testing.T) {
-	in, want := parseConfigTxtarTest(t, `
--- in --
-{
-  "packages": {
-    "go": {
-      "version": "1.20"
-    },
-    "python": {
-      "version": "3.10",
-      "platforms": [
-        "x86_64-linux"
-      ]
-    },
-    "hello": {
-      "version": "latest",
-      "platforms": ["x86_64-linux"]
-    },
-    "vim": {
-      "version": "latest"
-    }
-  }
-}
--- want --
-{
-  "packages": {
-    "go": {
-      "version":   "1.20",
-      "platforms": ["aarch64-darwin", "x86_64-darwin"]
-    },
-    "python": {
-      "version": "3.10",
-      "platforms": [
-        "x86_64-linux",
-        "x86_64-darwin"
-      ]
-    },
-    "hello": {
-      "version":   "latest",
-      "platforms": ["x86_64-linux", "x86_64-darwin"]
-    },
-    "vim": {
-      "version": "latest"
-    }
-  }
-}`)
-
-	err := in.PackagesMutator.AddPlatforms(io.Discard, "go@1.20", []string{"aarch64-darwin", "x86_64-darwin"})
-	if err != nil {
-		t.Error(err)
-	}
-	err = in.PackagesMutator.AddPlatforms(io.Discard, "python@3.10", []string{"x86_64-darwin"})
-	if err != nil {
-		t.Error(err)
-	}
-	err = in.PackagesMutator.AddPlatforms(io.Discard, "hello@latest", []string{"x86_64-darwin"})
-	if err != nil {
-		t.Error(err)
-	}
-	if diff := cmp.Diff(want, in.Bytes(), optParseHujson()); diff != "" {
-		t.Errorf("wrong parsed config json (-want +got):\n%s", diff)
-	}
-	if diff := cmp.Diff(want, in.Bytes()); diff != "" {
-		t.Errorf("wrong raw config hujson (-want +got):\n%s", diff)
-	}
-}
-
-func TestAddPlatformsMigrateArray(t *testing.T) {
-	in, want := parseConfigTxtarTest(t, `
--- in --
-{
-  "packages": ["go", "python@3.10", "hello"]
-}
--- want --
-{
-  "packages": {
-    "go": {
-      "platforms": ["aarch64-darwin"]
-    },
-    "python": {
-      "version":   "3.10",
-      "platforms": ["x86_64-darwin", "x86_64-linux"]
-    },
-    "hello": ""
-  }
-}`)
-
-	err := in.PackagesMutator.AddPlatforms(io.Discard, "go", []string{"aarch64-darwin"})
-	if err != nil {
-		t.Error(err)
-	}
-	err = in.PackagesMutator.AddPlatforms(io.Discard, "python@3.10", []string{"x86_64-darwin", "x86_64-linux"})
-	if err != nil {
-		t.Error(err)
-	}
-	if diff := cmp.Diff(want, in.Bytes(), optParseHujson()); diff != "" {
-		t.Errorf("wrong parsed config json (-want +got):\n%s", diff)
-	}
-	if diff := cmp.Diff(want, in.Bytes()); diff != "" {
-		t.Errorf("wrong raw config hujson (-want +got):\n%s", diff)
-	}
-}
-
-func TestAddPlatformsMigrateArrayComments(t *testing.T) {
-	in, want := parseConfigTxtarTest(t, `
--- in --
-{
-  "packages": [
-    // Go comment
-    "go",
-
-    // Python comment
-    "python@3.10"
-  ]
-}
--- want --
-{
-  "packages": {
-    // Go comment
-    "go": "",
-    // Python comment
-    "python": {
-      "version":   "3.10",
-      "platforms": ["x86_64-darwin", "x86_64-linux"],
-    },
-  },
-}`)
-
-	err := in.PackagesMutator.AddPlatforms(io.Discard, "python@3.10", []string{"x86_64-darwin", "x86_64-linux"})
-	if err != nil {
-		t.Error(err)
-	}
-	if diff := cmp.Diff(want, in.Bytes(), optParseHujson()); diff != "" {
-		t.Errorf("wrong parsed config json (-want +got):\n%s", diff)
-	}
-	if diff := cmp.Diff(want, in.Bytes()); diff != "" {
-		t.Errorf("wrong raw config hujson (-want +got):\n%s", diff)
-	}
-}
-
-func TestExcludePlatforms(t *testing.T) {
-	in, want := parseConfigTxtarTest(t, `
--- in --
-{
-  "packages": {
-    "go": {
-      "version": "1.20"
-    }
-  }
-}
--- want --
-{
-  "packages": {
-    "go": {
-      "version":            "1.20",
-      "excluded_platforms": ["aarch64-darwin"]
-    }
-  }
-}`)
-
-	err := in.PackagesMutator.ExcludePlatforms(io.Discard, "go@1.20", []string{"aarch64-darwin"})
-	if err != nil {
-		t.Error(err)
-	}
-	if diff := cmp.Diff(want, in.Bytes(), optParseHujson()); diff != "" {
-		t.Errorf("wrong parsed config json (-want +got):\n%s", diff)
-	}
-	if diff := cmp.Diff(want, in.Bytes()); diff != "" {
-		t.Errorf("wrong raw config hujson (-want +got):\n%s", diff)
-	}
-}
-
-func TestSetOutputs(t *testing.T) {
-	in, want := parseConfigTxtarTest(t, `
--- in --
-{
-  "packages": {
-    "prometheus": {
-      "version": "latest"
-    }
-  }
-}
--- want --
-{
-  "packages": {
-    "prometheus": {
-      "version": "latest",
-      "outputs": ["cli"]
-    }
-  }
-}`)
-
-	err := in.PackagesMutator.SetOutputs(io.Discard, "prometheus@latest", []string{"cli"})
-	if err != nil {
-		t.Error(err)
-	}
-	if diff := cmp.Diff(want, in.Bytes(), optParseHujson()); diff != "" {
-		t.Errorf("wrong parsed config json (-want +got):\n%s", diff)
-	}
-	if diff := cmp.Diff(want, in.Bytes()); diff != "" {
-		t.Errorf("wrong raw config hujson (-want +got):\n%s", diff)
-	}
-}
-
-func TestSetOutputsMigrateArray(t *testing.T) {
-	in, want := parseConfigTxtarTest(t, `
--- in --
-{
-  "packages": ["go", "python@3.10", "prometheus@latest"]
-}
--- want --
-{
-  "packages": {
-    "go":     "",
-    "python": "3.10",
-    "prometheus": {
-      "version": "latest",
-      "outputs": ["cli"]
-    }
-  }
-}`)
-
-	err := in.PackagesMutator.SetOutputs(io.Discard, "prometheus@latest", []string{"cli"})
-	if err != nil {
-		t.Error(err)
-	}
-	if diff := cmp.Diff(want, in.Bytes(), optParseHujson()); diff != "" {
-		t.Errorf("wrong parsed config json (-want +got):\n%s", diff)
-	}
-	if diff := cmp.Diff(want, in.Bytes()); diff != "" {
-		t.Errorf("wrong raw config hujson (-want +got):\n%s", diff)
-	}
-}
-
-func TestSetAllowInsecure(t *testing.T) {
-	in, want := parseConfigTxtarTest(t, `
--- in --
-{
-  "packages": {
-    "python": {
-      "version": "2.7"
-    }
-  }
-}
--- want --
-{
-  "packages": {
-    "python": {
-      "version":        "2.7",
-      "allow_insecure": ["python-2.7.18.1"]
-    }
-  }
-}`)
-
-	err := in.PackagesMutator.SetAllowInsecure(io.Discard, "python@2.7", []string{"python-2.7.18.1"})
-	if err != nil {
-		t.Error(err)
-	}
-	if diff := cmp.Diff(want, in.Bytes(), optParseHujson()); diff != "" {
-		t.Errorf("wrong parsed config json (-want +got):\n%s", diff)
-	}
-	if diff := cmp.Diff(want, in.Bytes()); diff != "" {
-		t.Errorf("wrong raw config hujson (-want +got):\n%s", diff)
-	}
-}
-
 func TestDefault(t *testing.T) {
 	path := filepath.Join(t.TempDir())
-	in := DefaultConfig()
-	inBytes := in.Bytes()
+	cfg := DefaultConfig()
+	inBytes := cfg.Root.Bytes()
 	if _, err := hujson.Parse(inBytes); err != nil {
 		t.Fatalf("default config JSON is invalid: %v\n%s", err, inBytes)
 	}
-	err := in.SaveTo(path)
+	err := cfg.Root.SaveTo(path)
 	if err != nil {
 		t.Fatal("got save error:", err)
 	}
-	out, err := Load(filepath.Join(path, defaultName))
+	out, err := LoadForTest(filepath.Join(path, configfile.DefaultName))
 	if err != nil {
 		t.Fatal("got load error:", err)
 	}
-	if diff := cmp.Diff(in, &out.Root, cmpopts.IgnoreUnexported(ConfigFile{}, packagesMutator{})); diff != "" {
+	if diff := cmp.Diff(cfg, out, cmpopts.IgnoreUnexported(configfile.ConfigFile{}, configfile.PackagesMutator{}, Config{})); diff != "" {
 		t.Errorf("configs not equal (-in +out):\n%s", diff)
 	}
 
@@ -713,30 +37,3 @@ func TestDefault(t *testing.T) {
 		t.Errorf("got different JSON after load/save/load:\ninput:\n%s\noutput:\n%s", inBytes, outBytes)
 	}
 }
-
-func TestNixpkgsValidation(t *testing.T) {
-	testCases := map[string]struct {
-		commit   string
-		isErrant bool
-	}{
-		"invalid_nixpkg_commit": {"1234545", true},
-		"valid_nixpkg_commit":   {"af9e00071d0971eb292fd5abef334e66eda3cb69", false},
-	}
-
-	for name, testCase := range testCases {
-		t.Run(name, func(t *testing.T) {
-			assert := assert.New(t)
-
-			err := ValidateNixpkg(&ConfigFile{
-				Nixpkgs: &NixpkgsConfig{
-					Commit: testCase.commit,
-				},
-			})
-			if testCase.isErrant {
-				assert.Error(err)
-			} else {
-				assert.NoError(err)
-			}
-		})
-	}
-}
diff --git a/internal/devconfig/configfile/file_test.go b/internal/devconfig/configfile/file_test.go
new file mode 100644
index 00000000..661cc799
--- /dev/null
+++ b/internal/devconfig/configfile/file_test.go
@@ -0,0 +1,712 @@
+//nolint:varnamelen
+package configfile
+
+import (
+	"encoding/json"
+	"io"
+	"strings"
+	"testing"
+
+	"github.com/google/go-cmp/cmp"
+	"github.com/stretchr/testify/assert"
+	"github.com/tailscale/hujson"
+	"golang.org/x/tools/txtar"
+)
+
+/*
+The tests in this file use txtar to define test input and expected output.
+This makes the JSON a lot easier to read vs. defining it in variables or structs
+with weird indentation.
+
+Tests begin by defining their JSON with:
+
+  in, want := parseConfigTxtarTest(t, `an optional comment that will be logged with t.Log
+  -- in --
+  { }
+  -- want --
+  { "packages": { "go": "latest" } }`)
+*/
+
+func parseConfigTxtarTest(t *testing.T, test string) (in *ConfigFile, want []byte) {
+	t.Helper()
+
+	ar := txtar.Parse([]byte(test))
+	if comment := strings.TrimSpace(string(ar.Comment)); comment != "" {
+		t.Log(comment)
+	}
+	for _, f := range ar.Files {
+		switch f.Name {
+		case "in":
+			var err error
+			in, err = LoadBytes(f.Data)
+			if err != nil {
+				t.Fatalf("input devbox.json is invalid: %v\n%s", err, f.Data)
+			}
+
+		case "want":
+			want = f.Data
+		}
+	}
+	return in, want
+}
+
+func optBytesToStrings() cmp.Option {
+	return cmp.Transformer("bytesToStrings", func(b []byte) string {
+		return string(b)
+	})
+}
+
+func optParseHujson() cmp.Option {
+	f := func(b []byte) map[string]any {
+		gotMin, err := hujson.Minimize(b)
+		if err != nil {
+			return nil
+		}
+		var m map[string]any
+		if err := json.Unmarshal(gotMin, &m); err != nil {
+			return nil
+		}
+		return m
+	}
+	return cmp.Transformer("parseHujson", f)
+}
+
+func TestNoChanges(t *testing.T) {
+	in, want := parseConfigTxtarTest(t, `a config that's loaded and saved without any changes should have unchanged json
+-- in --
+{ "packages": { "go": "latest" } }
+-- want --
+{ "packages": { "go": "latest" } }`)
+
+	if diff := cmp.Diff(want, in.Bytes(), optParseHujson()); diff != "" {
+		t.Errorf("wrong parsed config json (-want +got):\n%s", diff)
+	}
+	if diff := cmp.Diff(want, in.Bytes()); diff != "" {
+		t.Errorf("wrong raw config hujson (-want +got):\n%s", diff)
+	}
+}
+
+func TestAddPackageEmptyConfig(t *testing.T) {
+	in, want := parseConfigTxtarTest(t, `
+-- in --
+{}
+-- want --
+{
+  "packages": {
+    "go": "latest"
+  }
+}`)
+
+	in.PackagesMutator.Add("go@latest")
+	if diff := cmp.Diff(want, in.Bytes(), optParseHujson()); diff != "" {
+		t.Errorf("wrong parsed config json (-want +got):\n%s", diff)
+	}
+	if diff := cmp.Diff(want, in.Bytes()); diff != "" {
+		t.Errorf("wrong raw config hujson (-want +got):\n%s", diff)
+	}
+}
+
+func TestAddPackageEmptyConfigWhitespace(t *testing.T) {
+	in, want := parseConfigTxtarTest(t, `
+-- in --
+{
+
+}
+-- want --
+{
+  "packages": {
+    "go": "latest"
+  }
+}`)
+
+	in.PackagesMutator.Add("go@latest")
+	if diff := cmp.Diff(want, in.Bytes(), optParseHujson()); diff != "" {
+		t.Errorf("wrong parsed config json (-want +got):\n%s", diff)
+	}
+	if diff := cmp.Diff(want, in.Bytes()); diff != "" {
+		t.Errorf("wrong raw config hujson (-want +got):\n%s", diff)
+	}
+}
+
+func TestAddPackageEmptyConfigComment(t *testing.T) {
+	in, want := parseConfigTxtarTest(t, `
+-- in --
+// Comment
+{}
+-- want --
+// Comment
+{
+  "packages": {
+    "go": "latest",
+  },
+}`)
+
+	in.PackagesMutator.Add("go@latest")
+	if diff := cmp.Diff(want, in.Bytes(), optParseHujson()); diff != "" {
+		t.Errorf("wrong parsed config json (-want +got):\n%s", diff)
+	}
+	if diff := cmp.Diff(want, in.Bytes()); diff != "" {
+		t.Errorf("wrong raw config hujson (-want +got):\n%s", diff)
+	}
+}
+
+func TestAddPackageNull(t *testing.T) {
+	in, want := parseConfigTxtarTest(t, `
+-- in --
+{ "packages": null }
+-- want --
+{
+  "packages": {
+    "go": "latest"
+  }
+}`)
+
+	in.PackagesMutator.Add("go@latest")
+	if diff := cmp.Diff(want, in.Bytes(), optParseHujson()); diff != "" {
+		t.Errorf("wrong parsed config json (-want +got):\n%s", diff)
+	}
+	if diff := cmp.Diff(want, in.Bytes()); diff != "" {
+		t.Errorf("wrong raw config hujson (-want +got):\n%s", diff)
+	}
+}
+
+func TestAddPackageObject(t *testing.T) {
+	in, want := parseConfigTxtarTest(t, `
+-- in --
+{
+  "packages": {
+    "go": "latest"
+  }
+}
+-- want --
+{
+  "packages": {
+    "go":     "latest",
+    "python": "3.10"
+  }
+}`)
+
+	in.PackagesMutator.Add("python@3.10")
+	if diff := cmp.Diff(want, in.Bytes(), optParseHujson()); diff != "" {
+		t.Errorf("wrong parsed config json (-want +got):\n%s", diff)
+	}
+	if diff := cmp.Diff(want, in.Bytes()); diff != "" {
+		t.Errorf("wrong raw config hujson (-want +got):\n%s", diff)
+	}
+}
+
+func TestAddPackageObjectComment(t *testing.T) {
+	in, want := parseConfigTxtarTest(t, `
+-- in --
+{
+  "packages": {
+    // Package comment
+    "go": "latest"
+  }
+}
+-- want --
+{
+  "packages": {
+    // Package comment
+    "go":     "latest",
+    "python": "3.10",
+  },
+}`)
+
+	in.PackagesMutator.Add("python@3.10")
+	if diff := cmp.Diff(want, in.Bytes(), optParseHujson()); diff != "" {
+		t.Errorf("wrong parsed config json (-want +got):\n%s", diff)
+	}
+	if diff := cmp.Diff(want, in.Bytes()); diff != "" {
+		t.Errorf("wrong raw config hujson (-want +got):\n%s", diff)
+	}
+}
+
+func TestAddPackageEmptyArray(t *testing.T) {
+	in, want := parseConfigTxtarTest(t, `
+-- in --
+{
+  "packages": []
+}
+-- want --
+{
+  "packages": ["go@latest"]
+}`)
+
+	in.PackagesMutator.Add("go@latest")
+	if diff := cmp.Diff(want, in.Bytes(), optParseHujson()); diff != "" {
+		t.Errorf("wrong parsed config json (-want +got):\n%s", diff)
+	}
+	if diff := cmp.Diff(want, in.Bytes()); diff != "" {
+		t.Errorf("wrong raw config hujson (-want +got):\n%s", diff)
+	}
+}
+
+func TestAddPackageOneLineArray(t *testing.T) {
+	in, want := parseConfigTxtarTest(t, `
+-- in --
+{
+  "packages": ["go"]
+}
+-- want --
+{
+  "packages": [
+    "go",
+    "python@3.10"
+  ]
+}`)
+
+	in.PackagesMutator.Add("python@3.10")
+	if diff := cmp.Diff(want, in.Bytes(), optParseHujson()); diff != "" {
+		t.Errorf("wrong parsed config json (-want +got):\n%s", diff)
+	}
+	if diff := cmp.Diff(want, in.Bytes()); diff != "" {
+		t.Errorf("wrong raw config hujson (-want +got):\n%s", diff)
+	}
+}
+
+func TestAddPackageMultiLineArray(t *testing.T) {
+	in, want := parseConfigTxtarTest(t, `
+-- in --
+{
+  "packages": [
+    "go"
+  ]
+}
+-- want --
+{
+  "packages": [
+    "go",
+    "python@3.10"
+  ]
+}`)
+
+	in.PackagesMutator.Add("python@3.10")
+	if diff := cmp.Diff(want, in.Bytes(), optParseHujson()); diff != "" {
+		t.Errorf("wrong parsed config json (-want +got):\n%s", diff)
+	}
+	if diff := cmp.Diff(want, in.Bytes()); diff != "" {
+		t.Errorf("wrong raw config hujson (-want +got):\n%s", diff)
+	}
+}
+
+func TestAddPackageArrayComments(t *testing.T) {
+	in, want := parseConfigTxtarTest(t, `
+-- in --
+{
+  "packages": [
+    // Go package comment
+    "go",
+
+    // Python package comment
+    "python@3.10"
+  ]
+}
+-- want --
+{
+  "packages": [
+    // Go package comment
+    "go",
+
+    // Python package comment
+    "python@3.10",
+    "hello@latest",
+  ],
+}`)
+
+	in.PackagesMutator.Add("hello@latest")
+	if diff := cmp.Diff(want, in.Bytes(), optParseHujson()); diff != "" {
+		t.Errorf("wrong parsed config json (-want +got):\n%s", diff)
+	}
+	if diff := cmp.Diff(want, in.Bytes()); diff != "" {
+		t.Errorf("wrong raw config hujson (-want +got):\n%s", diff)
+	}
+}
+
+func TestRemovePackageObject(t *testing.T) {
+	in, want := parseConfigTxtarTest(t, `
+-- in --
+{
+  "packages": {
+    "go": "latest",
+    "python": "3.10"
+  }
+}
+-- want --
+{
+  "packages": {
+    "python": "3.10"
+  }
+}`)
+
+	in.PackagesMutator.Remove("go@latest")
+	if diff := cmp.Diff(want, in.Bytes(), optParseHujson()); diff != "" {
+		t.Errorf("wrong parsed config json (-want +got):\n%s", diff)
+	}
+	if diff := cmp.Diff(want, in.Bytes()); diff != "" {
+		t.Errorf("wrong raw config hujson (-want +got):\n%s", diff)
+	}
+}
+
+func TestRemovePackageLastMember(t *testing.T) {
+	in, want := parseConfigTxtarTest(t, `
+-- in --
+{
+  "env": {"NAME": "value"},
+  "packages": {
+    "go": "latest"
+  }
+}
+-- want --
+{
+  "env":      {"NAME": "value"},
+  "packages": {}
+}`)
+
+	in.PackagesMutator.Remove("go@latest")
+	if diff := cmp.Diff(want, in.Bytes(), optParseHujson()); diff != "" {
+		t.Errorf("wrong parsed config json (-want +got):\n%s", diff)
+	}
+	if diff := cmp.Diff(want, in.Bytes(), optBytesToStrings()); diff != "" {
+		t.Errorf("wrong raw config hujson (-want +got):\n%s", diff)
+	}
+}
+
+func TestRemovePackageArray(t *testing.T) {
+	in, want := parseConfigTxtarTest(t, `
+-- in --
+{
+  "packages": ["go@latest", "python@3.10"]
+}
+-- want --
+{
+  "packages": ["python@3.10"]
+}`)
+
+	in.PackagesMutator.Remove("go@latest")
+	if diff := cmp.Diff(want, in.Bytes(), optParseHujson()); diff != "" {
+		t.Errorf("wrong parsed config json (-want +got):\n%s", diff)
+	}
+	if diff := cmp.Diff(want, in.Bytes()); diff != "" {
+		t.Errorf("wrong raw config hujson (-want +got):\n%s", diff)
+	}
+}
+
+func TestRemovePackageLastElement(t *testing.T) {
+	in, want := parseConfigTxtarTest(t, `
+-- in --
+{
+  "packages": ["go@latest"],
+  "env": {
+    "NAME": "value"
+  }
+}
+-- want --
+{
+  "packages": [],
+  "env": {
+    "NAME": "value"
+  }
+}`)
+
+	in.PackagesMutator.Remove("go@latest")
+	if diff := cmp.Diff(want, in.Bytes(), optParseHujson()); diff != "" {
+		t.Errorf("wrong parsed config json (-want +got):\n%s", diff)
+	}
+	if diff := cmp.Diff(want, in.Bytes()); diff != "" {
+		t.Errorf("wrong raw config hujson (-want +got):\n%s", diff)
+	}
+}
+
+func TestAddPlatforms(t *testing.T) {
+	in, want := parseConfigTxtarTest(t, `
+-- in --
+{
+  "packages": {
+    "go": {
+      "version": "1.20"
+    },
+    "python": {
+      "version": "3.10",
+      "platforms": [
+        "x86_64-linux"
+      ]
+    },
+    "hello": {
+      "version": "latest",
+      "platforms": ["x86_64-linux"]
+    },
+    "vim": {
+      "version": "latest"
+    }
+  }
+}
+-- want --
+{
+  "packages": {
+    "go": {
+      "version":   "1.20",
+      "platforms": ["aarch64-darwin", "x86_64-darwin"]
+    },
+    "python": {
+      "version": "3.10",
+      "platforms": [
+        "x86_64-linux",
+        "x86_64-darwin"
+      ]
+    },
+    "hello": {
+      "version":   "latest",
+      "platforms": ["x86_64-linux", "x86_64-darwin"]
+    },
+    "vim": {
+      "version": "latest"
+    }
+  }
+}`)
+
+	err := in.PackagesMutator.AddPlatforms(io.Discard, "go@1.20", []string{"aarch64-darwin", "x86_64-darwin"})
+	if err != nil {
+		t.Error(err)
+	}
+	err = in.PackagesMutator.AddPlatforms(io.Discard, "python@3.10", []string{"x86_64-darwin"})
+	if err != nil {
+		t.Error(err)
+	}
+	err = in.PackagesMutator.AddPlatforms(io.Discard, "hello@latest", []string{"x86_64-darwin"})
+	if err != nil {
+		t.Error(err)
+	}
+	if diff := cmp.Diff(want, in.Bytes(), optParseHujson()); diff != "" {
+		t.Errorf("wrong parsed config json (-want +got):\n%s", diff)
+	}
+	if diff := cmp.Diff(want, in.Bytes()); diff != "" {
+		t.Errorf("wrong raw config hujson (-want +got):\n%s", diff)
+	}
+}
+
+func TestAddPlatformsMigrateArray(t *testing.T) {
+	in, want := parseConfigTxtarTest(t, `
+-- in --
+{
+  "packages": ["go", "python@3.10", "hello"]
+}
+-- want --
+{
+  "packages": {
+    "go": {
+      "platforms": ["aarch64-darwin"]
+    },
+    "python": {
+      "version":   "3.10",
+      "platforms": ["x86_64-darwin", "x86_64-linux"]
+    },
+    "hello": ""
+  }
+}`)
+
+	err := in.PackagesMutator.AddPlatforms(io.Discard, "go", []string{"aarch64-darwin"})
+	if err != nil {
+		t.Error(err)
+	}
+	err = in.PackagesMutator.AddPlatforms(io.Discard, "python@3.10", []string{"x86_64-darwin", "x86_64-linux"})
+	if err != nil {
+		t.Error(err)
+	}
+	if diff := cmp.Diff(want, in.Bytes(), optParseHujson()); diff != "" {
+		t.Errorf("wrong parsed config json (-want +got):\n%s", diff)
+	}
+	if diff := cmp.Diff(want, in.Bytes()); diff != "" {
+		t.Errorf("wrong raw config hujson (-want +got):\n%s", diff)
+	}
+}
+
+func TestAddPlatformsMigrateArrayComments(t *testing.T) {
+	in, want := parseConfigTxtarTest(t, `
+-- in --
+{
+  "packages": [
+    // Go comment
+    "go",
+
+    // Python comment
+    "python@3.10"
+  ]
+}
+-- want --
+{
+  "packages": {
+    // Go comment
+    "go": "",
+    // Python comment
+    "python": {
+      "version":   "3.10",
+      "platforms": ["x86_64-darwin", "x86_64-linux"],
+    },
+  },
+}`)
+
+	err := in.PackagesMutator.AddPlatforms(io.Discard, "python@3.10", []string{"x86_64-darwin", "x86_64-linux"})
+	if err != nil {
+		t.Error(err)
+	}
+	if diff := cmp.Diff(want, in.Bytes(), optParseHujson()); diff != "" {
+		t.Errorf("wrong parsed config json (-want +got):\n%s", diff)
+	}
+	if diff := cmp.Diff(want, in.Bytes()); diff != "" {
+		t.Errorf("wrong raw config hujson (-want +got):\n%s", diff)
+	}
+}
+
+func TestExcludePlatforms(t *testing.T) {
+	in, want := parseConfigTxtarTest(t, `
+-- in --
+{
+  "packages": {
+    "go": {
+      "version": "1.20"
+    }
+  }
+}
+-- want --
+{
+  "packages": {
+    "go": {
+      "version":            "1.20",
+      "excluded_platforms": ["aarch64-darwin"]
+    }
+  }
+}`)
+
+	err := in.PackagesMutator.ExcludePlatforms(io.Discard, "go@1.20", []string{"aarch64-darwin"})
+	if err != nil {
+		t.Error(err)
+	}
+	if diff := cmp.Diff(want, in.Bytes(), optParseHujson()); diff != "" {
+		t.Errorf("wrong parsed config json (-want +got):\n%s", diff)
+	}
+	if diff := cmp.Diff(want, in.Bytes()); diff != "" {
+		t.Errorf("wrong raw config hujson (-want +got):\n%s", diff)
+	}
+}
+
+func TestSetOutputs(t *testing.T) {
+	in, want := parseConfigTxtarTest(t, `
+-- in --
+{
+  "packages": {
+    "prometheus": {
+      "version": "latest"
+    }
+  }
+}
+-- want --
+{
+  "packages": {
+    "prometheus": {
+      "version": "latest",
+      "outputs": ["cli"]
+    }
+  }
+}`)
+
+	err := in.PackagesMutator.SetOutputs(io.Discard, "prometheus@latest", []string{"cli"})
+	if err != nil {
+		t.Error(err)
+	}
+	if diff := cmp.Diff(want, in.Bytes(), optParseHujson()); diff != "" {
+		t.Errorf("wrong parsed config json (-want +got):\n%s", diff)
+	}
+	if diff := cmp.Diff(want, in.Bytes()); diff != "" {
+		t.Errorf("wrong raw config hujson (-want +got):\n%s", diff)
+	}
+}
+
+func TestSetOutputsMigrateArray(t *testing.T) {
+	in, want := parseConfigTxtarTest(t, `
+-- in --
+{
+  "packages": ["go", "python@3.10", "prometheus@latest"]
+}
+-- want --
+{
+  "packages": {
+    "go":     "",
+    "python": "3.10",
+    "prometheus": {
+      "version": "latest",
+      "outputs": ["cli"]
+    }
+  }
+}`)
+
+	err := in.PackagesMutator.SetOutputs(io.Discard, "prometheus@latest", []string{"cli"})
+	if err != nil {
+		t.Error(err)
+	}
+	if diff := cmp.Diff(want, in.Bytes(), optParseHujson()); diff != "" {
+		t.Errorf("wrong parsed config json (-want +got):\n%s", diff)
+	}
+	if diff := cmp.Diff(want, in.Bytes()); diff != "" {
+		t.Errorf("wrong raw config hujson (-want +got):\n%s", diff)
+	}
+}
+
+func TestSetAllowInsecure(t *testing.T) {
+	in, want := parseConfigTxtarTest(t, `
+-- in --
+{
+  "packages": {
+    "python": {
+      "version": "2.7"
+    }
+  }
+}
+-- want --
+{
+  "packages": {
+    "python": {
+      "version":        "2.7",
+      "allow_insecure": ["python-2.7.18.1"]
+    }
+  }
+}`)
+
+	err := in.PackagesMutator.SetAllowInsecure(io.Discard, "python@2.7", []string{"python-2.7.18.1"})
+	if err != nil {
+		t.Error(err)
+	}
+	if diff := cmp.Diff(want, in.Bytes(), optParseHujson()); diff != "" {
+		t.Errorf("wrong parsed config json (-want +got):\n%s", diff)
+	}
+	if diff := cmp.Diff(want, in.Bytes()); diff != "" {
+		t.Errorf("wrong raw config hujson (-want +got):\n%s", diff)
+	}
+}
+
+func TestNixpkgsValidation(t *testing.T) {
+	testCases := map[string]struct {
+		commit   string
+		isErrant bool
+	}{
+		"invalid_nixpkg_commit": {"1234545", true},
+		"valid_nixpkg_commit":   {"af9e00071d0971eb292fd5abef334e66eda3cb69", false},
+	}
+
+	for name, testCase := range testCases {
+		t.Run(name, func(t *testing.T) {
+			assert := assert.New(t)
+
+			err := ValidateNixpkg(&ConfigFile{
+				Nixpkgs: &NixpkgsConfig{
+					Commit: testCase.commit,
+				},
+			})
+			if testCase.isErrant {
+				assert.Error(err)
+			} else {
+				assert.NoError(err)
+			}
+		})
+	}
+}
diff --git a/internal/devconfig/packages_test.go b/internal/devconfig/configfile/packages_test.go
similarity index 92%
rename from internal/devconfig/packages_test.go
rename to internal/devconfig/configfile/packages_test.go
index 8fb7799d..0658d4c9 100644
--- a/internal/devconfig/packages_test.go
+++ b/internal/devconfig/configfile/packages_test.go
@@ -1,4 +1,4 @@
-package devconfig
+package configfile
 
 import (
 	"testing"
@@ -13,33 +13,33 @@ func TestJsonifyConfigPackages(t *testing.T) {
 	testCases := []struct {
 		name       string
 		jsonConfig string
-		expected   packagesMutator
+		expected   PackagesMutator
 	}{
 		{
 			name:       "empty-list",
 			jsonConfig: `{"packages":[]}`,
-			expected: packagesMutator{
+			expected: PackagesMutator{
 				collection: []Package{},
 			},
 		},
 		{
 			name:       "empty-map",
 			jsonConfig: `{"packages":{}}`,
-			expected: packagesMutator{
+			expected: PackagesMutator{
 				collection: []Package{},
 			},
 		},
 		{
 			name:       "flat-list",
 			jsonConfig: `{"packages":["python","hello@latest","go@1.20"]}`,
-			expected: packagesMutator{
+			expected: PackagesMutator{
 				collection: packagesFromLegacyList([]string{"python", "hello@latest", "go@1.20"}),
 			},
 		},
 		{
 			name:       "map-with-string-value",
 			jsonConfig: `{"packages":{"python":"latest","go":"1.20"}}`,
-			expected: packagesMutator{
+			expected: PackagesMutator{
 				collection: []Package{
 					NewVersionOnlyPackage("python", "latest"),
 					NewVersionOnlyPackage("go", "1.20"),
@@ -50,7 +50,7 @@ func TestJsonifyConfigPackages(t *testing.T) {
 		{
 			name:       "map-with-struct-value",
 			jsonConfig: `{"packages":{"python":{"version":"latest"}}}`,
-			expected: packagesMutator{
+			expected: PackagesMutator{
 				collection: []Package{
 					NewPackage("python", map[string]any{"version": "latest"}),
 				},
@@ -59,7 +59,7 @@ func TestJsonifyConfigPackages(t *testing.T) {
 		{
 			name:       "map-with-string-and-struct-values",
 			jsonConfig: `{"packages":{"go":"1.20","emacs":"latest","python":{"version":"latest"}}}`,
-			expected: packagesMutator{
+			expected: PackagesMutator{
 				collection: []Package{
 					NewVersionOnlyPackage("go", "1.20"),
 					NewVersionOnlyPackage("emacs", "latest"),
@@ -71,7 +71,7 @@ func TestJsonifyConfigPackages(t *testing.T) {
 			name: "map-with-platforms",
 			jsonConfig: `{"packages":{"python":{"version":"latest",` +
 				`"platforms":["x86_64-darwin","aarch64-linux"]}}}`,
-			expected: packagesMutator{
+			expected: PackagesMutator{
 				collection: []Package{
 					NewPackage("python", map[string]any{
 						"version":   "latest",
@@ -84,7 +84,7 @@ func TestJsonifyConfigPackages(t *testing.T) {
 			name: "map-with-excluded-platforms",
 			jsonConfig: `{"packages":{"python":{"version":"latest",` +
 				`"excluded_platforms":["x86_64-linux"]}}}`,
-			expected: packagesMutator{
+			expected: PackagesMutator{
 				collection: []Package{
 					NewPackage("python", map[string]any{
 						"version":            "latest",
@@ -98,7 +98,7 @@ func TestJsonifyConfigPackages(t *testing.T) {
 			jsonConfig: `{"packages":{"python":{"version":"latest",` +
 				`"platforms":["x86_64-darwin","aarch64-linux"],` +
 				`"excluded_platforms":["x86_64-linux"]}}}`,
-			expected: packagesMutator{
+			expected: PackagesMutator{
 				collection: []Package{
 					NewPackage("python", map[string]any{
 						"version":            "latest",
@@ -113,7 +113,7 @@ func TestJsonifyConfigPackages(t *testing.T) {
 			jsonConfig: `{"packages":{"path:my-php-flake#hello":{"version":"latest",` +
 				`"platforms":["x86_64-darwin","aarch64-linux"],` +
 				`"excluded_platforms":["x86_64-linux"]}}}`,
-			expected: packagesMutator{
+			expected: PackagesMutator{
 				collection: []Package{
 					NewPackage("path:my-php-flake#hello", map[string]any{
 						"version":            "latest",
@@ -129,7 +129,7 @@ func TestJsonifyConfigPackages(t *testing.T) {
 				`{"version":"latest",` +
 				`"platforms":["x86_64-darwin","aarch64-linux"],` +
 				`"excluded_platforms":["x86_64-linux"]}}}`,
-			expected: packagesMutator{
+			expected: PackagesMutator{
 				collection: []Package{
 					NewPackage("github:F1bonacc1/process-compose/v0.43.1", map[string]any{
 						"version":            "latest",
@@ -145,7 +145,7 @@ func TestJsonifyConfigPackages(t *testing.T) {
 				`{"version":"latest",` +
 				`"platforms":["x86_64-darwin","aarch64-linux"],` +
 				`"excluded_platforms":["x86_64-linux"]}}}`,
-			expected: packagesMutator{
+			expected: PackagesMutator{
 				collection: []Package{
 					NewPackage("github:nixos/nixpkgs/5233fd2ba76a3accb5aaa999c00509a11fd0793c#hello", map[string]any{
 						"version":            "latest",
@@ -163,7 +163,7 @@ func TestJsonifyConfigPackages(t *testing.T) {
 				`"excluded_platforms":["x86_64-linux"],` +
 				`"outputs":["cli"]` +
 				`}}}`,
-			expected: packagesMutator{
+			expected: PackagesMutator{
 				collection: []Package{
 					NewPackage("github:nixos/nixpkgs/5233fd2ba76a3accb5aaa999c00509a11fd0793c#hello", map[string]any{
 						"version":            "latest",
@@ -181,7 +181,7 @@ func TestJsonifyConfigPackages(t *testing.T) {
 				`"allow_insecure":["python-2.7.18.1"]` +
 				`}}}`,
 
-			expected: packagesMutator{
+			expected: PackagesMutator{
 				collection: []Package{
 					NewPackage("github:nixos/nixpkgs/5233fd2ba76a3accb5aaa999c00509a11fd0793c#python", map[string]any{
 						"version":        "2.7",
@@ -194,7 +194,7 @@ func TestJsonifyConfigPackages(t *testing.T) {
 
 	for _, testCase := range testCases {
 		t.Run(testCase.name, func(t *testing.T) {
-			config, err := loadBytes([]byte(testCase.jsonConfig))
+			config, err := LoadBytes([]byte(testCase.jsonConfig))
 			if err != nil {
 				t.Errorf("load error: %v", err)
 			}
@@ -213,10 +213,10 @@ func TestJsonifyConfigPackages(t *testing.T) {
 	}
 }
 
-func diffPackages(t *testing.T, got, want packagesMutator) string {
+func diffPackages(t *testing.T, got, want PackagesMutator) string {
 	t.Helper()
 
-	return cmp.Diff(want, got, cmpopts.IgnoreUnexported(packagesMutator{}, Package{}))
+	return cmp.Diff(want, got, cmpopts.IgnoreUnexported(PackagesMutator{}, Package{}))
 }
 
 func TestParseVersionedName(t *testing.T) {
diff --git a/internal/plugin/github_test.go b/internal/plugin/github_test.go
index 09e1bc6b..a1b1ce6f 100644
--- a/internal/plugin/github_test.go
+++ b/internal/plugin/github_test.go
@@ -24,6 +24,7 @@ func TestNewGithubPlugin(t *testing.T) {
 						Owner: "jetpack-io",
 						Repo:  "devbox-plugins",
 					},
+					raw:      "github:jetpack-io/devbox-plugins",
 					filename: pluginConfigName,
 				},
 			},
@@ -40,6 +41,7 @@ func TestNewGithubPlugin(t *testing.T) {
 						Repo:  "devbox-plugins",
 						Dir:   "mongodb",
 					},
+					raw:      "github:jetpack-io/devbox-plugins?dir=mongodb",
 					filename: pluginConfigName,
 				},
 			},
@@ -57,6 +59,7 @@ func TestNewGithubPlugin(t *testing.T) {
 						Ref:   "my-branch",
 						Dir:   "mongodb",
 					},
+					raw:      "github:jetpack-io/devbox-plugins/my-branch?dir=mongodb",
 					filename: pluginConfigName,
 				},
 			},
@@ -74,6 +77,7 @@ func TestNewGithubPlugin(t *testing.T) {
 						Ref:   "initials/my-branch",
 						Dir:   "mongodb",
 					},
+					raw:      "github:jetpack-io/devbox-plugins/initials/my-branch?dir=mongodb",
 					filename: pluginConfigName,
 				},
 			},
@@ -83,7 +87,7 @@ func TestNewGithubPlugin(t *testing.T) {
 
 	for _, testCase := range testCases {
 		t.Run(testCase.name, func(t *testing.T) {
-			actual, _ := parseReflike(testCase.Include)
+			actual, _ := parseReflike(testCase.Include, "")
 			assert.Equal(t, &testCase.expected, actual)
 			u, err := testCase.expected.url("")
 			assert.Nil(t, err)
diff --git a/testscripts/testrunner/examplesrunner.go b/testscripts/testrunner/examplesrunner.go
index be6ffcc0..c5f4e9f8 100644
--- a/testscripts/testrunner/examplesrunner.go
+++ b/testscripts/testrunner/examplesrunner.go
@@ -43,7 +43,7 @@ func RunDevboxTestscripts(t *testing.T, dir string) {
 		}
 
 		configPath := filepath.Join(path, "devbox.json")
-		config, err := devconfig.Load(configPath)
+		config, err := devconfig.LoadForTest(configPath)
 		if err != nil {
 			// skip directories that do not have a devbox.json defined
 			if errors.Is(err, fs.ErrNotExist) {