Skip to content

Commit 36bc7c5

Browse files
committed
[Config Packages] Enable optional map spec
1 parent 7988140 commit 36bc7c5

File tree

11 files changed

+412
-13
lines changed

11 files changed

+412
-13
lines changed

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ require (
2727
github.com/google/go-cmp v0.5.9
2828
github.com/google/uuid v1.3.0
2929
github.com/hashicorp/go-envparse v0.1.0
30+
github.com/iancoleman/orderedmap v0.3.0
3031
github.com/mattn/go-isatty v0.0.18
3132
github.com/mholt/archiver/v4 v4.0.0-alpha.7
3233
github.com/pelletier/go-toml/v2 v2.0.7

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,8 @@ github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u
124124
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68=
125125
github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI=
126126
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
127+
github.com/iancoleman/orderedmap v0.3.0 h1:5cbR2grmZR/DiVt+VJopEhtVs9YGInGIxAoMJn+Ichc=
128+
github.com/iancoleman/orderedmap v0.3.0/go.mod h1:XuLcCUkdL5owUCQeF2Ue9uuw1EptkJDkXXS7VoV7XGE=
127129
github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
128130
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
129131
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=

internal/boxcli/midcobra/telemetry.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,5 +101,5 @@ func getPackagesAndCommitHash(c *cobra.Command) ([]string, string) {
101101
return []string{}, ""
102102
}
103103

104-
return box.Config().Packages, box.Config().NixPkgsCommitHash()
104+
return box.Config().Packages.VersionedNames(), box.Config().NixPkgsCommitHash()
105105
}

internal/cuecfg/json.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import (
1010
"github.com/pkg/errors"
1111
)
1212

13+
const Indent = " "
14+
1315
// MarshalJSON marshals the given value to JSON. It does not HTML escape and
1416
// adds standard indentation.
1517
//
@@ -19,7 +21,7 @@ import (
1921
func MarshalJSON(v interface{}) ([]byte, error) {
2022
buff := &bytes.Buffer{}
2123
e := json.NewEncoder(buff)
22-
e.SetIndent("", " ")
24+
e.SetIndent("", Indent)
2325
e.SetEscapeHTML(false)
2426
if err := e.Encode(v); err != nil {
2527
return nil, errors.WithStack(err)

internal/devconfig/config.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ const DefaultName = "devbox.json"
2222
type Config struct {
2323
// Packages is the slice of Nix packages that devbox makes available in
2424
// its environment. Deliberately do not omitempty.
25-
Packages []string `json:"packages"`
25+
Packages Packages `json:"packages"`
2626

2727
// Env allows specifying env variables
2828
Env map[string]string `json:"env,omitempty"`
@@ -57,7 +57,8 @@ type Stage struct {
5757

5858
func DefaultConfig() *Config {
5959
return &Config{
60-
Packages: []string{}, // initialize to empty slice instead of nil for consistent marshalling
60+
// initialize to empty slice instead of nil for consistent marshalling
61+
Packages: Packages{Collection: []Package{}},
6162
Shell: &shellConfig{
6263
Scripts: map[string]*shellcmd.Commands{
6364
"test": {

internal/devconfig/packages.go

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
package devconfig
2+
3+
import (
4+
"encoding/json"
5+
"strings"
6+
7+
"github.com/iancoleman/orderedmap"
8+
"github.com/pkg/errors"
9+
)
10+
11+
type jsonKind int
12+
13+
const (
14+
// jsonList is the legacy format for packages
15+
jsonList jsonKind = iota
16+
// jsonMap is the new format for packages
17+
jsonMap jsonKind = iota
18+
)
19+
20+
type Packages struct {
21+
jsonKind jsonKind
22+
23+
// Collection contains the set of package definitions
24+
// We don't want this key to be serialized automatically, hence the "key" in json is "-"
25+
Collection []Package `json:"-,omitempty"`
26+
}
27+
28+
// VersionedNames returns a list of package names with versions.
29+
// NOTE: if the package is unversioned, the version will be omitted (doesn't default to @latest).
30+
//
31+
// example:
32+
// ["package1", "package2@latest", "[email protected]"]
33+
func (pkgs *Packages) VersionedNames() []string {
34+
result := []string{}
35+
for _, p := range pkgs.Collection {
36+
name := p.name
37+
if p.Version != "" {
38+
name += "@" + p.Version
39+
}
40+
result = append(result, name)
41+
}
42+
return result
43+
}
44+
45+
// Add adds a package to the list of packages
46+
func (pkgs *Packages) Add(versionedName string) {
47+
name, version := parseVersionedName(versionedName)
48+
pkgs.Collection = append(pkgs.Collection, NewVersionOnlyPackage(name, version))
49+
}
50+
51+
// Remove removes a package from the list of packages
52+
func (pkgs *Packages) Remove(versionedName string) error {
53+
name, version := parseVersionedName(versionedName)
54+
for idx, pkg := range pkgs.Collection {
55+
if pkg.name == name && pkg.Version == version {
56+
pkgs.Collection = append(pkgs.Collection[:idx], pkgs.Collection[idx+1:]...)
57+
return nil
58+
}
59+
}
60+
return errors.Errorf("package %s not found", versionedName)
61+
}
62+
63+
func (pkgs *Packages) UnmarshalJSON(data []byte) error {
64+
65+
// First, attempt to unmarshal as a list of strings (legacy format)
66+
var packages []string
67+
if err := json.Unmarshal(data, &packages); err == nil {
68+
pkgs.Collection = packagesFromLegacyList(packages)
69+
pkgs.jsonKind = jsonList
70+
return nil
71+
}
72+
73+
// Second, attempt to unmarshal as a map of Packages
74+
// We use orderedmap to preserve the order of the packages. While the JSON
75+
// specification specifies that maps are unordered, we do rely on the order
76+
// for certain functionality.
77+
orderedMap := orderedmap.New()
78+
err := json.Unmarshal(data, &orderedMap)
79+
if err != nil {
80+
return errors.WithStack(err)
81+
}
82+
83+
packagesList := []Package{}
84+
for _, name := range orderedMap.Keys() {
85+
// The value may be a JSON object or a string
86+
packageValue, _ := orderedMap.Get(name)
87+
88+
// Test if the value is a JSON object. Since the Collection was unmarshalled
89+
// as an orderedmap, this JSON object will also be defaulted to an orderedmap.
90+
if packageMap, ok := packageValue.(orderedmap.OrderedMap); ok {
91+
p := NewPackage(name, &packageMap)
92+
packagesList = append(packagesList, p)
93+
94+
// Test if the value is a string:
95+
} else if packageString, ok := packageValue.(string); ok {
96+
p := NewVersionOnlyPackage(name, packageString)
97+
packagesList = append(packagesList, p)
98+
99+
} else {
100+
return errors.Errorf("invalid package %packageValue of type: %T", packageValue, packageValue)
101+
}
102+
}
103+
pkgs.Collection = packagesList
104+
pkgs.jsonKind = jsonMap
105+
return nil
106+
}
107+
108+
func (pkgs *Packages) MarshalJSON() ([]byte, error) {
109+
if pkgs.jsonKind == jsonList {
110+
packagesList := []string{}
111+
for _, p := range pkgs.Collection {
112+
113+
// Version may be empty for unversioned packages
114+
packageToWrite := p.name
115+
if p.Version != "" {
116+
packageToWrite += "@" + p.Version
117+
}
118+
packagesList = append(packagesList, packageToWrite)
119+
}
120+
return json.Marshal(packagesList)
121+
}
122+
123+
orderedMap := orderedmap.New()
124+
for _, p := range pkgs.Collection {
125+
orderedMap.Set(p.name, p)
126+
}
127+
return json.Marshal(orderedMap)
128+
}
129+
130+
type packageKind int
131+
132+
const (
133+
versionOnly packageKind = iota
134+
regular packageKind = iota
135+
)
136+
137+
type Package struct {
138+
kind packageKind
139+
name string
140+
Version string `json:"version"`
141+
142+
// TODO: add other fields like platforms
143+
}
144+
145+
func NewVersionOnlyPackage(name, version string) Package {
146+
return Package{
147+
kind: versionOnly,
148+
name: name,
149+
Version: version,
150+
}
151+
}
152+
153+
func NewPackage(name string, packageMap *orderedmap.OrderedMap) Package {
154+
version, ok := packageMap.Get("version")
155+
if !ok {
156+
// For legacy packages, the version may not be specified. We leave it blank
157+
// here, and code that consumes the Config is expected to handle this case
158+
// (e.g. by defaulting to @latest).
159+
version = ""
160+
}
161+
162+
return Package{
163+
kind: regular,
164+
name: name,
165+
Version: version.(string),
166+
}
167+
}
168+
169+
func (p Package) MarshalJSON() ([]byte, error) {
170+
if p.kind == versionOnly {
171+
return json.Marshal(p.Version)
172+
}
173+
174+
// If we have a regular package, we want to marshal the entire struct:
175+
type Alias Package // Use an alias-type to avoid infinite recursion
176+
return json.Marshal((Alias)(p))
177+
}
178+
179+
// parseVersionedName parses the name and version from package@version representation
180+
func parseVersionedName(versionedName string) (name, version string) {
181+
// use the last @ symbol as the version delimiter, some packages have @ in the name
182+
atSymbolIndex := strings.LastIndex(versionedName, "@")
183+
if atSymbolIndex != -1 {
184+
// Common case: package@version
185+
if atSymbolIndex != len(versionedName)-1 {
186+
name, version = versionedName[:atSymbolIndex], versionedName[atSymbolIndex+1:]
187+
} else {
188+
// This case handles packages that end with `@` in the name
189+
// example: `emacsPackages.@`
190+
name = versionedName[:atSymbolIndex] + "@"
191+
}
192+
} else {
193+
// Case without any @version: package
194+
name = versionedName
195+
196+
// We deliberately do not set version to latest so that we don't
197+
// automatically modify the devbox.json file. It should only be modified
198+
// upon `devbox update`.
199+
// version = "latest"
200+
}
201+
return name, version
202+
}
203+
204+
// packagesFromLegacyList converts a list of strings to a list of packages
205+
// Example inputs: `["python@latest", "hello", "cowsay@1"]`
206+
func packagesFromLegacyList(packages []string) []Package {
207+
packagesList := []Package{}
208+
for _, p := range packages {
209+
name, version := parseVersionedName(p)
210+
packagesList = append(packagesList, NewVersionOnlyPackage(name, version))
211+
}
212+
return packagesList
213+
}

0 commit comments

Comments
 (0)