Repository: jetpack-io/devbox
Pull request #1735 (2024-01-19 07:32:10)

[gen-readme] Improve gen readme
## Summary

Improve automatically generated readme. Highlights:

* Adds "getting started" section.
* documents how to install devbox, start shell, run script
* Adds more context around scripts, init hook.
* Adds link to devbox repo.
* Adds more spacing between script details

What it looks like: https://github.com/jetpack-io/devbox/blob/56e7427a8f3da266d2f3d2b4a5028a7c7a017fc3/devbox.md

## How was it tested?

`devbox gen readme devbox.md`


Patch:
diff --git a/devbox.md b/devbox.md
index 48f6c120..60ba8965 100644
--- a/devbox.md
+++ b/devbox.md
@@ -3,23 +3,42 @@ # devbox
 
 Instant, easy, and predictable development environments
 
-## Scripts
+## Getting Started
+This project uses [devbox](https://github.com/jetpack-io/devbox) to manage its development environment.
+
+Install devbox:
+```sh
+curl -fsSL https://get.jetpack.io/devbox | bash
+```
 
-* [build](#build)
-* [build-all](#build-all)
-* [build-darwin-amd64](#build-darwin-amd64)
-* [build-darwin-arm64](#build-darwin-arm64)
-* [build-linux-amd64](#build-linux-amd64)
-* [build-linux-arm64](#build-linux-arm64)
-* [code](#code)
-* [fmt](#fmt)
-* [lint](#lint)
-* [test](#test)
-* [tidy](#tidy)
-* [update-examples](#update-examples)
-
-## Init Hook
+Start the devbox shell:
+```sh 
+devbox shell
+```
 
+Run a script in the devbox environment:
+```sh
+devbox run <script>
+```
+## Scripts
+Scripts are custom commands that can be run using this project's environment. This project has the following scripts:
+
+* [build](#devbox-run-build)
+* [build-all](#devbox-run-build-all)
+* [build-darwin-amd64](#devbox-run-build-darwin-amd64)
+* [build-darwin-arm64](#devbox-run-build-darwin-arm64)
+* [build-linux-amd64](#devbox-run-build-linux-amd64)
+* [build-linux-arm64](#devbox-run-build-linux-arm64)
+* [code](#devbox-run-code)
+* [fmt](#devbox-run-fmt)
+* [lint](#devbox-run-lint)
+* [test](#devbox-run-test)
+* [tidy](#devbox-run-tidy)
+* [update-examples](#devbox-run-update-examples)
+
+## Shell Init Hook
+The Shell Init Hook is a script that runs whenever the devbox environment is instantiated. It runs 
+on `devbox shell` and on `devbox run`.
 ```sh
 test -z $FISH_VERSION && unset CGO_ENABLED GO111MODULE GOARCH GOFLAGS GOMOD GOOS GOROOT GOTOOLCHAIN GOWORK
 ```
@@ -29,74 +48,85 @@ ## Packages
 * [go@latest](https://www.nixhub.io/packages/go)
 * [runx:golangci/golangci-lint@latest](https://www.github.com/golangci/golangci-lint)
 * [runx:mvdan/gofumpt@latest](https://www.github.com/mvdan/gofumpt)
-* nixpkgs/63143ac2c9186be6d9da6035fa22620018c85932#hello
 
 ## Script Details
 
-### build
+### devbox run build
 Build devbox for the current platform
 ```sh
 go build -o dist/devbox ./cmd/devbox
 ```
+&ensp;
 
-### build-all
+### devbox run build-all
 ```sh
 devbox run build-darwin-amd64
 devbox run build-darwin-arm64
 devbox run build-linux-amd64
 devbox run build-linux-arm64
 ```
+&ensp;
 
-### build-darwin-amd64
+### devbox run build-darwin-amd64
 ```sh
 GOOS=darwin GOARCH=amd64 go build -o dist/devbox-darwin-amd64 ./cmd/devbox
 ```
+&ensp;
 
-### build-darwin-arm64
+### devbox run build-darwin-arm64
 ```sh
 GOOS=darwin GOARCH=arm64 go build -o dist/devbox-darwin-arm64 ./cmd/devbox
 ```
+&ensp;
 
-### build-linux-amd64
+### devbox run build-linux-amd64
 ```sh
 GOOS=linux GOARCH=amd64 go build -o dist/devbox-linux-amd64 ./cmd/devbox
 ```
+&ensp;
 
-### build-linux-arm64
+### devbox run build-linux-arm64
 ```sh
 GOOS=linux GOARCH=arm64 go build -o dist/devbox-linux-arm64 ./cmd/devbox
 ```
+&ensp;
 
-### code
+### devbox run code
 Open VSCode
 ```sh
 code .
 ```
+&ensp;
 
-### fmt
+### devbox run fmt
 ```sh
 scripts/gofumpt.sh
 ```
+&ensp;
 
-### lint
+### devbox run lint
 ```sh
 golangci-lint run --timeout 5m && scripts/gofumpt.sh
 ```
+&ensp;
 
-### test
+### devbox run test
 ```sh
 go test -race -cover ./...
 ```
+&ensp;
 
-### tidy
+### devbox run tidy
 ```sh
 go mod tidy
 ```
+&ensp;
 
-### update-examples
+### devbox run update-examples
 ```sh
 devbox run build && go run testscripts/testrunner/updater/main.go
 ```
+&ensp;
 
 
 
diff --git a/internal/devbox/devbox.go b/internal/devbox/devbox.go
index e4b0a225..e6cdc183 100644
--- a/internal/devbox/devbox.go
+++ b/internal/devbox/devbox.go
@@ -204,7 +204,6 @@ func (d *Devbox) Shell(ctx context.Context) error {
 	}
 
 	opts := []ShellOption{
-		WithHooksFilePath(shellgen.ScriptPath(d.ProjectDir(), shellgen.HooksFilename)),
 		WithHistoryFile(filepath.Join(d.projectDir, shellHistoryFile)),
 		WithProjectDir(d.projectDir),
 		WithEnvVariables(envs),
@@ -1108,20 +1107,27 @@ func (d *Devbox) configEnvs(
 	env := map[string]string{}
 	if d.cfg.IsEnvsecEnabled() {
 		secrets, err := d.Secrets(ctx)
-		if err != nil {
+		// TODO: replace this with error.Is check once envsec exports it.
+		if err != nil && !strings.Contains(err.Error(), "project not initialized") {
 			return nil, err
-		}
-
-		cloudSecrets, err := secrets.List(ctx)
-		if err != nil {
+		} else if err != nil {
 			ux.Fwarning(
-				os.Stderr,
-				"Error reading secrets from jetpack cloud: %s\n\n",
-				err,
+				d.stderr,
+				"Ignoring env_from directive. jetpack cloud secrets is not "+
+					"initialized. Run `devbox secrets init` to initialize it.\n",
 			)
 		} else {
-			for _, secret := range cloudSecrets {
-				env[secret.Name] = secret.Value
+			cloudSecrets, err := secrets.List(ctx)
+			if err != nil {
+				ux.Fwarning(
+					os.Stderr,
+					"Error reading secrets from jetpack cloud: %s\n\n",
+					err,
+				)
+			} else {
+				for _, secret := range cloudSecrets {
+					env[secret.Name] = secret.Value
+				}
 			}
 		}
 	} else if d.cfg.EnvFrom != "" {
diff --git a/internal/devbox/docgen/readme.tmpl b/internal/devbox/docgen/readme.tmpl
index a50467c7..b0e2009a 100644
--- a/internal/devbox/docgen/readme.tmpl
+++ b/internal/devbox/docgen/readme.tmpl
@@ -7,11 +7,29 @@
 {{- if .Description }}
 {{ .Description }}
 {{ end }}
+## Getting Started
+This project uses [devbox](https://github.com/jetpack-io/devbox) to manage its development environment.
+
+Install devbox:
+```sh
+curl -fsSL https://get.jetpack.io/devbox | bash
+```
+
+Start the devbox shell:
+```sh 
+devbox shell
+```
+
+Run a script in the devbox environment:
+```sh
+devbox run <script>
+```
 
 {{- if .Scripts }}
 ## Scripts
+Scripts are custom commands that can be run using this project's environment. This project has the following scripts:
 {{ range $name, $_ := .Scripts }}
-* [{{ $name }}](#{{ $name }})
+* [{{ $name }}](#devbox-run-{{ $name }})
 {{- end }}
 {{ end }}
 
@@ -26,8 +44,9 @@
 {{ end }}
 
 {{- if .InitHook }}
-## Init Hook
-
+## Shell Init Hook
+The Shell Init Hook is a script that runs whenever the devbox environment is instantiated. It runs 
+on `devbox shell` and on `devbox run`.
 ```sh
 {{ .InitHook }}
 ```
@@ -43,13 +62,14 @@
 {{- if .Scripts }}
 ## Script Details
 {{ range $name, $commands := .Scripts }}
-### {{ $name }}
+### devbox run {{ $name }}
 {{- if .Comments }}
 {{ .Comments }}
 {{-  end }}
 ```sh
 {{ $commands }}
 ```
+&ensp;
 {{ end }}
 {{ end }}
 
diff --git a/internal/devbox/nixprofile.go b/internal/devbox/nixprofile.go
deleted file mode 100644
index f52ce22e..00000000
--- a/internal/devbox/nixprofile.go
+++ /dev/null
@@ -1,86 +0,0 @@
-package devbox
-
-import (
-	"context"
-	"fmt"
-	"strings"
-
-	"github.com/samber/lo"
-	"go.jetpack.io/devbox/internal/nix"
-	"go.jetpack.io/devbox/internal/nix/nixprofile"
-	"go.jetpack.io/devbox/internal/ux"
-)
-
-// syncNixProfileFromFlake ensures the nix profile has the packages from the buildInputs
-// from the devshell of the generated flake.
-//
-// It also removes any packages from the nix profile that are no longer in the buildInputs.
-func (d *Devbox) syncNixProfileFromFlake(ctx context.Context) error {
-	// Get the computed Devbox environment from the generated flake
-	env, err := d.computeEnv(ctx, false /*usePrintDevEnvCache*/)
-	if err != nil {
-		return err
-	}
-
-	// Get the store-paths of the packages we want installed in the nix profile
-	wantStorePaths := []string{}
-	if env["buildInputs"] != "" {
-		// env["buildInputs"] can be empty string if there are no packages in the project
-		// if buildInputs is empty, then we don't want wantStorePaths to be an array with a single "" entry
-		wantStorePaths = strings.Split(env["buildInputs"], " ")
-	}
-
-	profilePath, err := d.profilePath()
-	if err != nil {
-		return err
-	}
-
-	// Get the store-paths of the packages currently installed in the nix profile
-	items, err := nixprofile.ProfileListItems(d.stderr, profilePath)
-	if err != nil {
-		return fmt.Errorf("nix profile list: %v", err)
-	}
-	gotStorePaths := make([]string, 0, len(items))
-	for _, item := range items {
-		gotStorePaths = append(gotStorePaths, item.StorePaths()...)
-	}
-
-	// Diff the store paths and install/remove packages as needed
-	remove, add := lo.Difference(gotStorePaths, wantStorePaths)
-	if len(remove) > 0 {
-		packagesToRemove := make([]string, 0, len(remove))
-		for _, p := range remove {
-			storePath := nix.NewStorePathParts(p)
-			packagesToRemove = append(packagesToRemove, fmt.Sprintf("%s@%s", storePath.Name, storePath.Version))
-		}
-		if len(packagesToRemove) == 1 {
-			ux.Finfo(d.stderr, "Removing %s\n", strings.Join(packagesToRemove, ", "))
-		} else {
-			ux.Finfo(d.stderr, "Removing packages: %s\n", strings.Join(packagesToRemove, ", "))
-		}
-
-		if err := nix.ProfileRemove(profilePath, remove...); err != nil {
-			return err
-		}
-	}
-	if len(add) > 0 {
-		// We need to install the packages in the nix profile one-by-one because
-		// we do checks for insecure packages.
-		// TODO: move the insecure package check here, and do `nix profile install installables...`
-		// in one command for speed.
-		for _, addPath := range add {
-			if err = nix.ProfileInstall(ctx, &nix.ProfileInstallArgs{
-				Installable: addPath,
-				// Install in offline mode for speed. We know we should have all the files
-				// locally in /nix/store since we have run `nix print-dev-env` prior to this.
-				// Also avoids some "substituter not found for store-path" errors.
-				Offline:     true,
-				ProfilePath: profilePath,
-				Writer:      d.stderr,
-			}); err != nil {
-				return fmt.Errorf("error installing package in nix profile %s: %w", addPath, err)
-			}
-		}
-	}
-	return nil
-}
diff --git a/internal/devbox/packages.go b/internal/devbox/packages.go
index b52340e3..da0aef42 100644
--- a/internal/devbox/packages.go
+++ b/internal/devbox/packages.go
@@ -13,7 +13,6 @@
 	"slices"
 	"strings"
 
-	"github.com/fatih/color"
 	"github.com/pkg/errors"
 	"github.com/samber/lo"
 	"go.jetpack.io/devbox/internal/devbox/devopt"
@@ -256,14 +255,13 @@ func (d *Devbox) ensureStateIsUpToDate(ctx context.Context, mode installMode) er
 	defer trace.StartRegion(ctx, "devboxEnsureStateIsUpToDate").End()
 	defer debug.FunctionTimer().End()
 
+	// if mode is install or uninstall, then we need to update the nix-profile
+	// and lockfile, so we must continue below.
 	upToDate, err := d.lockfile.IsUpToDateAndInstalled()
 	if err != nil {
 		return err
 	}
 
-	// if mode is install or uninstall, then we need to compute some state
-	// like updating the flake or installing packages locally, so must continue
-	// below
 	if mode == ensure {
 		// if mode is ensure and we are up to date, then we can skip the rest
 		if upToDate {
@@ -272,15 +270,42 @@ func (d *Devbox) ensureStateIsUpToDate(ctx context.Context, mode installMode) er
 		fmt.Fprintln(d.stderr, "Ensuring packages are installed.")
 	}
 
-	if mode == install || mode == update || mode == ensure {
-		if err := d.installPackages(ctx); err != nil {
+	// Create plugin directories first because packages might need them
+	for _, pkg := range d.InstallablePackages() {
+		if err := d.PluginManager().Create(pkg); err != nil {
 			return err
 		}
 	}
 
-	recomputeState := mode == ensure || d.IsEnvEnabled()
-	if recomputeState {
-		if err := d.recomputeState(ctx); err != nil {
+	if err := d.syncPackagesToProfile(ctx, mode); err != nil {
+		return err
+	}
+
+	if err := d.InstallRunXPackages(ctx); err != nil {
+		return err
+	}
+
+	if err := shellgen.GenerateForPrintEnv(ctx, d); err != nil {
+		return err
+	}
+
+	if err := plugin.RemoveInvalidSymlinks(d.projectDir); err != nil {
+		return err
+	}
+
+	// Use the printDevEnvCache if we are adding or removing or updating any package,
+	// AND we are not in the shellenv-enabled environment of the current devbox-project.
+	usePrintDevEnvCache := mode != ensure && !d.IsEnvEnabled()
+	if _, err := d.computeEnv(ctx, usePrintDevEnvCache); err != nil {
+		return err
+	}
+
+	// Ensure we clean out packages that are no longer needed.
+	d.lockfile.Tidy()
+
+	// Update lockfile with new packages that are not to be installed
+	for _, pkg := range d.ConfigPackages() {
+		if err := pkg.EnsureUninstallableIsInLockfile(); err != nil {
 			return err
 		}
 	}
@@ -296,63 +321,173 @@ func (d *Devbox) ensureStateIsUpToDate(ctx context.Context, mode installMode) er
 		)
 	}
 
-	return d.updateLockfile(recomputeState)
+	return d.lockfile.Save()
 }
 
-// updateLockfile will ensure devbox.lock is up to date with the current state of the project.update
-// If recomputeState is true, then we will also update the local.lock file.
-func (d *Devbox) updateLockfile(recomputeState bool) error {
-	// Ensure we clean out packages that are no longer needed.
-	d.lockfile.Tidy()
+func (d *Devbox) profilePath() (string, error) {
+	absPath := filepath.Join(d.projectDir, nix.ProfilePath)
 
-	// Update lockfile with new packages that are not to be installed
-	for _, pkg := range d.ConfigPackages() {
-		if err := pkg.EnsureUninstallableIsInLockfile(); err != nil {
+	if err := resetProfileDirForFlakes(absPath); err != nil {
+		debug.Log("ERROR: resetProfileDirForFlakes error: %v\n", err)
+	}
+
+	return absPath, errors.WithStack(os.MkdirAll(filepath.Dir(absPath), 0o755))
+}
+
+// syncPackagesToProfile can ensure that all packages in devbox.json exist in the nix profile,
+// and no more. However, it may skip some steps depending on the `mode`.
+func (d *Devbox) syncPackagesToProfile(ctx context.Context, mode installMode) error {
+	defer debug.FunctionTimer().End()
+	defer trace.StartRegion(ctx, "syncPackagesToProfile").End()
+
+	// First, fetch the profile items from the nix-profile,
+	// and get the installable packages
+	profileDir, err := d.profilePath()
+	if err != nil {
+		return err
+	}
+	profileItems, err := nixprofile.ProfileListItems(d.stderr, profileDir)
+	if err != nil {
+		return err
+	}
+	packages, err := d.AllInstallablePackages()
+	if err != nil {
+		return err
+	}
+
+	// Remove non-nix packages from the list
+	packages = lo.Filter(packages, devpkg.IsNix)
+
+	if err := devpkg.FillNarInfoCache(ctx, packages...); err != nil {
+		return err
+	}
+
+	// Second, remove any packages from the nix-profile that are not in the config
+	itemsToKeep := profileItems
+	if mode != install {
+		itemsToKeep, err = d.removeExtraItemsFromProfile(ctx, profileDir, profileItems, packages)
+		if err != nil {
 			return err
 		}
 	}
 
-	// Save the lockfile at the very end, after all other operations were successful.
-	if err := d.lockfile.Save(); err != nil {
-		return err
+	// we are done if mode is uninstall
+	if mode == uninstall {
+		return nil
 	}
 
-	// If we are recomputing state, then we need to update the local.lock file.
-	// If not, we leave the local.lock in a stale state, so that state is recomputed
-	// on the next ensureStateIsUpToDate call with mode=ensure.
-	if recomputeState {
-		return d.lockfile.UpdateAndSaveLocalLock()
+	// Last, find the pending packages, and ensure they are added to the nix-profile
+	// Important to maintain the order of packages as specified by
+	// Devbox.InstallablePackages() (higher priority first).
+	// We also run nix profile upgrade on any virtenv flakes. This is a bit of a
+	// blunt approach, but ensured any plugin created flakes are up-to-date.
+	pending := []*devpkg.Package{}
+	for _, pkg := range packages {
+		idx, err := nixprofile.ProfileListIndex(&nixprofile.ProfileListIndexArgs{
+			Items:      itemsToKeep,
+			Lockfile:   d.lockfile,
+			Writer:     d.stderr,
+			Package:    pkg,
+			ProfileDir: profileDir,
+		})
+		if err != nil {
+			if !errors.Is(err, nix.ErrPackageNotFound) {
+				return err
+			}
+			pending = append(pending, pkg)
+		} else if f, err := pkg.FlakeInstallable(); err == nil && d.pluginManager.PathIsInVirtenv(f.Ref.Path) {
+			if err := nix.ProfileUpgrade(profileDir, idx); err != nil {
+				return err
+			}
+		}
 	}
-	return nil
+
+	return d.addPackagesToProfile(ctx, pending)
 }
 
-// recomputeState updates the local state comprising of:
-// - plugins directories
-// - devbox.lock file
-// - the generated flake
-// - the nix-profile
-func (d *Devbox) recomputeState(ctx context.Context) error {
-	if err := shellgen.GenerateForPrintEnv(ctx, d); err != nil {
-		return err
+func (d *Devbox) removeExtraItemsFromProfile(
+	ctx context.Context,
+	profileDir string,
+	profileItems []*nixprofile.NixProfileListItem,
+	packages []*devpkg.Package,
+) ([]*nixprofile.NixProfileListItem, error) {
+	defer debug.FunctionTimer().End()
+	defer trace.StartRegion(ctx, "removeExtraPackagesFromProfile").End()
+
+	itemsToKeep := []*nixprofile.NixProfileListItem{}
+	extras := []*nixprofile.NixProfileListItem{}
+	// Note: because devpkg.Package uses memoization when normalizing attribute paths (slow operation),
+	// and since we're reusing the Package objects, this O(n*m) loop becomes O(n+m) wrt the slow operation.
+	for _, item := range profileItems {
+		found := false
+		for _, pkg := range packages {
+			if item.Matches(pkg, d.lockfile) {
+				itemsToKeep = append(itemsToKeep, item)
+				found = true
+				break
+			}
+		}
+		if !found {
+			extras = append(extras, item)
+		}
 	}
+	// Remove by index to avoid comparing nix.ProfileListItem <> nix.Inputs again.
+	if err := nixprofile.ProfileRemoveItems(profileDir, extras); err != nil {
+		return nil, err
+	}
+	return itemsToKeep, nil
+}
 
-	// TODO: should this be moved into GenerateForPrintEnv?
-	// OR into a plugin.GenerateFiles() along with d.pluginManager().Create()?
-	if err := plugin.RemoveInvalidSymlinks(d.projectDir); err != nil {
+// addPackagesToProfile inspects the packages in devbox.json, checks which of them
+// are missing from the nix profile, and then installs each package individually into the
+// nix profile.
+func (d *Devbox) addPackagesToProfile(ctx context.Context, pkgs []*devpkg.Package) error {
+	defer debug.FunctionTimer().End()
+	defer trace.StartRegion(ctx, "addPackagesToProfile").End()
+
+	if len(pkgs) == 0 {
+		return nil
+	}
+
+	// If packages are in profile but nixpkgs has been purged, the experience
+	// will be poor when we try to run print-dev-env. So we ensure nixpkgs is
+	// prefetched for all relevant packages (those not in binary cache).
+	if err := devpkg.EnsureNixpkgsPrefetched(ctx, d.stderr, pkgs); err != nil {
 		return err
 	}
 
-	return d.syncNixProfileFromFlake(ctx)
-}
+	var msg string
+	if len(pkgs) == 1 {
+		msg = fmt.Sprintf("Installing package: %s.", pkgs[0])
+	} else {
+		pkgNames := lo.Map(pkgs, func(p *devpkg.Package, _ int) string { return p.Raw })
+		msg = fmt.Sprintf("Installing %d packages: %s.", len(pkgs), strings.Join(pkgNames, ", "))
+	}
+	fmt.Fprintf(d.stderr, "\n%s\n\n", msg)
 
-func (d *Devbox) profilePath() (string, error) {
-	absPath := filepath.Join(d.projectDir, nix.ProfilePath)
+	profileDir, err := d.profilePath()
+	if err != nil {
+		return fmt.Errorf("error getting profile path: %w", err)
+	}
 
-	if err := resetProfileDirForFlakes(absPath); err != nil {
-		debug.Log("ERROR: resetProfileDirForFlakes error: %v\n", err)
+	total := len(pkgs)
+	for idx, pkg := range pkgs {
+		stepNum := idx + 1
+
+		stepMsg := fmt.Sprintf("[%d/%d] %s", stepNum, total, pkg)
+
+		if err = nixprofile.ProfileInstall(ctx, &nixprofile.ProfileInstallArgs{
+			CustomStepMessage: stepMsg,
+			Lockfile:          d.lockfile,
+			Package:           pkg.Raw,
+			ProfilePath:       profileDir,
+			Writer:            d.stderr,
+		}); err != nil {
+			return fmt.Errorf("error installing package %s: %w", pkg, err)
+		}
 	}
 
-	return absPath, errors.WithStack(os.MkdirAll(filepath.Dir(absPath), 0o755))
+	return nil
 }
 
 var resetCheckDone = false
@@ -389,21 +524,6 @@ func resetProfileDirForFlakes(profileDir string) (err error) {
 	return errors.WithStack(os.Remove(profileDir))
 }
 
-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 {
-			return err
-		}
-	}
-
-	if err := d.installNixPackagesToStore(ctx); err != nil {
-		return err
-	}
-
-	return d.InstallRunXPackages(ctx)
-}
-
 func (d *Devbox) InstallRunXPackages(ctx context.Context) error {
 	for _, pkg := range lo.Filter(d.InstallablePackages(), devpkg.IsRunX) {
 		lockedPkg, err := d.lockfile.Resolve(pkg.Raw)
@@ -419,108 +539,3 @@ func (d *Devbox) InstallRunXPackages(ctx context.Context) error {
 	}
 	return nil
 }
-
-// installNixPackagesToStore will install all the packages in the nix store, if
-// mode is install or update, and we're not in a devbox environment.
-// This is done by running `nix build` on the flake. We do this so that the
-// packages will be available in the nix store when computing the devbox environment
-// and installing in the nix profile (even if offline).
-func (d *Devbox) installNixPackagesToStore(ctx context.Context) error {
-	packages, err := d.packagesToInstallInProfile(ctx)
-	if err != nil {
-		return err
-	}
-
-	stepNum := 0
-	total := len(packages)
-	for _, pkg := range packages {
-		stepNum += 1
-
-		installable, err := pkg.Installable()
-		if err != nil {
-			return err
-		}
-
-		stepMsg := fmt.Sprintf("[%d/%d] %s", stepNum, total, pkg)
-		fmt.Fprintf(d.stderr, stepMsg+"\n")
-
-		// --no-link to avoid generating the result objects
-		err = nix.Build(ctx, []string{"--no-link"}, installable)
-		if err != nil {
-			fmt.Fprintf(d.stderr, "%s: ", stepMsg)
-			color.New(color.FgRed).Fprintf(d.stderr, "Fail\n")
-
-			// Check if the user is installing a package that cannot be installed on their platform.
-			// For example, glibcLocales on MacOS will give the following error:
-			// flake output attribute 'legacyPackages.x86_64-darwin.glibcLocales' is not a derivation or path
-			// This is because glibcLocales is only available on Linux.
-			// The user should try `devbox add` again with `--exclude-platform`
-			errMessage := strings.TrimSpace(err.Error())
-			fmt.Printf("errMessage is %s\n", errMessage)
-			maybePackageSystemCompatibilityError := strings.Contains(errMessage, "error: flake output attribute") &&
-				strings.Contains(errMessage, "is not a derivation or path")
-
-			if maybePackageSystemCompatibilityError {
-				platform := nix.System()
-				return usererr.WithUserMessage(
-					err,
-					"package %s cannot be installed on your platform %s.\n"+
-						"If you know this package is incompatible with %[2]s, then "+
-						"you could run `devbox add %[1]s --exclude-platform %[2]s` and re-try.\n"+
-						"If you think this package should be compatible with %[2]s, then "+
-						"it's possible this particular version is not available yet from the nix registry. "+
-						"You could try `devbox add` with a different version for this package.\n\n"+
-						"Underlying Error from nix is:",
-					pkg.Raw,
-					platform,
-				)
-			}
-
-			return usererr.WithUserMessage(err, "error installing package %s", pkg.Raw)
-		}
-
-		fmt.Fprintf(d.stderr, "%s: ", stepMsg)
-		color.New(color.FgGreen).Fprintf(d.stderr, "Success\n")
-	}
-	return err
-}
-
-func (d *Devbox) packagesToInstallInProfile(ctx context.Context) ([]*devpkg.Package, error) {
-	// First, fetch the profile items from the nix-profile,
-	profileDir, err := d.profilePath()
-	if err != nil {
-		return nil, err
-	}
-	profileItems, err := nixprofile.ProfileListItems(d.stderr, profileDir)
-	if err != nil {
-		return nil, err
-	}
-
-	// Second, 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
-	if err := devpkg.FillNarInfoCache(ctx, packages...); err != nil {
-		return nil, err
-	}
-
-	// Third, compute which packages need to be installed
-	packagesToInstall := []*devpkg.Package{}
-	// Note: because devpkg.Package uses memoization when normalizing attribute paths (slow operation),
-	// and since we're reusing the Package objects, this O(n*m) loop becomes O(n+m) wrt the slow operation.
-	for _, pkg := range packages {
-		found := false
-		for _, item := range profileItems {
-			if item.Matches(pkg, d.lockfile) {
-				found = true
-				break
-			}
-		}
-		if !found {
-			packagesToInstall = append(packagesToInstall, pkg)
-		}
-	}
-	return packagesToInstall, nil
-}
diff --git a/internal/devbox/shell.go b/internal/devbox/shell.go
index 0ff4e849..94709b4c 100644
--- a/internal/devbox/shell.go
+++ b/internal/devbox/shell.go
@@ -17,6 +17,7 @@
 
 	"github.com/alessio/shellescape"
 	"github.com/pkg/errors"
+	"go.jetpack.io/devbox/internal/shellgen"
 	"go.jetpack.io/devbox/internal/telemetry"
 
 	"go.jetpack.io/devbox/internal/debug"
@@ -59,8 +60,7 @@ type DevboxShell struct {
 	env             map[string]string
 	userShellrcPath string
 
-	hooksFilePath string
-	historyFile   string
+	historyFile string
 
 	// shellStartTime is the unix timestamp for when the command was invoked
 	shellStartTime time.Time
@@ -183,12 +183,6 @@ func WithHistoryFile(historyFile string) ShellOption {
 	}
 }
 
-func WithHooksFilePath(hooksFilePath string) ShellOption {
-	return func(s *DevboxShell) {
-		s.hooksFilePath = hooksFilePath
-	}
-}
-
 // TODO: Consider removing this once plugins add env vars directly to binaries via wrapper scripts.
 func WithEnvVariables(envVariables map[string]string) ShellOption {
 	return func(s *DevboxShell) {
@@ -317,8 +311,10 @@ func (s *DevboxShell) writeDevboxShellrc() (path string, err error) {
 	}()
 
 	tmpl := shellrcTmpl
+	hooksFilePath := shellgen.ScriptPath(s.projectDir, shellgen.HooksFilename)
 	if s.name == shFish {
 		tmpl = fishrcTmpl
+		hooksFilePath = shellgen.ScriptPath(s.projectDir, shellgen.HooksFishFilename)
 	}
 
 	err = tmpl.Execute(shellrcf, struct {
@@ -337,7 +333,7 @@ func (s *DevboxShell) writeDevboxShellrc() (path string, err error) {
 		ProjectDir:         s.projectDir,
 		OriginalInit:       string(bytes.TrimSpace(userShellrc)),
 		OriginalInitPath:   s.userShellrcPath,
-		HooksFilePath:      s.hooksFilePath,
+		HooksFilePath:      hooksFilePath,
 		ShellStartTime:     telemetry.FormatShellStart(s.shellStartTime),
 		HistoryFile:        strings.TrimSpace(s.historyFile),
 		ExportEnv:          exportify(s.env),
diff --git a/internal/devbox/shellrc.tmpl b/internal/devbox/shellrc.tmpl
index 24a314e1..6f0e5f52 100644
--- a/internal/devbox/shellrc.tmpl
+++ b/internal/devbox/shellrc.tmpl
@@ -38,8 +38,11 @@ ignore the existing value of HISTFILE.
 HISTFILE="{{ .HistoryFile }}"
 {{- end }}
 
-# Prepend to the prompt to make it clear we're in a devbox shell.
-export PS1="(devbox) $PS1"
+# If the user hasn't specified they want to handle the prompt themselves,
+# prepend to the prompt to make it clear we're in a devbox shell.
+if [ -z "$DEVBOX_NO_PROMPT" ]; then
+  export PS1="(devbox) $PS1"
+fi
 
 {{- if .ShellStartTime }}
 # log that the shell is ready now!
diff --git a/internal/devbox/shellrc_fish.tmpl b/internal/devbox/shellrc_fish.tmpl
index daf252ad..a50b3fb2 100644
--- a/internal/devbox/shellrc_fish.tmpl
+++ b/internal/devbox/shellrc_fish.tmpl
@@ -38,10 +38,13 @@ enough approximation for now.
 set fish_history devbox
 {{- end }}
 
-# Prepend to the prompt to make it clear we're in a devbox shell.
-functions -c fish_prompt __devbox_fish_prompt_orig
-function fish_prompt
-    echo "(devbox)" (__devbox_fish_prompt_orig)
+# If the user hasn't specified they want to handle the prompt themselves,
+# prepend to the prompt to make it clear we're in a devbox shell.
+if not set -q devbox_no_prompt
+    functions -c fish_prompt __devbox_fish_prompt_orig
+    function fish_prompt
+        echo "(devbox)" (__devbox_fish_prompt_orig)
+    end
 end
 
 {{- if .ShellStartTime }}
diff --git a/internal/devbox/util.go b/internal/devbox/util.go
index e6b0f67f..64c5168a 100644
--- a/internal/devbox/util.go
+++ b/internal/devbox/util.go
@@ -10,7 +10,6 @@
 	"path/filepath"
 
 	"github.com/pkg/errors"
-	"go.jetpack.io/devbox/internal/devpkg"
 	"go.jetpack.io/devbox/internal/nix/nixprofile"
 
 	"go.jetpack.io/devbox/internal/xdg"
@@ -20,21 +19,15 @@
 // It's used to install applications devbox might need, like process-compose
 // This is an alternative to a global install which would modify a user's
 // environment.
-func (d *Devbox) addDevboxUtilityPackage(ctx context.Context, pkgName string) error {
-	pkg := devpkg.PackageFromStringWithDefaults(pkgName, d.lockfile)
-	installable, err := pkg.Installable()
-	if err != nil {
-		return err
-	}
-
+func (d *Devbox) addDevboxUtilityPackage(ctx context.Context, pkg string) error {
 	profilePath, err := utilityNixProfilePath()
 	if err != nil {
 		return err
 	}
 
 	return nixprofile.ProfileInstall(ctx, &nixprofile.ProfileInstallArgs{
-		Installable: installable,
-		PackageName: pkgName,
+		Lockfile:    d.lockfile,
+		Package:     pkg,
 		ProfilePath: profilePath,
 		Writer:      d.stderr,
 	})
diff --git a/internal/devpkg/narinfo_cache.go b/internal/devpkg/narinfo_cache.go
index 9eb9da8c..170ff7e0 100644
--- a/internal/devpkg/narinfo_cache.go
+++ b/internal/devpkg/narinfo_cache.go
@@ -27,10 +27,6 @@ func (p *Package) IsInBinaryCache() (bool, error) {
 	if p.PatchGlibc {
 		return false, nil
 	}
-	// Packages with non-default outputs are not to be taken from the binary cache.
-	if len(p.Outputs) > 0 {
-		return false, nil
-	}
 	if eligible, err := p.isEligibleForBinaryCache(); err != nil {
 		return false, err
 	} else if !eligible {
diff --git a/internal/devpkg/package.go b/internal/devpkg/package.go
index f81226c6..3a96fee4 100644
--- a/internal/devpkg/package.go
+++ b/internal/devpkg/package.go
@@ -78,10 +78,6 @@ type Package struct {
 	//    example: github:nixos/nixpkgs/5233fd2ba76a3accb5aaa999c00509a11fd0793c#hello
 	Raw string
 
-	// Outputs is a list of outputs to build from the package's derivation.
-	// If empty, the default output is used.
-	Outputs []string
-
 	// PatchGlibc applies a function to the package's derivation that
 	// patches any ELF binaries to use the latest version of nixpkgs#glibc.
 	PatchGlibc bool
@@ -117,7 +113,6 @@ func PackagesFromConfig(config *devconfig.Config, l lock.Locker) []*Package {
 		pkg := newPackage(cfgPkg.VersionedName(), cfgPkg.IsEnabledOnPlatform(), l)
 		pkg.DisablePlugin = cfgPkg.DisablePlugin
 		pkg.PatchGlibc = cfgPkg.PatchGlibc && nix.SystemIsLinux()
-		pkg.Outputs = cfgPkg.Outputs
 		result = append(result, pkg)
 	}
 	return result
@@ -131,7 +126,6 @@ func PackageFromStringWithOptions(raw string, locker lock.Locker, opts devopt.Ad
 	pkg := PackageFromStringWithDefaults(raw, locker)
 	pkg.DisablePlugin = opts.DisablePlugin
 	pkg.PatchGlibc = opts.PatchGlibc
-	pkg.Outputs = opts.Outputs
 	return pkg
 }
 
diff --git a/internal/lock/lockfile.go b/internal/lock/lockfile.go
index a6c6d81b..f7d4fa7c 100644
--- a/internal/lock/lockfile.go
+++ b/internal/lock/lockfile.go
@@ -98,10 +98,9 @@ func (f *File) ForceResolve(pkg string) (*Package, error) {
 }
 
 func (f *File) Save() error {
-	return cuecfg.WriteFile(lockFilePath(f.devboxProject), f)
-}
-
-func (f *File) UpdateAndSaveLocalLock() error {
+	if err := cuecfg.WriteFile(lockFilePath(f.devboxProject), f); err != nil {
+		return err
+	}
 	return updateLocal(f.devboxProject)
 }
 
diff --git a/internal/nix/build.go b/internal/nix/build.go
deleted file mode 100644
index 03ee9707..00000000
--- a/internal/nix/build.go
+++ /dev/null
@@ -1,30 +0,0 @@
-package nix
-
-import (
-	"context"
-	"fmt"
-	"os"
-	"os/exec"
-
-	"github.com/pkg/errors"
-	"go.jetpack.io/devbox/internal/debug"
-)
-
-func Build(ctx context.Context, flags []string, installables ...string) error {
-	// --impure is required for allowUnfreeEnv to work.
-	cmd := commandContext(ctx, "build", "--impure")
-	cmd.Args = append(cmd.Args, flags...)
-	cmd.Args = append(cmd.Args, installables...)
-	cmd.Env = allowUnfreeEnv(os.Environ())
-
-	debug.Log("Running cmd: %s\n", cmd)
-	_, err := cmd.Output()
-	if err != nil {
-		if exitErr := (&exec.ExitError{}); errors.As(err, &exitErr) {
-			debug.Log("Nix build exit code: %d, output: %s\n", exitErr.ExitCode(), exitErr.Stderr)
-			return fmt.Errorf("nix build exit code: %d, output: %s, err: %w", exitErr.ExitCode(), exitErr.Stderr, err)
-		}
-		return err
-	}
-	return nil
-}
diff --git a/internal/nix/nixprofile/item.go b/internal/nix/nixprofile/item.go
index af14ad0b..b6759500 100644
--- a/internal/nix/nixprofile/item.go
+++ b/internal/nix/nixprofile/item.go
@@ -83,7 +83,3 @@ func (i *NixProfileListItem) String() string {
 		i.nixStorePaths,
 	)
 }
-
-func (i *NixProfileListItem) StorePaths() []string {
-	return i.nixStorePaths
-}
diff --git a/internal/nix/nixprofile/profile.go b/internal/nix/nixprofile/profile.go
index d293667c..dc9490f6 100644
--- a/internal/nix/nixprofile/profile.go
+++ b/internal/nix/nixprofile/profile.go
@@ -15,6 +15,7 @@
 	"github.com/fatih/color"
 	"github.com/pkg/errors"
 	"github.com/samber/lo"
+	"go.jetpack.io/devbox/internal/boxcli/usererr"
 	"go.jetpack.io/devbox/internal/devpkg"
 	"go.jetpack.io/devbox/internal/lock"
 	"go.jetpack.io/devbox/internal/nix"
@@ -190,16 +191,42 @@ func parseNixProfileListItem(line string) (*NixProfileListItem, error) {
 
 type ProfileInstallArgs struct {
 	CustomStepMessage string
-	Installable       string
-	Offline           bool
-	PackageName       string
+	Lockfile          *lock.File
+	Package           string
 	ProfilePath       string
 	Writer            io.Writer
 }
 
 // ProfileInstall calls nix profile install with default profile
 func ProfileInstall(ctx context.Context, args *ProfileInstallArgs) error {
-	stepMsg := args.PackageName
+	input := devpkg.PackageFromStringWithDefaults(args.Package, args.Lockfile)
+
+	inCache, err := input.IsInBinaryCache()
+	if err != nil {
+		return err
+	}
+
+	if !inCache && nix.IsGithubNixpkgsURL(input.URLForFlakeInput()) {
+		if err := nix.EnsureNixpkgsPrefetched(args.Writer, input.HashFromNixPkgsURL()); err != nil {
+			return err
+		}
+		if exists, err := input.ValidateInstallsOnSystem(); err != nil {
+			return err
+		} else if !exists {
+			platform := nix.System()
+			return usererr.New(
+				"package %s cannot be installed on your platform %s.\n"+
+					"If you know this package is incompatible with %[2]s, then "+
+					"you could run `devbox add %[1]s --exclude-platform %[2]s` and re-try.\n"+
+					"If you think this package should be compatible with %[2]s, then "+
+					"it's possible this particular version is not available yet from the nix registry. "+
+					"You could try `devbox add` with a different version for this package.\n",
+				input.Raw,
+				platform,
+			)
+		}
+	}
+	stepMsg := args.Package
 	if args.CustomStepMessage != "" {
 		stepMsg = args.CustomStepMessage
 		// Only print this first one if we have a custom message. Otherwise it feels
@@ -207,12 +234,12 @@ func ProfileInstall(ctx context.Context, args *ProfileInstallArgs) error {
 		fmt.Fprintf(args.Writer, "%s\n", stepMsg)
 	}
 
-	err := nix.ProfileInstall(ctx, &nix.ProfileInstallArgs{
-		Installable: args.Installable,
-		Offline:     args.Offline,
-		ProfilePath: args.ProfilePath,
-		Writer:      args.Writer,
-	})
+	installable, err := input.Installable()
+	if err != nil {
+		return err
+	}
+
+	err = nix.ProfileInstall(args.Writer, args.ProfilePath, installable)
 	if err != nil {
 		fmt.Fprintf(args.Writer, "%s: ", stepMsg)
 		color.New(color.FgRed).Fprintf(args.Writer, "Fail\n")
@@ -223,3 +250,17 @@ func ProfileInstall(ctx context.Context, args *ProfileInstallArgs) error {
 	color.New(color.FgGreen).Fprintf(args.Writer, "Success\n")
 	return nil
 }
+
+// ProfileRemoveItems removes the items from the profile, in a single call, using their indexes.
+// It is up to the caller to ensure that the underlying profile has not changed since the items
+// were queried.
+func ProfileRemoveItems(profilePath string, items []*NixProfileListItem) error {
+	if len(items) == 0 {
+		return nil
+	}
+	indexes := []string{}
+	for _, item := range items {
+		indexes = append(indexes, strconv.Itoa(item.index))
+	}
+	return nix.ProfileRemove(profilePath, indexes)
+}
diff --git a/internal/nix/profiles.go b/internal/nix/profiles.go
index 5ca87e3d..e01516cc 100644
--- a/internal/nix/profiles.go
+++ b/internal/nix/profiles.go
@@ -4,7 +4,6 @@
 package nix
 
 import (
-	"context"
 	"encoding/json"
 	"fmt"
 	"io"
@@ -30,17 +29,10 @@ func ProfileList(writer io.Writer, profilePath string, useJSON bool) (string, er
 	return string(out), nil
 }
 
-type ProfileInstallArgs struct {
-	Installable string
-	Offline     bool
-	ProfilePath string
-	Writer      io.Writer
-}
-
-func ProfileInstall(ctx context.Context, args *ProfileInstallArgs) error {
-	if !IsInsecureAllowed() && PackageIsInsecure(args.Installable) {
-		knownVulnerabilities := PackageKnownVulnerabilities(args.Installable)
-		errString := fmt.Sprintf("Package %s is insecure. \n\n", args.Installable)
+func ProfileInstall(writer io.Writer, profilePath, installable string) error {
+	if !IsInsecureAllowed() && PackageIsInsecure(installable) {
+		knownVulnerabilities := PackageKnownVulnerabilities(installable)
+		errString := fmt.Sprintf("Package %s is insecure. \n\n", installable)
 		if len(knownVulnerabilities) > 0 {
 			errString += fmt.Sprintf("Known vulnerabilities: %s \n\n", knownVulnerabilities)
 		}
@@ -48,34 +40,30 @@ func ProfileInstall(ctx context.Context, args *ProfileInstallArgs) error {
 		return usererr.New(errString)
 	}
 
-	cmd := commandContext(
-		ctx,
+	cmd := command(
 		"profile", "install",
-		"--profile", args.ProfilePath,
+		"--profile", profilePath,
 		"--impure", // for NIXPKGS_ALLOW_UNFREE
 		// Using an arbitrary priority to avoid conflicts with other packages.
 		// Note that this is not really the priority we care about, since we
 		// use the flake.nix to specify the priority.
-		"--priority", nextPriority(args.ProfilePath),
+		"--priority", nextPriority(profilePath),
+		installable,
 	)
-	if args.Offline {
-		cmd.Args = append(cmd.Args, "--offline")
-	}
-	cmd.Args = append(cmd.Args, args.Installable)
 	cmd.Env = allowUnfreeEnv(os.Environ())
 
 	// If nix profile install runs as tty, the output is much nicer. If we ever
 	// need to change this to our own writers, consider that you may need
 	// to implement your own nicer output. --print-build-logs flag may be useful.
 	cmd.Stdin = os.Stdin
-	cmd.Stdout = args.Writer
-	cmd.Stderr = args.Writer
+	cmd.Stdout = writer
+	cmd.Stderr = writer
 
 	debug.Log("running command: %s\n", cmd)
 	return cmd.Run()
 }
 
-func ProfileRemove(profilePath string, indexes ...string) error {
+func ProfileRemove(profilePath string, indexes []string) error {
 	cmd := command(
 		append([]string{
 			"profile", "remove",
diff --git a/internal/shellgen/flake_input.go b/internal/shellgen/flake_input.go
index a7b16bfb..91c47329 100644
--- a/internal/shellgen/flake_input.go
+++ b/internal/shellgen/flake_input.go
@@ -2,7 +2,6 @@
 
 import (
 	"context"
-	"errors"
 	"runtime/trace"
 	"slices"
 	"strings"
@@ -51,54 +50,9 @@ func (f *flakeInput) PkgImportName() string {
 	return f.Name + "-pkgs"
 }
 
-type SymlinkJoin struct {
-	Name  string
-	Paths []string
-}
-
-// BuildInputsForSymlinkJoin returns a list of SymlinkJoin objects that can be used
-// as the buildInput. Used for packages that have non-default outputs that need to
-// be combined into a single buildInput.
-func (f *flakeInput) BuildInputsForSymlinkJoin() ([]*SymlinkJoin, error) {
-	joins := []*SymlinkJoin{}
-	for _, pkg := range f.Packages {
-		// skip packages that have no non-default outputs
-		if len(pkg.Outputs) == 0 {
-			continue
-		}
-
-		attributePath, err := pkg.FullPackageAttributePath()
-		if err != nil {
-			return nil, err
-		}
-
-		if pkg.PatchGlibc {
-			return nil, errors.New("patch_glibc is not yet supported for packages with non-default outputs")
-		}
-		joins = append(joins, &SymlinkJoin{
-			Name: pkg.String() + "-combined",
-			Paths: lo.Map(pkg.Outputs, func(output string, _ int) string {
-				if !f.IsNixpkgs() {
-					return f.Name + "." + attributePath + "." + output
-				}
-				parts := strings.Split(attributePath, ".")
-				return f.PkgImportName() + "." + strings.Join(parts[2:], ".") + "." + output
-			}),
-		})
-	}
-	return joins, nil
-}
-
 func (f *flakeInput) BuildInputs() ([]string, error) {
 	var err error
-
-	// Filter out packages that have non-default outputs
-	// These are handled in BuildInputsForSymlinkJoin
-	packages := lo.Filter(f.Packages, func(pkg *devpkg.Package, _ int) bool {
-		return len(pkg.Outputs) == 0
-	})
-
-	attributePaths := lo.Map(packages, func(pkg *devpkg.Package, _ int) string {
+	attributePaths := lo.Map(f.Packages, func(pkg *devpkg.Package, _ int) string {
 		attributePath, attributePathErr := pkg.FullPackageAttributePath()
 		if attributePathErr != nil {
 			err = attributePathErr
diff --git a/internal/shellgen/generate.go b/internal/shellgen/generate.go
index 9b6bd4fb..796d2c5c 100644
--- a/internal/shellgen/generate.go
+++ b/internal/shellgen/generate.go
@@ -30,7 +30,7 @@
 // devbox.PrintEnv, which is the core function from which devbox shell/run/direnv
 // functionality is derived.
 func GenerateForPrintEnv(ctx context.Context, devbox devboxer) error {
-	defer trace.StartRegion(ctx, "GenerateForPrintEnv").End()
+	defer trace.StartRegion(ctx, "generateShellFiles").End()
 
 	plan, err := newFlakePlan(ctx, devbox)
 	if err != nil {
diff --git a/internal/shellgen/scripts.go b/internal/shellgen/scripts.go
index 896487bb..cb6982ce 100644
--- a/internal/shellgen/scripts.go
+++ b/internal/shellgen/scripts.go
@@ -21,11 +21,18 @@
 //go:embed tmpl/init-hook.tmpl
 var initHookTmpl string
 
+//go:embed tmpl/init-hook-fish.tmpl
+var initHookFishTmpl string
+
 const scriptsDir = ".devbox/gen/scripts"
 
 // HooksFilename is the name of the file that contains the project's init-hooks and plugin hooks
 const HooksFilename = ".hooks"
 
+// This is only used in shellrc_fish.tmpl. A bit of a hack, because scripts use
+// sh instead of fish.
+const HooksFishFilename = ".hooks.fish"
+
 type devboxer interface {
 	Config() *devconfig.Config
 	Lockfile() *lock.File
@@ -62,11 +69,16 @@ func WriteScriptsToFiles(devbox devboxer) error {
 	}
 	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 = writeInitHookFile(devbox, hooks)
+	err = writeInitHookFile(devbox, hooks, initHookTmpl, HooksFilename)
 	if err != nil {
 		return errors.WithStack(err)
 	}
 	written[HooksFilename] = struct{}{}
+	err = writeInitHookFile(devbox, hooks, initHookFishTmpl, HooksFishFilename)
+	if err != nil {
+		return errors.WithStack(err)
+	}
+	written[HooksFishFilename] = struct{}{}
 
 	// Write scripts to files.
 	for name, body := range devbox.Config().Scripts() {
@@ -91,14 +103,14 @@ func WriteScriptsToFiles(devbox devboxer) error {
 	return nil
 }
 
-func writeInitHookFile(devbox devboxer, body string) (err error) {
-	script, err := createScriptFile(devbox, HooksFilename)
+func writeInitHookFile(devbox devboxer, body, tmpl, filename string) (err error) {
+	script, err := createScriptFile(devbox, filename)
 	if err != nil {
 		return errors.WithStack(err)
 	}
 	defer script.Close() // best effort: close file
 
-	t, err := template.New("init-hook-template").Parse(initHookTmpl)
+	t, err := template.New(filename).Parse(tmpl)
 	if err != nil {
 		return errors.WithStack(err)
 	}
@@ -106,10 +118,6 @@ func writeInitHookFile(devbox devboxer, body string) (err error) {
 	return t.Execute(script, map[string]any{
 		"Body":         body,
 		"InitHookHash": "__DEVBOX_INIT_HOOK_" + devbox.ProjectDirHash(),
-		// TODO put IsFish() in common place so we can call here and in devbox package
-		// without adding more stuff to interface
-		"IsFish": filepath.Base(os.Getenv("SHELL")) == "fish" ||
-			os.Getenv("FISH_VERSION") != "",
 	})
 }
 
diff --git a/internal/shellgen/tmpl/flake_remove_nixpkgs.nix.tmpl b/internal/shellgen/tmpl/flake_remove_nixpkgs.nix.tmpl
index e49111ea..0516ba06 100644
--- a/internal/shellgen/tmpl/flake_remove_nixpkgs.nix.tmpl
+++ b/internal/shellgen/tmpl/flake_remove_nixpkgs.nix.tmpl
@@ -46,17 +46,7 @@
             })
             {{- end }}
             {{- end }}
-            {{- range $_, $flakeInput := .FlakeInputs }}
-            {{- range .BuildInputsForSymlinkJoin }}
-            (pkgs.symlinkJoin {
-              name = "{{.Name}}";
-              paths = [
-                {{- range .Paths }}
-                {{.}}
-                {{- end }}
-              ];
-            })
-            {{- end }}
+            {{- range .FlakeInputs }}
             {{- range .BuildInputs }}
             {{.}}
             {{- end }}
diff --git a/internal/shellgen/tmpl/init-hook-fish.tmpl b/internal/shellgen/tmpl/init-hook-fish.tmpl
new file mode 100644
index 00000000..0324c4b4
--- /dev/null
+++ b/internal/shellgen/tmpl/init-hook-fish.tmpl
@@ -0,0 +1,12 @@
+{{/* Fish version */ -}}
+{{/* if hash is set, we're in recursive loop, just exit */ -}}
+
+if set -q {{ .InitHookHash }}; then
+    return
+end
+
+export {{ .InitHookHash }}=true
+
+{{ .Body }}
+
+set -e {{ .InitHookHash }}
diff --git a/internal/shellgen/tmpl/init-hook.tmpl b/internal/shellgen/tmpl/init-hook.tmpl
index 46b2da5b..57e677c3 100644
--- a/internal/shellgen/tmpl/init-hook.tmpl
+++ b/internal/shellgen/tmpl/init-hook.tmpl
@@ -1,22 +1,12 @@
 
 {{/* if hash is set, we're in recursive loop, just exit */ -}}
 
-{{ if .IsFish }}
-if set -q {{ .InitHookHash }}; then
-    return
-end
-{{ else }}
 if [ -n "${{ .InitHookHash }}" ]; then
     return
 fi
-{{ end }}
 
 export {{ .InitHookHash }}=true
 
 {{ .Body }}
 
-{{ if .IsFish }}
-set -e {{ .InitHookHash }}
-{{ else }}
 unset {{ .InitHookHash }}
-{{ end }}


Test patch:
diff --git a/.github/workflows/cli-tests.yaml b/.github/workflows/cli-tests.yaml
index d0a306fe..21a0a203 100644
--- a/.github/workflows/cli-tests.yaml
+++ b/.github/workflows/cli-tests.yaml
@@ -59,7 +59,7 @@ jobs:
       - name: Build devbox
         run: go build -o dist/devbox ./cmd/devbox
       - name: Upload devbox artifact
-        uses: actions/upload-artifact@v3
+        uses: actions/upload-artifact@v3  
         with:
           name: devbox-${{ runner.os }}-${{ runner.arch }}
           path: ./dist/devbox
@@ -113,7 +113,7 @@ jobs:
         run: |
           chmod +x ./devbox
           sudo mv ./devbox /usr/local/bin/
-
+          
       - run:  devbox run lint
 
   test:
@@ -129,8 +129,9 @@ jobs:
         run-project-tests: ["project-tests", "project-tests-off"]
         # Run tests on:
         # 1. the oldest supported nix version (which is 2.9.0? But determinate-systems installer has 2.12.0)
-        # 2. latest nix version
-        nix-version: ["2.12.0", "2.19.2"]
+        # 2. nix version 2.17.0 which introduces a new code path that minimizes nixpkgs downloads.
+        # 3. latest nix version
+        nix-version: ["2.12.0", "2.17.0", "2.19.2"]
         exclude:
           - is-main: "not-main"
             os: "${{ inputs.run-mac-tests && 'dummy' || 'macos-latest' }}"
@@ -199,7 +200,7 @@ jobs:
           go test -v -timeout $DEVBOX_GOLANG_TEST_TIMEOUT ./...
 
   auto-nix-install: # ensure Devbox installs nix and works properly after installation.
-    needs: build-devbox
+    needs: build-devbox 
     strategy:
       matrix:
         os: [ubuntu-latest, macos-latest]
diff --git a/internal/devbox/shell_test.go b/internal/devbox/shell_test.go
index a931123b..71b6a3cc 100644
--- a/internal/devbox/shell_test.go
+++ b/internal/devbox/shell_test.go
@@ -67,9 +67,8 @@ func testWriteDevboxShellrc(t *testing.T, testdirs []string) {
 			s := &DevboxShell{
 				devbox:          &Devbox{projectDir: projectDir},
 				env:             test.env,
-				projectDir:      "path/to/projectDir",
+				projectDir:      "/path/to/projectDir",
 				userShellrcPath: test.shellrcPath,
-				hooksFilePath:   test.hooksFilePath,
 			}
 			gotPath, err := s.writeDevboxShellrc()
 			if err != nil {
diff --git a/internal/devbox/testdata/shellrc/basic/shellrc.golden b/internal/devbox/testdata/shellrc/basic/shellrc.golden
index adef1f0c..9e110a4a 100644
--- a/internal/devbox/testdata/shellrc/basic/shellrc.golden
+++ b/internal/devbox/testdata/shellrc/basic/shellrc.golden
@@ -8,14 +8,17 @@ export simple="value";
 export space="quote me";
 export special="\$\`\"\\";
 
-# Prepend to the prompt to make it clear we're in a devbox shell.
-export PS1="(devbox) $PS1"
+# If the user hasn't specified they want to handle the prompt themselves,
+# prepend to the prompt to make it clear we're in a devbox shell.
+if [ -z "$DEVBOX_NO_PROMPT" ]; then
+  export PS1="(devbox) $PS1"
+fi
 
 # End Devbox Post-init Hook
 
 # Run plugin and user init hooks from the devbox.json directory.
 working_dir="$(pwd)"
-cd "path/to/projectDir" || exit
+cd "/path/to/projectDir" || exit
 
 # Source the hooks file, which contains the project's init hooks and plugin hooks.
 . /path/to/projectDir/.devbox/gen/scripts/.hooks.sh
diff --git a/internal/devbox/testdata/shellrc/noshellrc/shellrc.golden b/internal/devbox/testdata/shellrc/noshellrc/shellrc.golden
index 8e80a0ed..e7ac3d7b 100644
--- a/internal/devbox/testdata/shellrc/noshellrc/shellrc.golden
+++ b/internal/devbox/testdata/shellrc/noshellrc/shellrc.golden
@@ -2,14 +2,17 @@
 
 
 
-# Prepend to the prompt to make it clear we're in a devbox shell.
-export PS1="(devbox) $PS1"
+# If the user hasn't specified they want to handle the prompt themselves,
+# prepend to the prompt to make it clear we're in a devbox shell.
+if [ -z "$DEVBOX_NO_PROMPT" ]; then
+  export PS1="(devbox) $PS1"
+fi
 
 # End Devbox Post-init Hook
 
 # Run plugin and user init hooks from the devbox.json directory.
 working_dir="$(pwd)"
-cd "path/to/projectDir" || exit
+cd "/path/to/projectDir" || exit
 
 # Source the hooks file, which contains the project's init hooks and plugin hooks.
 . /path/to/projectDir/.devbox/gen/scripts/.hooks.sh
diff --git a/testscripts/add/add_outputs.test.txt b/testscripts/add/add_outputs.test.txt
deleted file mode 100644
index a03caf96..00000000
--- a/testscripts/add/add_outputs.test.txt
+++ /dev/null
@@ -1,32 +0,0 @@
-# Testscript to add packages with non-default outputs
-
-exec devbox init
-
-# Add prometheus with default outputs. It will not have promtool.
-exec devbox add prometheus
-exec devbox run -- prometheus --version
-! exec devbox run -- promtool --version
-
-# Add prometheus with cli and out outputs. It will have promtool as well.
-exec devbox add prometheus --outputs cli,out
-json.superset devbox.json expected_devbox.json
-exec devbox run -- promtool --version
-exec devbox run -- prometheus --version
-
-
-
--- devbox.json --
-{
-  "packages": [
-  ]
-}
-
--- expected_devbox.json --
-{
-  "packages": {
-    "prometheus": {
-      "version": "latest",
-      "outputs": ["cli", "out"]
-    }
-  }
-}
diff --git a/testscripts/add/add_platforms_flakeref.test.txt b/testscripts/add/add_platforms_flakeref.test.txt
index f58df62d..345fd45e 100644
--- a/testscripts/add/add_platforms_flakeref.test.txt
+++ b/testscripts/add/add_platforms_flakeref.test.txt
@@ -2,15 +2,9 @@
 
 exec devbox install
 
-# aside: choose armv7l-linux to verify that the add actually works on the
-# current host that is unlikely to be armv7l-linux
-exec devbox add github:F1bonacc1/process-compose/v0.40.2 --exclude-platform armv7l-linux
+exec devbox add github:F1bonacc1/process-compose/v0.40.2 --exclude-platform x86_64-darwin
 json.superset devbox.json expected_devbox1.json
 
-# verify that the package is installed on this platform
-exec devbox run -- process-compose version
-stdout '0.40.2'
-
 -- devbox.json --
 {
   "packages": [
@@ -25,7 +19,8 @@ stdout '0.40.2'
     "hello": "",
     "cowsay": "latest",
     "github:F1bonacc1/process-compose/v0.40.2": {
-      "excluded_platforms": ["armv7l-linux"]
+      "excluded_platforms": ["x86_64-darwin"]
     }
   }
 }
+
diff --git a/testscripts/add/global_add.test.txt b/testscripts/add/global_add.test.txt
index 373ebbde..0bb0ebc5 100644
--- a/testscripts/add/global_add.test.txt
+++ b/testscripts/add/global_add.test.txt
@@ -4,7 +4,7 @@
 ! exec vim --version
 exec devbox global add ripgrep vim
 
-exec devbox global shellenv --recompute
+exec devbox global shellenv
 source.path
 exec rg --version
 exec vim --version
diff --git a/testscripts/languages/php.test.txt b/testscripts/languages/php.test.txt
index 81247aa5..cb0371fd 100644
--- a/testscripts/languages/php.test.txt
+++ b/testscripts/languages/php.test.txt
@@ -1,13 +1,18 @@
 exec devbox run php index.php
 stdout 'done\n'
 
-exec devbox rm php83Extensions.ds
-exec devbox run php index.php
-stdout 'ds extension is not enabled'
+# temporarily disable rm test until we fix. It requires ensuring that package 
+# in profile matches the flake we are installing.
+# For non-flakes, this involves comparing versions. For flakes we need to compare 
+# the content (or hash)
 
-exec devbox add php83Extensions.ds
-exec devbox run php index.php
-stdout 'done\n'
+# exec devbox rm php81Extensions.ds
+# exec devbox run php index.php
+# stdout 'ds extension is not enabled'
+
+# exec devbox add php81Extensions.ds
+# exec devbox run php index.php
+# stdout 'done\n'
 
 -- devbox.json --
 {
diff --git a/testscripts/packages/unfree.test.txt b/testscripts/packages/unfree.test.txt
index c2c107c0..f8ae874f 100644
--- a/testscripts/packages/unfree.test.txt
+++ b/testscripts/packages/unfree.test.txt
@@ -4,6 +4,6 @@ exec devbox init
 
 # we could test with slack and/or vscode. Using slack since it is lighter.
 exec devbox add slack
-stderr 'Adding package "slack@latest" to devbox.json'
+stderr 'Installing package: slack.'
 
 exec devbox rm slack