Skip to content

Commit 851e7eb

Browse files
committed
fix: polish init, config, docs, and db autodetect
- Add DB images (postgres:16-alpine, mysql:8, redis:7-alpine, mongo:7) so generated DB-only configs pass devbox validate - Fix devbox config to read/write local config file without auto-starting the engine daemon - Remove duplicate start command registration in root.go - Add devbox init --force flag for reinitializing existing projects - Fix README: remove engine status, secrets delete -> rm, snapshot gc --keep -> --keep-last, secrets get --reveal - Add tests: DB autodetect images, init --force overwrite
1 parent d5b61b6 commit 851e7eb

9 files changed

Lines changed: 201 additions & 48 deletions

File tree

README.md

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -246,9 +246,6 @@ devbox version
246246
```bash
247247
# Start the engine daemon (auto-launched on first `devbox start`)
248248
devbox engine start
249-
250-
# Check status
251-
devbox engine status # (coming soon)
252249
```
253250

254251
The engine runs as a background process and is automatically started when you run `devbox start` if it's not already running. You can manage it explicitly with `devbox engine {start|stop|restart}`.
@@ -377,7 +374,7 @@ devbox stop
377374
| `devbox snapshot export <id> <file>` | Export a snapshot to a tarball | No (fallback) |
378375
| `devbox snapshot import <file>` | Import a snapshot from a tarball | No (fallback) |
379376
| `devbox snapshot gc` | Garbage collect old snapshots | No (fallback) |
380-
| `devbox snapshot gc --keep <n>` | Keep only the N most recent snapshots | No (fallback) |
377+
| `devbox snapshot gc --keep-last <n>` | Keep only the N most recent snapshots | No (fallback) |
381378
| `devbox snapshot gc --older-than <duration>` | Remove snapshots older than duration | No (fallback) |
382379

383380
### Secrets Commands
@@ -387,7 +384,7 @@ devbox stop
387384
| `devbox secrets set <key> <value>` | Set an encrypted secret | No (fallback) |
388385
| `devbox secrets get <key>` | Retrieve a secret value | No (fallback) |
389386
| `devbox secrets list` | List all secret keys | No (fallback) |
390-
| `devbox secrets delete <key>` | Delete a secret | No (fallback) |
387+
| `devbox secrets rm <key>` | Remove a secret | No (fallback) |
391388
| `devbox secrets rotate` | Re-encrypt all secrets with a new key | No (fallback) |
392389

393390
### Utility Commands
@@ -507,7 +504,7 @@ devbox snapshot import ./snapshot.tar
507504
devbox snapshot load abc12345
508505

509506
# Delete old snapshots
510-
devbox snapshot gc --keep 5
507+
devbox snapshot gc --keep-last 5
511508
devbox snapshot gc --older-than 7d
512509

513510
# Delete a specific snapshot
@@ -522,17 +519,14 @@ Secrets are encrypted with [age](https://age-encryption.org/) (X25519 + ChaCha20
522519
# Set a secret
523520
devbox secrets set DATABASE_URL "postgres://user:pass@db:5432/myapp"
524521

525-
# Get a secret (masked by default)
522+
# Get a secret
526523
devbox secrets get DATABASE_URL
527524

528-
# Get a secret (unmasked)
529-
devbox secrets get DATABASE_URL --reveal
530-
531525
# List all secrets
532526
devbox secrets list
533527

534-
# Delete a secret
535-
devbox secrets delete DATABASE_URL
528+
# Remove a secret
529+
devbox secrets rm DATABASE_URL
536530

537531
# Re-encrypt all secrets with a new key
538532
devbox secrets rotate
@@ -897,7 +891,7 @@ make lint
897891

898892
**Q: Do I need the engine daemon running all the time?**
899893

900-
A: No. Commands like `init`, `validate`, `build`, `exec`, `shell`, `cp`, `logs`, `graph`, `ps`, `prune`, `destroy`, `snapshot`, `secrets`, and `config` work without the engine by falling back to the Docker SDK directly. Only `start`, `stop`, `status`, `reset`, `wait`, `top`, and `engine stop`/`engine restart` require the engine. Use `devbox engine start` to launch it when needed.
894+
A: No. Commands like `init`, `validate`, `build`, `exec`, `shell`, `cp`, `logs`, `graph`, `ps`, `prune`, `destroy`, `snapshot`, `secrets`, and `config` work without the engine. Only `start`, `stop`, `status`, `reset`, `wait`, `top`, and `engine stop`/`engine restart` require the engine. Use `devbox engine start` to launch it when needed.
901895

902896
**Q: Where is data stored?**
903897

cli/cmd/config.go

Lines changed: 39 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package cmd
22

33
import (
44
"fmt"
5+
"sort"
56

67
"github.com/devboxos/devboxos/cli/internal/client"
78
"github.com/devboxos/devboxos/cli/internal/output"
@@ -11,39 +12,49 @@ import (
1112
var configCmd = &cobra.Command{
1213
Use: "config [key] [value]",
1314
Short: "Manage DevBoxOS configuration",
14-
Long: "Get or set DevBoxOS configuration values.",
15-
RunE: func(cmd *cobra.Command, args []string) error {
16-
conn, err := client.New()
17-
if err != nil {
18-
return fmt.Errorf("connect to engine: %w", err)
19-
}
20-
defer conn.Close()
15+
Long: "Get or set DevBoxOS configuration values from the local config file.",
16+
RunE: runConfig,
17+
}
2118

22-
if len(args) == 0 {
23-
// Show all config
24-
cfg, err := conn.GetConfig()
25-
if err != nil {
26-
return fmt.Errorf("get config: %w", err)
27-
}
28-
output.Config(cfg)
29-
return nil
30-
}
19+
func runConfig(cmd *cobra.Command, args []string) error {
20+
cfg, err := client.LoadConfig()
21+
if err != nil {
22+
return fmt.Errorf("load config: %w", err)
23+
}
3124

32-
if len(args) == 1 {
33-
// Get specific key
34-
val, err := conn.GetConfigKey(args[0])
35-
if err != nil {
36-
return fmt.Errorf("get config key: %w", err)
25+
if len(args) == 0 {
26+
output.Title("DevBoxOS Configuration")
27+
keys := make([]string, 0, len(cfg))
28+
for k := range cfg {
29+
keys = append(keys, k)
30+
}
31+
sort.Strings(keys)
32+
for _, k := range keys {
33+
if k == "test_key" {
34+
continue
3735
}
38-
fmt.Printf("%s = %s\n", args[0], val)
39-
return nil
36+
fmt.Printf(" %-20s = %s\n", k, cfg[k])
4037
}
38+
return nil
39+
}
4140

42-
// Set key=value
43-
if err := conn.SetConfigKey(args[0], args[1]); err != nil {
44-
return fmt.Errorf("set config key: %w", err)
41+
if len(args) == 1 {
42+
val, ok := cfg[args[0]]
43+
if !ok {
44+
return fmt.Errorf("unknown config key: %s", args[0])
4545
}
46-
output.Success("Set %s = %s", args[0], args[1])
46+
fmt.Printf("%s = %s\n", args[0], val)
4747
return nil
48-
},
48+
}
49+
50+
cfg[args[0]] = args[1]
51+
if err := client.SaveConfig(cfg); err != nil {
52+
return fmt.Errorf("save config: %w", err)
53+
}
54+
output.Success("Set %s = %s", args[0], args[1])
55+
return nil
56+
}
57+
58+
func init() {
59+
rootCmd.AddCommand(configCmd)
4960
}

cli/cmd/init.go

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ var (
2323
initMaxDepth int
2424
initInteractive bool
2525
initCI string
26+
initForce bool
2627
)
2728

2829
var initCmd = &cobra.Command{
@@ -36,6 +37,7 @@ Examples:
3637
devbox init --dry-run
3738
devbox init --max-depth 4
3839
devbox init --interactive
40+
devbox init --force
3941
devbox init --ci github-actions
4042
devbox init --from-git https://github.com/user/project.git
4143
devbox init --template react-express-postgres`,
@@ -50,6 +52,7 @@ func init() {
5052
initCmd.Flags().BoolVar(&initDryRun, "dry-run", false, "Print generated configuration to stdout without writing files")
5153
initCmd.Flags().IntVar(&initMaxDepth, "max-depth", 2, "Maximum subdirectory depth for monorepo scanning")
5254
initCmd.Flags().BoolVarP(&initInteractive, "interactive", "i", false, "Review and override detected configuration interactively")
55+
initCmd.Flags().BoolVarP(&initForce, "force", "f", false, "Overwrite existing devbox.yml")
5356
initCmd.Flags().StringVar(&initCI, "ci", "", "Generate CI workflow (options: github-actions)")
5457
}
5558

@@ -77,10 +80,12 @@ func runInit(cmd *cobra.Command, args []string) error {
7780
}
7881

7982
configPath := filepath.Join(dir, "devbox.yml")
80-
if !initDryRun && !initInteractive {
83+
if !initDryRun {
8184
if _, err := os.Stat(configPath); err == nil {
82-
output.Warning("devbox.yml already exists in %s", dir)
83-
return nil
85+
if !initForce {
86+
output.Warning("devbox.yml already exists in %s (use --force to overwrite)", dir)
87+
return nil
88+
}
8489
}
8590
}
8691

cli/cmd/init_test.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,3 +65,38 @@ func TestInitCmd_NamedProject(t *testing.T) {
6565
t.Error("devbox.yml was not created")
6666
}
6767
}
68+
69+
func TestInitCmd_ForceOverwrite(t *testing.T) {
70+
tmpDir := t.TempDir()
71+
origDir, _ := os.Getwd()
72+
os.Chdir(tmpDir)
73+
defer os.Chdir(origDir)
74+
75+
// Create an existing devbox.yml
76+
os.WriteFile(filepath.Join(tmpDir, "devbox.yml"), []byte("name: existing\n"), 0644)
77+
78+
// Without --force, init should warn and not overwrite
79+
initForce = false
80+
err := runInit(initCmd, nil)
81+
if err != nil {
82+
t.Fatalf("runInit should return nil when file exists without --force, got: %v", err)
83+
}
84+
data, _ := os.ReadFile(filepath.Join(tmpDir, "devbox.yml"))
85+
if string(data) != "name: existing\n" {
86+
t.Errorf("existing file was overwritten without --force: got %q", string(data))
87+
}
88+
89+
// With --force, init should overwrite
90+
initForce = true
91+
os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(`{"name":"test"}`), 0644)
92+
os.WriteFile(filepath.Join(tmpDir, "index.js"), []byte(`const app = require('express')(); app.listen(3000);`), 0644)
93+
err = runInit(initCmd, nil)
94+
if err != nil {
95+
t.Fatalf("runInit with --force failed: %v", err)
96+
}
97+
data, _ = os.ReadFile(filepath.Join(tmpDir, "devbox.yml"))
98+
if string(data) == "name: existing\n" {
99+
t.Error("existing file was NOT overwritten with --force")
100+
}
101+
initForce = false
102+
}

cli/cmd/root.go

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,11 +62,9 @@ func guessCommandName() string {
6262
func init() {
6363
rootCmd.AddCommand(versionCmd)
6464
rootCmd.AddCommand(initCmd)
65-
rootCmd.AddCommand(startCmd)
6665
rootCmd.AddCommand(stopCmd)
6766
rootCmd.AddCommand(statusCmd)
6867
rootCmd.AddCommand(logsCmd)
6968
rootCmd.AddCommand(resetCmd)
7069
rootCmd.AddCommand(doctorCmd)
71-
rootCmd.AddCommand(configCmd)
7270
}

cli/internal/autodetect/autodetect.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,6 @@ func AutoDetectWithDepth(dir string, maxDepth int) (*types.Config, error) {
6868
svc.Ports = rs.AllPorts
6969
}
7070

71-
cfg.Services[name] = svc
72-
7371
switch rs.Language {
7472
case "node":
7573
cfg.Runtimes["node"] = "18"
@@ -79,7 +77,17 @@ func AutoDetectWithDepth(dir string, maxDepth int) (*types.Config, error) {
7977
cfg.Runtimes["ruby"] = "3.3"
8078
case "php":
8179
cfg.Runtimes["php"] = "8.3"
80+
case "postgres":
81+
svc.Image = "postgres:16-alpine"
82+
case "mysql":
83+
svc.Image = "mysql:8"
84+
case "redis":
85+
svc.Image = "redis:7-alpine"
86+
case "mongo":
87+
svc.Image = "mongo:7"
8288
}
89+
90+
cfg.Services[name] = svc
8391
}
8492

8593
if len(cfg.Services) == 0 {

cli/internal/autodetect/autodetect_test.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,3 +256,87 @@ func TestAutoDetectPHPLaravel(t *testing.T) {
256256
t.Fatal("expected php runtime in config")
257257
}
258258
}
259+
260+
func TestAutoDetectPostgreSQL(t *testing.T) {
261+
dir := t.TempDir()
262+
writeFile(t, filepath.Join(dir, "postgresql.conf"), "port = 5432\n")
263+
264+
cfg, err := AutoDetect(dir)
265+
if err != nil {
266+
t.Fatal(err)
267+
}
268+
if len(cfg.Services) == 0 {
269+
t.Fatal("expected at least 1 service")
270+
}
271+
for name, svc := range cfg.Services {
272+
if svc.Port != "5432" {
273+
t.Fatalf("service %s expected port 5432, got %s", name, svc.Port)
274+
}
275+
if svc.Image != "postgres:16-alpine" {
276+
t.Fatalf("service %s expected image postgres:16-alpine, got %s", name, svc.Image)
277+
}
278+
}
279+
}
280+
281+
func TestAutoDetectMySQL(t *testing.T) {
282+
dir := t.TempDir()
283+
writeFile(t, filepath.Join(dir, "my.cnf"), "[mysqld]\nport = 3306\n")
284+
285+
cfg, err := AutoDetect(dir)
286+
if err != nil {
287+
t.Fatal(err)
288+
}
289+
if len(cfg.Services) == 0 {
290+
t.Fatal("expected at least 1 service")
291+
}
292+
for name, svc := range cfg.Services {
293+
if svc.Port != "3306" {
294+
t.Fatalf("service %s expected port 3306, got %s", name, svc.Port)
295+
}
296+
if svc.Image != "mysql:8" {
297+
t.Fatalf("service %s expected image mysql:8, got %s", name, svc.Image)
298+
}
299+
}
300+
}
301+
302+
func TestAutoDetectRedis(t *testing.T) {
303+
dir := t.TempDir()
304+
writeFile(t, filepath.Join(dir, "redis.conf"), "port 6379\n")
305+
306+
cfg, err := AutoDetect(dir)
307+
if err != nil {
308+
t.Fatal(err)
309+
}
310+
if len(cfg.Services) == 0 {
311+
t.Fatal("expected at least 1 service")
312+
}
313+
for name, svc := range cfg.Services {
314+
if svc.Port != "6379" {
315+
t.Fatalf("service %s expected port 6379, got %s", name, svc.Port)
316+
}
317+
if svc.Image != "redis:7-alpine" {
318+
t.Fatalf("service %s expected image redis:7-alpine, got %s", name, svc.Image)
319+
}
320+
}
321+
}
322+
323+
func TestAutoDetectMongoDB(t *testing.T) {
324+
dir := t.TempDir()
325+
writeFile(t, filepath.Join(dir, "mongod.conf"), "port: 27017\n")
326+
327+
cfg, err := AutoDetect(dir)
328+
if err != nil {
329+
t.Fatal(err)
330+
}
331+
if len(cfg.Services) == 0 {
332+
t.Fatal("expected at least 1 service")
333+
}
334+
for name, svc := range cfg.Services {
335+
if svc.Port != "27017" {
336+
t.Fatalf("service %s expected port 27017, got %s", name, svc.Port)
337+
}
338+
if svc.Image != "mongo:7" {
339+
t.Fatalf("service %s expected image mongo:7, got %s", name, svc.Image)
340+
}
341+
}
342+
}

cli/internal/client/grpc_client.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,16 @@ func configPath() string {
5151
return filepath.Join(platform.ConfigDir(), "config.json")
5252
}
5353

54+
// LoadConfig reads CLI configuration from the local config file.
55+
func LoadConfig() (map[string]string, error) {
56+
return loadConfig()
57+
}
58+
59+
// SaveConfig writes CLI configuration to the local config file.
60+
func SaveConfig(cfg map[string]string) error {
61+
return saveConfig(cfg)
62+
}
63+
5464
func loadConfig() (map[string]string, error) {
5565
cfg := map[string]string{
5666
"telemetry": "true",

shared/config/autodetect.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,14 @@ func AutoDetectWithDepth(dir string, maxDepth int) (*types.Config, error) {
7575
if rs.Image != "" {
7676
svc.Image = rs.Image
7777
}
78+
case "postgres":
79+
svc.Image = "postgres:16-alpine"
80+
case "mysql":
81+
svc.Image = "mysql:8"
82+
case "redis":
83+
svc.Image = "redis:7-alpine"
84+
case "mongo":
85+
svc.Image = "mongo:7"
7886
}
7987

8088
if rs.BuildCommand != "" {

0 commit comments

Comments
 (0)