Skip to content

Commit 5a117f4

Browse files
committed
boxcli,flakegen: add generate flake-wrapper subcommand (#2717)
Devbox's package syntax expects a flake, which makes it awkward to consume a locally-authored .nix expression (e.g. a `default.nix` written with `pkgs.callPackage`). Today users have to hand-write a boilerplate wrapper flake before they can reference the directory as `"./my-pkg": ""` or `"path:./mypackage"` in devbox.json. This adds a new `devbox generate flake-wrapper [path]` command that scaffolds that wrapper for them: - New `internal/devbox/flakegen` package renders a small embedded template (`flake-wrapper.nix.tmpl`) that imports a target .nix file via `pkgs.callPackage` and exposes it under `packages.${system}.<attr>`. - `boxcli` wires it up as a subcommand of `generate` with flags for `--force`, `--nixpkgs`, `--attr`, and `--print`. When run inside a devbox project the command defaults `--nixpkgs` to the project's stdenv so the wrapper stays in sync; otherwise it falls back to `nixpkgs-unstable`. The subcommand skips the parent `generate` command's ensureNixInstalled hook since it is pure text templating. - Adds unit tests for `flakegen` (template rendering, path resolution, `--force`, `--print`) and a CLI-level test for the new subcommand. - Adds an `examples/nix/hello` example showing the end-to-end flow. Closes #2717
1 parent 1dae9ff commit 5a117f4

10 files changed

Lines changed: 963 additions & 0 deletions

File tree

examples/nix/hello/.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
flake.nix
2+
flake.lock

examples/nix/hello/README.md

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
# Custom Nix Package Example
2+
3+
This example shows how to include a locally-authored Nix expression in your
4+
devbox shell. The `hello-pkg/default.nix` file defines a trivial shell script
5+
using `pkgs.writeShellScriptBin`, and devbox consumes it via its existing
6+
local-flake pipeline.
7+
8+
## One-time scaffolding
9+
10+
devbox's package syntax expects a flake, so the first time you set this up
11+
(or whenever you change the pinned nixpkgs), generate a thin wrapper flake
12+
next to the `default.nix`:
13+
14+
```sh
15+
devbox generate flake-wrapper ./hello-pkg
16+
```
17+
18+
This writes `hello-pkg/flake.nix` (see below). The wrapper is intentionally
19+
not committed to this example repo so you can see the scaffolding step.
20+
21+
## devbox.json
22+
23+
```json
24+
{
25+
"packages": {
26+
"./hello-pkg": ""
27+
}
28+
}
29+
```
30+
31+
The `./hello-pkg` entry is a standard local-flake reference: devbox passes it
32+
to Nix as `path:./hello-pkg` and adds `packages.${system}.default` from that
33+
flake to the shell's `buildInputs`.
34+
35+
## Running the example
36+
37+
```sh
38+
devbox generate flake-wrapper ./hello-pkg
39+
devbox shell -- hello
40+
# Hello from a custom Nix package!
41+
```
42+
43+
## What the generated wrapper looks like
44+
45+
`devbox generate flake-wrapper ./hello-pkg` produces something like:
46+
47+
```nix
48+
{
49+
description = "devbox wrapper flake for hello-pkg";
50+
51+
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
52+
53+
outputs = { self, nixpkgs }: let
54+
forAllSystems = f: nixpkgs.lib.genAttrs
55+
nixpkgs.lib.systems.flakeExposed
56+
(system: f nixpkgs.legacyPackages.${system});
57+
in {
58+
packages = forAllSystems (pkgs: {
59+
default = pkgs.callPackage ./default.nix {};
60+
});
61+
};
62+
}
63+
```
64+
65+
`pkgs.callPackage ./default.nix {}` auto-injects the usual nixpkgs arguments
66+
(`stdenv`, `lib`, `bash`, ...) into the expression. You can hand-edit the
67+
wrapper to pass overrides (e.g. `pkgs.callPackage ./default.nix { withSsl = true; }`),
68+
expose additional attributes, or pin a different nixpkgs — and re-run
69+
`devbox generate flake-wrapper --force ./hello-pkg` whenever you want to
70+
regenerate it from scratch.

examples/nix/hello/devbox.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"packages": {
3+
"./hello-pkg": ""
4+
}
5+
}

examples/nix/hello/devbox.lock

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"lockfile_version": "1",
3+
"packages": {
4+
"github:NixOS/nixpkgs/nixpkgs-unstable": {
5+
"last_modified": "2026-04-05T15:42:39Z",
6+
"resolved": "github:NixOS/nixpkgs/5e11f7acce6c3469bef9df154d78534fa7ae8b6c?lastModified=1775403759&narHash=sha256-cGyKiTspHEUx3QwAnV3RfyT%2BVOXhHLs%2BNEr17HU34Wo%3D"
7+
}
8+
}
9+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{ stdenv, lib, bash, writeShellScriptBin }:
2+
3+
writeShellScriptBin "hello" ''
4+
echo "Hello from a custom Nix package!"
5+
''

internal/boxcli/generate.go

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ package boxcli
66
import (
77
"cmp"
88
"fmt"
9+
"path/filepath"
910
"regexp"
1011

1112
"github.com/pkg/errors"
@@ -16,6 +17,7 @@ import (
1617
"go.jetify.com/devbox/internal/devbox"
1718
"go.jetify.com/devbox/internal/devbox/devopt"
1819
"go.jetify.com/devbox/internal/devbox/docgen"
20+
"go.jetify.com/devbox/internal/devbox/flakegen"
1921
)
2022

2123
type generateCmdFlags struct {
@@ -44,6 +46,14 @@ type GenerateAliasCmdFlags struct {
4446
noPrefix bool
4547
}
4648

49+
type genFlakeWrapperCmdFlags struct {
50+
config configFlags
51+
force bool
52+
nixpkgs string
53+
attr string
54+
print bool
55+
}
56+
4757
func generateCmd() *cobra.Command {
4858
flags := &generateCmdFlags{}
4959

@@ -59,6 +69,7 @@ func generateCmd() *cobra.Command {
5969
command.AddCommand(dockerfileCmd())
6070
command.AddCommand(debugCmd())
6171
command.AddCommand(direnvCmd())
72+
command.AddCommand(genFlakeWrapperCmd())
6273
command.AddCommand(genReadmeCmd())
6374
flags.config.register(command)
6475

@@ -255,6 +266,46 @@ func genAliasCmd() *cobra.Command {
255266
return command
256267
}
257268

269+
func genFlakeWrapperCmd() *cobra.Command {
270+
flags := &genFlakeWrapperCmdFlags{}
271+
command := &cobra.Command{
272+
Use: "flake-wrapper [path]",
273+
Short: "Generate a flake.nix wrapping an existing .nix expression",
274+
Long: "Generate a flake.nix next to an existing .nix expression so " +
275+
"the directory can be consumed as a local flake in devbox.json " +
276+
"(e.g. \"packages\": { \"./my-pkg\": \"\" }). The path may be a " +
277+
"directory containing a default.nix, or a specific .nix file. " +
278+
"The generated flake imports the sibling .nix file via " +
279+
"pkgs.callPackage.",
280+
Args: cobra.MaximumNArgs(1),
281+
// This command is pure text templating and does not need Nix.
282+
// Override the parent generate command's ensureNixInstalled check.
283+
PersistentPreRunE: func(*cobra.Command, []string) error { return nil },
284+
RunE: func(cmd *cobra.Command, args []string) error {
285+
target := "."
286+
if len(args) == 1 {
287+
target = args[0]
288+
}
289+
return runGenFlakeWrapperCmd(cmd, target, flags)
290+
},
291+
}
292+
flags.config.register(command)
293+
command.Flags().BoolVarP(
294+
&flags.force, "force", "f", false,
295+
"overwrite flake.nix if it already exists")
296+
command.Flags().StringVar(
297+
&flags.nixpkgs, "nixpkgs", "",
298+
"nixpkgs input URL to pin (defaults to the project's stdenv if run "+
299+
"inside a devbox project, else "+flakegen.DefaultNixpkgsURL+")")
300+
command.Flags().StringVar(
301+
&flags.attr, "attr", "default",
302+
"attribute name to expose under packages.${system}")
303+
command.Flags().BoolVar(
304+
&flags.print, "print", false,
305+
"print the generated flake.nix to stdout instead of writing it")
306+
return command
307+
}
308+
258309
func runGenerateCmd(cmd *cobra.Command, flags *generateCmdFlags) error {
259310
// Check the directory exists.
260311
box, err := devbox.Open(&devopt.Opts{
@@ -310,3 +361,61 @@ func runGenerateDirenvCmd(cmd *cobra.Command, flags *generateCmdFlags) error {
310361

311362
return box.GenerateEnvrcFile(cmd.Context(), generateEnvrcOpts)
312363
}
364+
365+
func runGenFlakeWrapperCmd(
366+
cmd *cobra.Command,
367+
target string,
368+
flags *genFlakeWrapperCmdFlags,
369+
) error {
370+
nixPath, err := flakegen.ResolveNixFile(target)
371+
if err != nil {
372+
return err
373+
}
374+
flakePath, err := flakegen.Generate(flakegen.Opts{
375+
NixFile: nixPath,
376+
NixpkgsURL: resolveFlakeWrapperNixpkgs(cmd, flags),
377+
Attr: flags.attr,
378+
Force: flags.force,
379+
Print: flags.print,
380+
Out: cmd.OutOrStdout(),
381+
})
382+
if err != nil {
383+
return err
384+
}
385+
if flags.print {
386+
return nil
387+
}
388+
out := cmd.OutOrStdout()
389+
fmt.Fprintf(out, "Wrote %s.\n", flakePath)
390+
fmt.Fprintln(out, "Add it to devbox.json:")
391+
fmt.Fprintln(out)
392+
fmt.Fprintln(out, " \"packages\": {")
393+
fmt.Fprintf(out, " \"./%s\": \"\"\n", filepath.Base(filepath.Dir(nixPath)))
394+
fmt.Fprintln(out, " }")
395+
return nil
396+
}
397+
398+
// resolveFlakeWrapperNixpkgs determines which nixpkgs URL to pin in the
399+
// generated flake. An explicit --nixpkgs flag wins; otherwise, if the command
400+
// is run inside a devbox project we use that project's stdenv so the wrapper
401+
// matches it; otherwise fall back to flakegen.DefaultNixpkgsURL.
402+
func resolveFlakeWrapperNixpkgs(
403+
cmd *cobra.Command,
404+
flags *genFlakeWrapperCmdFlags,
405+
) string {
406+
if flags.nixpkgs != "" {
407+
return flags.nixpkgs
408+
}
409+
box, err := devbox.Open(&devopt.Opts{
410+
Dir: flags.config.path,
411+
Stderr: cmd.ErrOrStderr(),
412+
})
413+
if err != nil {
414+
return flakegen.DefaultNixpkgsURL
415+
}
416+
stdenv := box.Stdenv().String()
417+
if stdenv == "" {
418+
return flakegen.DefaultNixpkgsURL
419+
}
420+
return stdenv
421+
}

0 commit comments

Comments
 (0)