Skip to content

Commit b5d56f6

Browse files
authored
[shell] add packages within shell, leveraging nix-profile (#188)
## Summary **Motivation:** We can define a custom nix-profile, and install packages scoped to this profile. The goal is to make it easier to add packages when within devbox shell. Currently, users need to exit the shell and restart it for the nix package to be installed - clearly not ideal. With a nix-profile, if we install the package using that profile, then the package's binary should be downloaded and visible within the shell. Within `devbox shell`: - previously, we would manually add the path to each package to `PATH`. - now, we can add the path to the nix-profile to `PATH` and skip manually adding each package. **Limitations:** 1. For `devbox add` within a `devbox shell`, the user needs to manually execute `hash -r` to ensure the latest binaries are visible. 2. ~For `devbox rm` within a `devbox shell`, the user still needs to restart the shell. I'm not quite sure why the `nix-env --profile <profile dir> --uninstall <pkg>` isn't working as intended.~ Fixed via reapplying `nix-env --profile <profile> -if development.nix` 3. `devbox add` and `rm` when in a shell started from a parent directory will not work. - A possible fix is to set `DEVBOX_CONFIG_DIR` env-var when inside a shell, and use that value in `devbox add` and `rm`. - Example: ``` > cd testdata/rust/ > devbox shell rust-stable (devbox)> devbox add go_1_18 Error: No devbox.json found in this directory, or any parent directories. Did you run `devbox init` yet? ``` ## How was it tested? - [x] in `testdata/rust/rust-stable`: - open `devbox shell` - verify openssl not installed via `which openssl` and getting `/usr/bin/openssl` - do `devbox add openssl` - note that `which openssl` still reflects the non nix-store location. It has NOT yet been updated. - do `hash -r` and then `which openssl` and see it point to a nix-store location - do `devbox rm openssl` - note that `which openssl` still reflects the nix-store location. It has NOT yet been updated. - restart `devbox shell` and do `which openssl` to verify it has been removed
1 parent de1214e commit b5d56f6

File tree

19 files changed

+196
-47
lines changed

19 files changed

+196
-47
lines changed

boxcli/add.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
package boxcli
55

66
import (
7+
"os"
8+
79
"github.com/pkg/errors"
810
"github.com/spf13/cobra"
911
"go.jetpack.io/devbox"
@@ -23,10 +25,11 @@ func AddCmd() *cobra.Command {
2325

2426
func addCmdFunc() runFunc {
2527
return func(cmd *cobra.Command, args []string) error {
26-
box, err := devbox.Open(".")
28+
box, err := devbox.Open(".", os.Stdout)
2729
if err != nil {
2830
return errors.WithStack(err)
2931
}
32+
3033
return box.Add(args...)
3134
}
3235
}

boxcli/build.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
package boxcli
55

66
import (
7+
"os"
8+
79
"github.com/pkg/errors"
810
"github.com/spf13/cobra"
911
"go.jetpack.io/devbox"
@@ -38,7 +40,7 @@ func buildCmdFunc(flags *docker.BuildFlags) runFunc {
3840
path := pathArg(args)
3941

4042
// Check the directory exists.
41-
box, err := devbox.Open(path)
43+
box, err := devbox.Open(path, os.Stdout)
4244
if err != nil {
4345
return errors.WithStack(err)
4446
}

boxcli/generate.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
package boxcli
55

66
import (
7+
"os"
8+
79
"github.com/pkg/errors"
810
"github.com/spf13/cobra"
911
"go.jetpack.io/devbox"
@@ -23,7 +25,7 @@ func runGenerateCmd(cmd *cobra.Command, args []string) error {
2325
path := pathArg(args)
2426

2527
// Check the directory exists.
26-
box, err := devbox.Open(path)
28+
box, err := devbox.Open(path, os.Stdout)
2729
if err != nil {
2830
return errors.WithStack(err)
2931
}

boxcli/plan.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ func runPlanCmd(cmd *cobra.Command, args []string) error {
2626
path := pathArg(args)
2727

2828
// Check the directory exists.
29-
box, err := devbox.Open(path)
29+
box, err := devbox.Open(path, os.Stdout)
3030
if err != nil {
3131
return errors.WithStack(err)
3232
}

boxcli/rm.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
package boxcli
55

66
import (
7+
"os"
8+
79
"github.com/pkg/errors"
810
"github.com/spf13/cobra"
911
"go.jetpack.io/devbox"
@@ -20,7 +22,7 @@ func RemoveCmd() *cobra.Command {
2022
}
2123

2224
func runRemoveCmd(cmd *cobra.Command, args []string) error {
23-
box, err := devbox.Open(".")
25+
box, err := devbox.Open(".", os.Stdout)
2426
if err != nil {
2527
return errors.WithStack(err)
2628
}

boxcli/shell.go

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,18 +29,15 @@ func runShellCmd(cmd *cobra.Command, args []string) error {
2929
path, cmds := parseShellArgs(cmd, args)
3030

3131
// Check the directory exists.
32-
box, err := devbox.Open(path)
32+
box, err := devbox.Open(path, os.Stdout)
3333
if err != nil {
3434
return errors.WithStack(err)
3535
}
3636

37-
inDevboxShell := os.Getenv("DEVBOX_SHELL_ENABLED")
38-
if inDevboxShell != "" && inDevboxShell != "0" && inDevboxShell != "false" {
37+
if devbox.IsDevboxShellEnabled() {
3938
return errors.New("You are already in an active devbox shell.\nRun 'exit' before calling devbox shell again. Shell inception is not supported.")
4039
}
4140

42-
fmt.Println("Installing nix packages. This may take a while...")
43-
4441
if len(cmds) > 0 {
4542
err = box.Exec(cmds...)
4643
} else {

devbox.go

Lines changed: 124 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,12 @@ package devbox
66

77
import (
88
"fmt"
9+
"io"
910
"os"
11+
"os/exec"
1012
"path/filepath"
13+
"strconv"
14+
"strings"
1115

1216
"github.com/pkg/errors"
1317
"go.jetpack.io/devbox/boxcli/usererr"
@@ -21,6 +25,9 @@ import (
2125
"golang.org/x/exp/slices"
2226
)
2327

28+
// profileDir contains the contents of the profile generated via `nix-env --profile profileDir <command>`
29+
const profileDir = ".devbox/profile"
30+
2431
// configFilename is name of the JSON file that defines a devbox environment.
2532
const configFilename = "devbox.json"
2633

@@ -37,10 +44,11 @@ type Devbox struct {
3744
cfg *Config
3845
// srcDir is the directory where the config file (devbox.json) resides
3946
srcDir string
47+
writer io.Writer
4048
}
4149

4250
// Open opens a devbox by reading the config file in dir.
43-
func Open(dir string) (*Devbox, error) {
51+
func Open(dir string, writer io.Writer) (*Devbox, error) {
4452

4553
cfgDir, err := findConfigDir(dir)
4654
if err != nil {
@@ -56,6 +64,7 @@ func Open(dir string) (*Devbox, error) {
5664
box := &Devbox{
5765
cfg: cfg,
5866
srcDir: cfgDir,
67+
writer: writer,
5968
}
6069
return box, nil
6170
}
@@ -72,22 +81,36 @@ func (d *Devbox) Add(pkgs ...string) error {
7281
}
7382
}
7483

75-
// Add to Packages only if it's not already there
84+
// Add to Packages to config only if it's not already there
7685
for _, pkg := range pkgs {
7786
if slices.Contains(d.cfg.Packages, pkg) {
7887
continue
7988
}
8089
d.cfg.Packages = append(d.cfg.Packages, pkg)
8190
}
82-
return d.saveCfg()
91+
if err := d.saveCfg(); err != nil {
92+
return err
93+
}
94+
95+
if err := d.ensurePackagesAreInstalled(install); err != nil {
96+
return err
97+
}
98+
return d.printPackageUpdateMessage(install, pkgs)
8399
}
84100

85101
// Remove removes Nix packages from the config so that it no longer exists in
86102
// the devbox environment.
87103
func (d *Devbox) Remove(pkgs ...string) error {
88104
// Remove packages from config.
89105
d.cfg.Packages = pkgslice.Exclude(d.cfg.Packages, pkgs)
90-
return d.saveCfg()
106+
if err := d.saveCfg(); err != nil {
107+
return err
108+
}
109+
110+
if err := d.ensurePackagesAreInstalled(uninstall); err != nil {
111+
return err
112+
}
113+
return d.printPackageUpdateMessage(uninstall, pkgs)
91114
}
92115

93116
// Build creates a Docker image containing a shell with the devbox environment.
@@ -142,35 +165,33 @@ func (d *Devbox) Generate() error {
142165
// Shell generates the devbox environment and launches nix-shell as a child
143166
// process.
144167
func (d *Devbox) Shell() error {
145-
if err := d.generateShellFiles(); err != nil {
146-
return errors.WithStack(err)
168+
169+
if err := d.ensurePackagesAreInstalled(install); err != nil {
170+
return err
147171
}
172+
148173
plan, err := d.ShellPlan()
149174
if err != nil {
150175
return errors.WithStack(err)
151176
}
152-
nixDir := filepath.Join(d.srcDir, ".devbox/gen/shell.nix")
153-
sh, err := nix.DetectShell(nix.WithPlanInitHook(plan.ShellInitHook))
177+
nixShellFilePath := filepath.Join(d.srcDir, ".devbox/gen/shell.nix")
178+
sh, err := nix.DetectShell(nix.WithPlanInitHook(plan.ShellInitHook), nix.WithProfile(d.profileDir()))
154179
if err != nil {
155180
// Fall back to using a plain Nix shell.
156181
sh = &nix.Shell{}
157182
}
158183
sh.UserInitHook = d.cfg.Shell.InitHook.String()
159-
return sh.Run(nixDir)
184+
return sh.Run(nixShellFilePath)
160185
}
161186

162187
func (d *Devbox) Exec(cmds ...string) error {
163-
plan, err := d.ShellPlan()
164-
if err != nil {
165-
return errors.WithStack(err)
166-
}
167-
if plan.Invalid() {
168-
return plan.Error()
169-
}
170-
err = generate(d.srcDir, plan, shellFiles)
171-
if err != nil {
172-
return errors.WithStack(err)
188+
if err := d.ensurePackagesAreInstalled(install); err != nil {
189+
return err
173190
}
191+
192+
pathWithProfileBin := fmt.Sprintf("PATH=%s:$PATH", d.profileBinDir())
193+
cmds = append([]string{pathWithProfileBin}, cmds...)
194+
174195
nixDir := filepath.Join(d.srcDir, ".devbox/gen/shell.nix")
175196
return nix.Exec(nixDir, cmds)
176197
}
@@ -226,6 +247,14 @@ func (d *Devbox) generateBuildFiles() error {
226247
return generate(d.srcDir, buildPlan, buildFiles)
227248
}
228249

250+
func (d *Devbox) profileDir() string {
251+
return filepath.Join(d.srcDir, profileDir)
252+
}
253+
254+
func (d *Devbox) profileBinDir() string {
255+
return filepath.Join(d.profileDir(), "bin")
256+
}
257+
229258
func missingDevboxJSONError(dir string) error {
230259

231260
// We try to prettify the `dir` before printing
@@ -266,3 +295,79 @@ func findConfigDir(dir string) (string, error) {
266295
}
267296
return "", missingDevboxJSONError(dir)
268297
}
298+
299+
// installMode is an enum for helping with ensurePackagesAreInstalled implementation
300+
type installMode string
301+
302+
const (
303+
install installMode = "install"
304+
uninstall installMode = "uninstall"
305+
)
306+
307+
func (d *Devbox) ensurePackagesAreInstalled(mode installMode) error {
308+
if err := d.Generate(); err != nil {
309+
return err
310+
}
311+
312+
installingVerb := "Installing"
313+
if mode == uninstall {
314+
installingVerb = "Uninstalling"
315+
}
316+
fmt.Fprintf(d.writer, "%s nix packages. This may take a while...", installingVerb)
317+
318+
// We need to re-install the packages
319+
if err := d.ApplyDevNixDerivation(); err != nil {
320+
fmt.Println()
321+
return err
322+
}
323+
fmt.Println("done.")
324+
325+
return nil
326+
}
327+
328+
func (d *Devbox) printPackageUpdateMessage(mode installMode, pkgs []string) error {
329+
// (Only when in devbox shell) Prompt the user to run `hash -r` to ensure their
330+
// shell can access the most recently installed binaries, or ensure their
331+
// recently uninstalled binaries are not accidentally still available.
332+
if len(pkgs) > 0 && IsDevboxShellEnabled() {
333+
installedVerb := "installed"
334+
if mode == uninstall {
335+
installedVerb = "removed"
336+
}
337+
338+
successMsg := fmt.Sprintf("%s is now %s.", pkgs[0], installedVerb)
339+
if len(pkgs) > 1 {
340+
successMsg = fmt.Sprintf("%s are now %s.", strings.Join(pkgs, ", "), installedVerb)
341+
}
342+
fmt.Fprint(d.writer, successMsg)
343+
fmt.Fprintln(d.writer, " Run `hash -r` to ensure your shell is updated.")
344+
}
345+
return nil
346+
}
347+
348+
// ApplyDevNixDerivation ensures the local profile has exactly the packages in the development.nix file
349+
//
350+
// Will move to a store interface/package
351+
func (d *Devbox) ApplyDevNixDerivation() error {
352+
353+
cmdStr := fmt.Sprintf(
354+
"--profile %s --install -f %s/.devbox/gen/development.nix",
355+
filepath.Join(d.srcDir, profileDir),
356+
d.srcDir,
357+
)
358+
cmdParts := strings.Split(cmdStr, " ")
359+
execCmd := exec.Command("nix-env", cmdParts...)
360+
361+
debug.Log("running command: %s\n", execCmd.Args)
362+
err := execCmd.Run()
363+
return errors.WithStack(err)
364+
}
365+
366+
// Move to a utility package?
367+
func IsDevboxShellEnabled() bool {
368+
inDevboxShell, err := strconv.ParseBool(os.Getenv("DEVBOX_SHELL_ENABLED"))
369+
if err != nil {
370+
return false
371+
}
372+
return inDevboxShell
373+
}

devbox_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ func testExample(t *testing.T, testPath string) {
3939
goldenFile := filepath.Join(baseDir, "plan.json")
4040
hasGoldenFile := fileExists(goldenFile)
4141

42-
box, err := Open(baseDir)
42+
box, err := Open(baseDir, os.Stdout)
4343
assert.NoErrorf(err, "%s should be a valid devbox project", baseDir)
4444

4545
// Just for tests, we make srcDir be a relative path so that the paths in plan.json

generate.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import (
2020
//go:embed tmpl/* tmpl/.*
2121
var tmplFS embed.FS
2222

23-
var shellFiles = []string{".gitignore", "shell.nix"}
23+
var shellFiles = []string{".gitignore", "development.nix", "shell.nix"}
2424
var buildFiles = []string{".gitignore", "development.nix", "runtime.nix", "Dockerfile", "Dockerfile.dockerignore"}
2525

2626
func generate(rootPath string, plan *plansdk.Plan, files []string) error {

0 commit comments

Comments
 (0)