Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion internal/boxcli/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ func initCmd() *cobra.Command {
path, _ = os.Getwd()
}
if errors.Is(err, os.ErrExist) {
ux.Fwarningf(cmd.ErrOrStderr(), "devbox.json already exists in %q.", path)
ux.Fwarningf(cmd.ErrOrStderr(), "A devbox config already exists in %q.", path)
return nil
}
if err != nil {
Expand Down
17 changes: 15 additions & 2 deletions internal/boxcli/multi/multi.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package multi
import (
"io/fs"
"path/filepath"
"slices"

"go.jetify.com/devbox/internal/debug"
"go.jetify.com/devbox/internal/devbox"
Expand All @@ -14,16 +15,28 @@ func Open(opts *devopt.Opts) ([]*devbox.Devbox, error) {
defer debug.FunctionTimer().End()

var boxes []*devbox.Devbox
// A single directory may contain more than one recognized config name
// (e.g. both devbox.json and devbox.jsonc). Track the directories already
// opened so each project is opened exactly once.
seenDirs := map[string]bool{}
err := filepath.WalkDir(
".",
func(path string, dirEntry fs.DirEntry, err error) error {
if err != nil {
return err
}

if !dirEntry.IsDir() && filepath.Base(path) == configfile.DefaultName {
if !dirEntry.IsDir() && slices.Contains(configfile.ValidNames, filepath.Base(path)) {
dir := filepath.Dir(path)
if seenDirs[dir] {
return nil
}
seenDirs[dir] = true

optsCopy := *opts
optsCopy.Dir = path
// Open by directory so devconfig applies its filename
// precedence (devbox.json wins over devbox.jsonc).
optsCopy.Dir = dir
box, err := devbox.Open(&optsCopy)
if err != nil {
return err
Expand Down
44 changes: 44 additions & 0 deletions internal/boxcli/multi/multi_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package multi

import (
"io"
"os"
"path/filepath"
"testing"

"go.jetify.com/devbox/internal/devbox/devopt"
"go.jetify.com/devbox/internal/devconfig/configfile"
)

// TestOpenDeduplicatesConfigsInSameDir ensures that a directory containing more
// than one recognized config name (e.g. both devbox.json and devbox.jsonc) is
// opened only once, so `--all-projects` commands don't run twice for it.
func TestOpenDeduplicatesConfigsInSameDir(t *testing.T) {
root := t.TempDir()

// projBoth has both config names; projJSON has only devbox.json.
projBoth := filepath.Join(root, "projBoth")
projJSON := filepath.Join(root, "projJSON")
for _, dir := range []string{projBoth, projJSON} {
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, configfile.DefaultName), []byte(`{"packages": []}`), 0o644); err != nil {
t.Fatal(err)
}
}
if err := os.WriteFile(filepath.Join(projBoth, configfile.AltName), []byte(`{"packages": []}`), 0o644); err != nil {
t.Fatal(err)
}

// multi.Open walks the current working directory.
t.Chdir(root)

boxes, err := Open(&devopt.Opts{Stderr: io.Discard})
if err != nil {
t.Fatalf("Open() error: %v", err)
}
if len(boxes) != 2 {
t.Errorf("Open() opened %d projects, want 2 (projBoth must be opened once despite having both config names)", len(boxes))
}
}
2 changes: 2 additions & 0 deletions internal/devbox/devbox.go
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,7 @@ func (d *Devbox) GenerateDevcontainer(ctx context.Context, generateOpts devopt.G
IsDevcontainer: true,
Pkgs: d.AllPackageNamesIncludingRemovedTriggerPackages(),
LocalFlakeDirs: d.getLocalFlakesDirs(),
ConfigFileName: d.cfg.Root.FileName(),
}

// generate dockerfile
Expand Down Expand Up @@ -541,6 +542,7 @@ func (d *Devbox) GenerateDockerfile(ctx context.Context, generateOpts devopt.Gen
IsDevcontainer: false,
Pkgs: d.AllPackageNamesIncludingRemovedTriggerPackages(),
LocalFlakeDirs: d.getLocalFlakesDirs(),
ConfigFileName: d.cfg.Root.FileName(),
}

scripts := d.cfg.Scripts()
Expand Down
6 changes: 6 additions & 0 deletions internal/devbox/generate/devcontainer_util.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"github.com/samber/lo"
"go.jetify.com/devbox/internal/boxcli/usererr"
"go.jetify.com/devbox/internal/devbox/devopt"
"go.jetify.com/devbox/internal/devconfig/configfile"
)

//go:embed tmpl/*
Expand All @@ -34,6 +35,10 @@ type Options struct {
IsDevcontainer bool
Pkgs []string
LocalFlakeDirs []string
// ConfigFileName is the basename of the project's config file
// (devbox.json or devbox.jsonc) so generated Dockerfiles copy the right
// one. Defaults to devbox.json when empty.
ConfigFileName string
}

type devcontainerObject struct {
Expand Down Expand Up @@ -112,6 +117,7 @@ func (g *Options) CreateDockerfile(
"IsDevcontainer": g.IsDevcontainer,
"RootUser": g.RootUser,
"LocalFlakeDirs": g.LocalFlakeDirs,
"ConfigFileName": cmp.Or(g.ConfigFileName, configfile.DefaultName),

// The following are only used for prod Dockerfile
"DevboxRunInstall": lo.Ternary(opts.HasInstall, "devbox run install", "echo 'No install script found, skipping'"),
Expand Down
29 changes: 29 additions & 0 deletions internal/devbox/generate/devcontainer_util_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,32 @@ func generateDevDockerfile(t *testing.T, rootUser bool) string {
}
return string(contents)
}

// TestCreateDockerfileDevCopiesConfigFileName ensures the generated dev
// Dockerfile copies the project's actual config file, which may be named
// devbox.jsonc rather than devbox.json.
func TestCreateDockerfileDevCopiesConfigFileName(t *testing.T) {
for _, name := range []string{"", "devbox.json", "devbox.jsonc"} {
dir := t.TempDir()
g := &Options{Path: dir, ConfigFileName: name}
if err := g.CreateDockerfile(t.Context(), CreateDockerfileOptions{ForType: "dev"}); err != nil {
t.Fatalf("CreateDockerfile(ConfigFileName=%q) failed: %v", name, err)
}
contents, err := os.ReadFile(filepath.Join(dir, "Dockerfile"))
if err != nil {
t.Fatal(err)
}
want := name
if want == "" {
want = "devbox.json"
}
// Non-root images use `COPY --chown=... <src> <dst>`; match on the
// trailing "<src> <dst>" pair so both forms are covered.
if !strings.Contains(string(contents), " "+want+" "+want+"\n") {
t.Errorf("ConfigFileName=%q: Dockerfile should copy %s, got:\n%s", name, want, contents)
}
if want != "devbox.json" && strings.Contains(string(contents), " devbox.json devbox.json") {
t.Errorf("ConfigFileName=%q: Dockerfile should not also copy devbox.json, got:\n%s", name, contents)
}
}
}
4 changes: 2 additions & 2 deletions internal/devbox/generate/tmpl/dev.Dockerfile.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@ WORKDIR /code
USER root:root
RUN mkdir -p /code && chown ${DEVBOX_USER}:${DEVBOX_USER} /code
USER ${DEVBOX_USER}:${DEVBOX_USER}
COPY --chown=${DEVBOX_USER}:${DEVBOX_USER} devbox.json devbox.json
COPY --chown=${DEVBOX_USER}:${DEVBOX_USER} {{.ConfigFileName}} {{.ConfigFileName}}
COPY --chown=${DEVBOX_USER}:${DEVBOX_USER} devbox.lock devbox.lock
{{- else}}
COPY devbox.json devbox.json
COPY {{.ConfigFileName}} {{.ConfigFileName}}
COPY devbox.lock devbox.lock
{{- end}}

Expand Down
2 changes: 1 addition & 1 deletion internal/devbox/generate/tmpl/envrcContent.tmpl
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use_devbox() {
eval "$(devbox shellenv --init-hook --install --no-refresh-alias{{ if .EnvFlag }} {{ .EnvFlag }}{{ end }}{{ if .ConfigDir }} {{ .ConfigDir }}{{ end }})"
watch_file $DEVBOX_PROJECT_ROOT/devbox.json $DEVBOX_PROJECT_ROOT/devbox.lock
watch_file $DEVBOX_PROJECT_ROOT/devbox.json $DEVBOX_PROJECT_ROOT/devbox.jsonc $DEVBOX_PROJECT_ROOT/devbox.lock
}
use devbox
{{ if .EnvFile }}
Expand Down
5 changes: 2 additions & 3 deletions internal/devconfig/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,8 +142,7 @@ func Find(path string) (*Config, error) {
// searchDir looks for a config file in dir. It does not search parent
// directories.
func searchDir(dir string) (*Config, error) {
try := []string{configfile.DefaultName}
for _, name := range try {
for _, name := range configfile.ValidNames {
path := filepath.Join(dir, name)
slog.Debug("trying config file", "path", path)

Expand All @@ -156,7 +155,7 @@ func searchDir(dir string) (*Config, error) {
if errors.Is(err, os.ErrNotExist) {
continue
}
// Ignore directories named devbox.json.
// Ignore directories that happen to share a config filename.
if errors.Is(err, errIsDirectory) {
continue
}
Expand Down
97 changes: 97 additions & 0 deletions internal/devconfig/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,86 @@ func TestFindError(t *testing.T) {
})
}

func TestJSONCConfig(t *testing.T) {
const jsonc = "{\n // devbox lets you comment your config\n \"packages\": []\n}\n"

t.Run("OpenDiscoversJSONC", func(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, configfile.AltName)
if err := os.WriteFile(path, []byte(jsonc), 0o644); err != nil {
t.Fatal(err)
}

cfg, err := Open(dir)
if err != nil {
t.Fatalf("Open(%q) error: %v", dir, err)
}
if cfg.Root.AbsRootPath != path {
t.Errorf("cfg.Root.AbsRootPath = %q, want %q", cfg.Root.AbsRootPath, path)
}
})

t.Run("FindDiscoversJSONCInParent", func(t *testing.T) {
root, child, _ := mkNestedDirs(t)
path := filepath.Join(root, configfile.AltName)
if err := os.WriteFile(path, []byte(jsonc), 0o644); err != nil {
t.Fatal(err)
}

cfg, err := Find(child)
if err != nil {
t.Fatalf("Find(%q) error: %v", child, err)
}
if cfg.Root.AbsRootPath != path {
t.Errorf("cfg.Root.AbsRootPath = %q, want %q", cfg.Root.AbsRootPath, path)
}
})

t.Run("DefaultNameWinsWhenBothExist", func(t *testing.T) {
dir := t.TempDir()
jsonPath := filepath.Join(dir, configfile.DefaultName)
if err := os.WriteFile(jsonPath, []byte(`{"packages": []}`), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, configfile.AltName), []byte(jsonc), 0o644); err != nil {
t.Fatal(err)
}

cfg, err := Open(dir)
if err != nil {
t.Fatalf("Open(%q) error: %v", dir, err)
}
if cfg.Root.AbsRootPath != jsonPath {
t.Errorf("cfg.Root.AbsRootPath = %q, want %q", cfg.Root.AbsRootPath, jsonPath)
}
})

t.Run("SaveWritesBackToJSONC", func(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, configfile.AltName)
if err := os.WriteFile(path, []byte(jsonc), 0o644); err != nil {
t.Fatal(err)
}

cfg, err := Open(dir)
if err != nil {
t.Fatalf("Open(%q) error: %v", dir, err)
}
if err := cfg.Root.SaveTo(dir); err != nil {
t.Fatalf("SaveTo(%q) error: %v", dir, err)
}

// Saving must write back to devbox.jsonc, not create a devbox.json.
if _, err := os.Stat(filepath.Join(dir, configfile.DefaultName)); !errors.Is(err, fs.ErrNotExist) {
t.Errorf("SaveTo created a %s; want it to update %s in place",
configfile.DefaultName, configfile.AltName)
}
if _, err := os.Stat(path); err != nil {
t.Errorf("os.Stat(%q) after save: %v", path, err)
}
})
}

// mkNestedDirs sets up a nested directory structure for Find and Open tests.
func mkNestedDirs(t *testing.T) (root, child, nested string) {
t.Helper()
Expand Down Expand Up @@ -599,3 +679,20 @@ func (p *testLockProject) ConfigHash() (string, error) { return "", nil }
func (p *testLockProject) Stdenv() flake.Ref { return flake.Ref{} }
func (p *testLockProject) AllPackageNamesIncludingRemovedTriggerPackages() []string { return nil }
func (p *testLockProject) ProjectDir() string { return p.dir }

func TestInitRefusesWhenJSONCExists(t *testing.T) {
dir := t.TempDir()
jsoncPath := filepath.Join(dir, configfile.AltName)
if err := os.WriteFile(jsoncPath, []byte("{\n // comment\n \"packages\": []\n}\n"), 0o644); err != nil {
t.Fatal(err)
}

_, err := Init(dir)
if !errors.Is(err, fs.ErrExist) {
t.Fatalf("Init() with existing %s: got err %v, want fs.ErrExist", configfile.AltName, err)
}
// Init must not have created a devbox.json that would shadow the jsonc.
if _, err := os.Stat(filepath.Join(dir, configfile.DefaultName)); !errors.Is(err, fs.ErrNotExist) {
t.Errorf("Init() created %s next to an existing %s", configfile.DefaultName, configfile.AltName)
}
}
29 changes: 26 additions & 3 deletions internal/devconfig/configfile/file.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,19 @@ import (

const (
DefaultName = "devbox.json"
// AltName is an alternate config filename that devbox also recognizes.
// devbox.json already permits comments (it is parsed as JSONC), but
// editors and GitHub diffs flag comments in a .json file as errors. Naming
// the file devbox.jsonc lets those tools highlight it correctly without any
// extra configuration. See https://github.com/jetify-com/devbox/issues/2602
AltName = "devbox.jsonc"
)

// ValidNames are the config filenames devbox recognizes, in the order they are
// searched for within a directory. devbox.json is listed first so it wins when
// a directory happens to contain both files.
var ValidNames = []string{DefaultName, AltName}

// ConfigFile defines a devbox environment as JSON.
type ConfigFile struct {
// AbsRootPath is the absolute path to the devbox.json or plugin.json file
Expand Down Expand Up @@ -114,9 +125,21 @@ func (c *ConfigFile) InitHook() *shellcmd.Commands {
return c.Shell.InitHook
}

// SaveTo writes the config to a file.
func (c *ConfigFile) SaveTo(path string) error {
return os.WriteFile(filepath.Join(path, DefaultName), c.Bytes(), 0o644)
// FileName returns the base name of the config file (e.g. "devbox.json" or
// "devbox.jsonc"). It preserves whatever name the config was loaded from so
// that saving writes back to the same file. It falls back to [DefaultName] when
// the config has no on-disk path (for example, a config loaded from a URL).
func (c *ConfigFile) FileName() string {
if c.AbsRootPath != "" {
return filepath.Base(c.AbsRootPath)
}
return DefaultName
}

// SaveTo writes the config into the directory dir, using the config's original
// filename (see [ConfigFile.FileName]).
func (c *ConfigFile) SaveTo(dir string) error {
return os.WriteFile(filepath.Join(dir, c.FileName()), c.Bytes(), 0o644)
}

// TODO: Can we remove SaveTo and just use Save()?
Expand Down
Loading
Loading