From 5a117f415747da081f1cf6e982937c6eefa73a93 Mon Sep 17 00:00:00 2001 From: "Michael V." Date: Tue, 21 Apr 2026 17:18:40 +0300 Subject: [PATCH] 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}.`. - `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 --- examples/nix/hello/.gitignore | 2 + examples/nix/hello/README.md | 70 ++++ examples/nix/hello/devbox.json | 5 + examples/nix/hello/devbox.lock | 9 + examples/nix/hello/hello-pkg/default.nix | 5 + internal/boxcli/generate.go | 109 ++++++ .../boxcli/generate_flake_wrapper_test.go | 242 ++++++++++++++ .../devbox/flakegen/flake-wrapper.nix.tmpl | 15 + internal/devbox/flakegen/flakegen.go | 190 +++++++++++ internal/devbox/flakegen/flakegen_test.go | 316 ++++++++++++++++++ 10 files changed, 963 insertions(+) create mode 100644 examples/nix/hello/.gitignore create mode 100644 examples/nix/hello/README.md create mode 100644 examples/nix/hello/devbox.json create mode 100644 examples/nix/hello/devbox.lock create mode 100644 examples/nix/hello/hello-pkg/default.nix create mode 100644 internal/boxcli/generate_flake_wrapper_test.go create mode 100644 internal/devbox/flakegen/flake-wrapper.nix.tmpl create mode 100644 internal/devbox/flakegen/flakegen.go create mode 100644 internal/devbox/flakegen/flakegen_test.go diff --git a/examples/nix/hello/.gitignore b/examples/nix/hello/.gitignore new file mode 100644 index 00000000000..443504ecd46 --- /dev/null +++ b/examples/nix/hello/.gitignore @@ -0,0 +1,2 @@ +flake.nix +flake.lock \ No newline at end of file diff --git a/examples/nix/hello/README.md b/examples/nix/hello/README.md new file mode 100644 index 00000000000..b2778b56aa7 --- /dev/null +++ b/examples/nix/hello/README.md @@ -0,0 +1,70 @@ +# Custom Nix Package Example + +This example shows how to include a locally-authored Nix expression in your +devbox shell. The `hello-pkg/default.nix` file defines a trivial shell script +using `pkgs.writeShellScriptBin`, and devbox consumes it via its existing +local-flake pipeline. + +## One-time scaffolding + +devbox's package syntax expects a flake, so the first time you set this up +(or whenever you change the pinned nixpkgs), generate a thin wrapper flake +next to the `default.nix`: + +```sh +devbox generate flake-wrapper ./hello-pkg +``` + +This writes `hello-pkg/flake.nix` (see below). The wrapper is intentionally +not committed to this example repo so you can see the scaffolding step. + +## devbox.json + +```json +{ + "packages": { + "./hello-pkg": "" + } +} +``` + +The `./hello-pkg` entry is a standard local-flake reference: devbox passes it +to Nix as `path:./hello-pkg` and adds `packages.${system}.default` from that +flake to the shell's `buildInputs`. + +## Running the example + +```sh +devbox generate flake-wrapper ./hello-pkg +devbox shell -- hello +# Hello from a custom Nix package! +``` + +## What the generated wrapper looks like + +`devbox generate flake-wrapper ./hello-pkg` produces something like: + +```nix +{ + description = "devbox wrapper flake for hello-pkg"; + + inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; + + outputs = { self, nixpkgs }: let + forAllSystems = f: nixpkgs.lib.genAttrs + nixpkgs.lib.systems.flakeExposed + (system: f nixpkgs.legacyPackages.${system}); + in { + packages = forAllSystems (pkgs: { + default = pkgs.callPackage ./default.nix {}; + }); + }; +} +``` + +`pkgs.callPackage ./default.nix {}` auto-injects the usual nixpkgs arguments +(`stdenv`, `lib`, `bash`, ...) into the expression. You can hand-edit the +wrapper to pass overrides (e.g. `pkgs.callPackage ./default.nix { withSsl = true; }`), +expose additional attributes, or pin a different nixpkgs — and re-run +`devbox generate flake-wrapper --force ./hello-pkg` whenever you want to +regenerate it from scratch. diff --git a/examples/nix/hello/devbox.json b/examples/nix/hello/devbox.json new file mode 100644 index 00000000000..dfb18cc3c8b --- /dev/null +++ b/examples/nix/hello/devbox.json @@ -0,0 +1,5 @@ +{ + "packages": { + "./hello-pkg": "" + } +} diff --git a/examples/nix/hello/devbox.lock b/examples/nix/hello/devbox.lock new file mode 100644 index 00000000000..81f77a5da85 --- /dev/null +++ b/examples/nix/hello/devbox.lock @@ -0,0 +1,9 @@ +{ + "lockfile_version": "1", + "packages": { + "github:NixOS/nixpkgs/nixpkgs-unstable": { + "last_modified": "2026-04-05T15:42:39Z", + "resolved": "github:NixOS/nixpkgs/5e11f7acce6c3469bef9df154d78534fa7ae8b6c?lastModified=1775403759&narHash=sha256-cGyKiTspHEUx3QwAnV3RfyT%2BVOXhHLs%2BNEr17HU34Wo%3D" + } + } +} diff --git a/examples/nix/hello/hello-pkg/default.nix b/examples/nix/hello/hello-pkg/default.nix new file mode 100644 index 00000000000..c32f8d1beff --- /dev/null +++ b/examples/nix/hello/hello-pkg/default.nix @@ -0,0 +1,5 @@ +{ stdenv, lib, bash, writeShellScriptBin }: + +writeShellScriptBin "hello" '' + echo "Hello from a custom Nix package!" +'' diff --git a/internal/boxcli/generate.go b/internal/boxcli/generate.go index 7a96229eb00..9282f1acf9f 100644 --- a/internal/boxcli/generate.go +++ b/internal/boxcli/generate.go @@ -6,6 +6,7 @@ package boxcli import ( "cmp" "fmt" + "path/filepath" "regexp" "github.com/pkg/errors" @@ -16,6 +17,7 @@ import ( "go.jetify.com/devbox/internal/devbox" "go.jetify.com/devbox/internal/devbox/devopt" "go.jetify.com/devbox/internal/devbox/docgen" + "go.jetify.com/devbox/internal/devbox/flakegen" ) type generateCmdFlags struct { @@ -44,6 +46,14 @@ type GenerateAliasCmdFlags struct { noPrefix bool } +type genFlakeWrapperCmdFlags struct { + config configFlags + force bool + nixpkgs string + attr string + print bool +} + func generateCmd() *cobra.Command { flags := &generateCmdFlags{} @@ -59,6 +69,7 @@ func generateCmd() *cobra.Command { command.AddCommand(dockerfileCmd()) command.AddCommand(debugCmd()) command.AddCommand(direnvCmd()) + command.AddCommand(genFlakeWrapperCmd()) command.AddCommand(genReadmeCmd()) flags.config.register(command) @@ -255,6 +266,46 @@ func genAliasCmd() *cobra.Command { return command } +func genFlakeWrapperCmd() *cobra.Command { + flags := &genFlakeWrapperCmdFlags{} + command := &cobra.Command{ + Use: "flake-wrapper [path]", + Short: "Generate a flake.nix wrapping an existing .nix expression", + Long: "Generate a flake.nix next to an existing .nix expression so " + + "the directory can be consumed as a local flake in devbox.json " + + "(e.g. \"packages\": { \"./my-pkg\": \"\" }). The path may be a " + + "directory containing a default.nix, or a specific .nix file. " + + "The generated flake imports the sibling .nix file via " + + "pkgs.callPackage.", + Args: cobra.MaximumNArgs(1), + // This command is pure text templating and does not need Nix. + // Override the parent generate command's ensureNixInstalled check. + PersistentPreRunE: func(*cobra.Command, []string) error { return nil }, + RunE: func(cmd *cobra.Command, args []string) error { + target := "." + if len(args) == 1 { + target = args[0] + } + return runGenFlakeWrapperCmd(cmd, target, flags) + }, + } + flags.config.register(command) + command.Flags().BoolVarP( + &flags.force, "force", "f", false, + "overwrite flake.nix if it already exists") + command.Flags().StringVar( + &flags.nixpkgs, "nixpkgs", "", + "nixpkgs input URL to pin (defaults to the project's stdenv if run "+ + "inside a devbox project, else "+flakegen.DefaultNixpkgsURL+")") + command.Flags().StringVar( + &flags.attr, "attr", "default", + "attribute name to expose under packages.${system}") + command.Flags().BoolVar( + &flags.print, "print", false, + "print the generated flake.nix to stdout instead of writing it") + return command +} + func runGenerateCmd(cmd *cobra.Command, flags *generateCmdFlags) error { // Check the directory exists. box, err := devbox.Open(&devopt.Opts{ @@ -310,3 +361,61 @@ func runGenerateDirenvCmd(cmd *cobra.Command, flags *generateCmdFlags) error { return box.GenerateEnvrcFile(cmd.Context(), generateEnvrcOpts) } + +func runGenFlakeWrapperCmd( + cmd *cobra.Command, + target string, + flags *genFlakeWrapperCmdFlags, +) error { + nixPath, err := flakegen.ResolveNixFile(target) + if err != nil { + return err + } + flakePath, err := flakegen.Generate(flakegen.Opts{ + NixFile: nixPath, + NixpkgsURL: resolveFlakeWrapperNixpkgs(cmd, flags), + Attr: flags.attr, + Force: flags.force, + Print: flags.print, + Out: cmd.OutOrStdout(), + }) + if err != nil { + return err + } + if flags.print { + return nil + } + out := cmd.OutOrStdout() + fmt.Fprintf(out, "Wrote %s.\n", flakePath) + fmt.Fprintln(out, "Add it to devbox.json:") + fmt.Fprintln(out) + fmt.Fprintln(out, " \"packages\": {") + fmt.Fprintf(out, " \"./%s\": \"\"\n", filepath.Base(filepath.Dir(nixPath))) + fmt.Fprintln(out, " }") + return nil +} + +// resolveFlakeWrapperNixpkgs determines which nixpkgs URL to pin in the +// generated flake. An explicit --nixpkgs flag wins; otherwise, if the command +// is run inside a devbox project we use that project's stdenv so the wrapper +// matches it; otherwise fall back to flakegen.DefaultNixpkgsURL. +func resolveFlakeWrapperNixpkgs( + cmd *cobra.Command, + flags *genFlakeWrapperCmdFlags, +) string { + if flags.nixpkgs != "" { + return flags.nixpkgs + } + box, err := devbox.Open(&devopt.Opts{ + Dir: flags.config.path, + Stderr: cmd.ErrOrStderr(), + }) + if err != nil { + return flakegen.DefaultNixpkgsURL + } + stdenv := box.Stdenv().String() + if stdenv == "" { + return flakegen.DefaultNixpkgsURL + } + return stdenv +} diff --git a/internal/boxcli/generate_flake_wrapper_test.go b/internal/boxcli/generate_flake_wrapper_test.go new file mode 100644 index 00000000000..f4e03214fe2 --- /dev/null +++ b/internal/boxcli/generate_flake_wrapper_test.go @@ -0,0 +1,242 @@ +// Copyright 2024 Jetify Inc. and contributors. All rights reserved. +// Use of this source code is governed by the license in the LICENSE file. + +package boxcli + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "go.jetify.com/devbox/internal/devbox/flakegen" +) + +// runFlakeWrapper invokes the flake-wrapper subcommand in-process and returns +// its stdout and error. It isolates HOME and the working directory so the +// command's devbox.Open fallback does not touch the user's real projects. +func runFlakeWrapper(t *testing.T, args ...string) (string, error) { + t.Helper() + // Keep devbox.Open from picking up state outside the sandbox. + t.Setenv("HOME", t.TempDir()) + + cmd := genFlakeWrapperCmd() + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + cmd.SetOut(stdout) + cmd.SetErr(stderr) + cmd.SetArgs(args) + err := cmd.Execute() + return stdout.String(), err +} + +func writeNixFile(t *testing.T, path string) { + t.Helper() + if err := os.WriteFile( + path, + []byte("{ stdenv }: stdenv.mkDerivation { name = \"noop\"; }\n"), + 0o644, + ); err != nil { + t.Fatal(err) + } +} + +func writeDefaultNix(t *testing.T, dir string) { + t.Helper() + writeNixFile(t, filepath.Join(dir, "default.nix")) +} + +func TestGenFlakeWrapper_WritesFlakeForDirectory(t *testing.T) { + dir := t.TempDir() + writeDefaultNix(t, dir) + + stdout, err := runFlakeWrapper(t, dir) + if err != nil { + t.Fatalf("flake-wrapper returned error: %v", err) + } + + flakePath := filepath.Join(dir, "flake.nix") + data, err := os.ReadFile(flakePath) + if err != nil { + t.Fatalf("expected flake.nix to be written: %v", err) + } + content := string(data) + if !strings.Contains(content, "pkgs.callPackage ./default.nix {}") { + t.Errorf("flake.nix missing callPackage line:\n%s", content) + } + if !strings.Contains(content, flakegen.DefaultNixpkgsURL) { + t.Errorf( + "expected default nixpkgs URL %q in flake.nix:\n%s", + flakegen.DefaultNixpkgsURL, content, + ) + } + if !strings.Contains(content, "packages = forAllSystems") { + t.Errorf("flake.nix missing forAllSystems block:\n%s", content) + } + if !strings.Contains(stdout, flakePath) { + t.Errorf("stdout summary should reference %s, got:\n%s", flakePath, stdout) + } +} + +func TestGenFlakeWrapper_RefusesToOverwriteWithoutForce(t *testing.T) { + dir := t.TempDir() + writeDefaultNix(t, dir) + + if _, err := runFlakeWrapper(t, dir); err != nil { + t.Fatalf("first invocation failed: %v", err) + } + + flakePath := filepath.Join(dir, "flake.nix") + sentinel := "# user edit\n" + if err := os.WriteFile(flakePath, []byte(sentinel), 0o644); err != nil { + t.Fatal(err) + } + + if _, err := runFlakeWrapper(t, dir); err == nil { + t.Fatal("expected error when flake.nix already exists without --force") + } + + data, err := os.ReadFile(flakePath) + if err != nil { + t.Fatal(err) + } + if string(data) != sentinel { + t.Errorf("flake.nix should be unchanged without --force, got:\n%s", data) + } + + if _, err := runFlakeWrapper(t, dir, "--force"); err != nil { + t.Fatalf("unexpected error with --force: %v", err) + } + data, err = os.ReadFile(flakePath) + if err != nil { + t.Fatal(err) + } + if string(data) == sentinel { + t.Errorf("--force should have overwritten the user edit") + } + if !strings.Contains(string(data), "pkgs.callPackage ./default.nix {}") { + t.Errorf("overwritten flake.nix missing callPackage line:\n%s", data) + } +} + +func TestGenFlakeWrapper_PrintDoesNotWriteFile(t *testing.T) { + dir := t.TempDir() + writeDefaultNix(t, dir) + + stdout, err := runFlakeWrapper(t, dir, "--print") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(stdout, "pkgs.callPackage ./default.nix {}") { + t.Errorf("--print should emit the rendered template, got:\n%s", stdout) + } + if _, err := os.Stat(filepath.Join(dir, "flake.nix")); !os.IsNotExist(err) { + t.Errorf("--print should not create flake.nix, stat err=%v", err) + } +} + +func TestGenFlakeWrapper_AcceptsDefaultNixFilePath(t *testing.T) { + dir := t.TempDir() + writeDefaultNix(t, dir) + + if _, err := runFlakeWrapper(t, filepath.Join(dir, "default.nix")); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "flake.nix")); err != nil { + t.Errorf("expected flake.nix to be written in parent dir: %v", err) + } +} + +func TestGenFlakeWrapper_AcceptsNamedNixFilePath(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "my_package.nix") + writeNixFile(t, target) + + stdout, err := runFlakeWrapper(t, target) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + flakePath := filepath.Join(dir, "flake.nix") + data, err := os.ReadFile(flakePath) + if err != nil { + t.Fatalf("expected flake.nix to be written: %v", err) + } + content := string(data) + if !strings.Contains(content, "pkgs.callPackage ./my_package.nix {}") { + t.Errorf("flake.nix should callPackage the named file:\n%s", content) + } + if strings.Contains(content, "./default.nix") { + t.Errorf("flake.nix should not reference default.nix:\n%s", content) + } + if !strings.Contains(stdout, flakePath) { + t.Errorf("stdout should reference %s, got:\n%s", flakePath, stdout) + } +} + +func TestGenFlakeWrapper_RejectsNonNixFile(t *testing.T) { + dir := t.TempDir() + other := filepath.Join(dir, "other.txt") + if err := os.WriteFile(other, []byte("{}"), 0o644); err != nil { + t.Fatal(err) + } + + _, err := runFlakeWrapper(t, other) + if err == nil { + t.Fatal("expected error when pointed at a non-.nix file") + } + if !strings.Contains(err.Error(), ".nix") { + t.Errorf("error should mention .nix extension, got: %v", err) + } +} + +func TestGenFlakeWrapper_MissingDefaultNixFails(t *testing.T) { + dir := t.TempDir() + + _, err := runFlakeWrapper(t, dir) + if err == nil { + t.Fatal("expected error when directory has no default.nix") + } + if !strings.Contains(err.Error(), "default.nix") { + t.Errorf("error should mention default.nix, got: %v", err) + } +} + +func TestGenFlakeWrapper_NixpkgsOverride(t *testing.T) { + dir := t.TempDir() + writeDefaultNix(t, dir) + + const customURL = "github:NixOS/nixpkgs/nixos-23.11" + if _, err := runFlakeWrapper(t, dir, "--nixpkgs", customURL); err != nil { + t.Fatalf("unexpected error: %v", err) + } + data, err := os.ReadFile(filepath.Join(dir, "flake.nix")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), customURL) { + t.Errorf("expected custom nixpkgs URL %q in flake.nix:\n%s", customURL, data) + } + if strings.Contains(string(data), flakegen.DefaultNixpkgsURL) { + t.Errorf( + "default nixpkgs URL should not appear when --nixpkgs is set:\n%s", + data, + ) + } +} + +func TestGenFlakeWrapper_AttrOverride(t *testing.T) { + dir := t.TempDir() + writeDefaultNix(t, dir) + + if _, err := runFlakeWrapper(t, dir, "--attr", "hello"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + data, err := os.ReadFile(filepath.Join(dir, "flake.nix")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), "hello = pkgs.callPackage ./default.nix {}") { + t.Errorf("expected custom attr in flake.nix:\n%s", data) + } +} diff --git a/internal/devbox/flakegen/flake-wrapper.nix.tmpl b/internal/devbox/flakegen/flake-wrapper.nix.tmpl new file mode 100644 index 00000000000..f9b96ab1ad5 --- /dev/null +++ b/internal/devbox/flakegen/flake-wrapper.nix.tmpl @@ -0,0 +1,15 @@ +{ + description = "{{ .Description }}"; + + inputs.nixpkgs.url = "{{ .NixpkgsURL }}"; + + outputs = { self, nixpkgs }: let + forAllSystems = f: nixpkgs.lib.genAttrs + nixpkgs.lib.systems.flakeExposed + (system: f nixpkgs.legacyPackages.${system}); + in { + packages = forAllSystems (pkgs: { + {{ .Attr }} = pkgs.callPackage ./{{ .NixFile }} {}; + }); + }; +} diff --git a/internal/devbox/flakegen/flakegen.go b/internal/devbox/flakegen/flakegen.go new file mode 100644 index 00000000000..94e8d3d6319 --- /dev/null +++ b/internal/devbox/flakegen/flakegen.go @@ -0,0 +1,190 @@ +// Copyright 2024 Jetify Inc. and contributors. All rights reserved. +// Use of this source code is governed by the license in the LICENSE file. + +// Package flakegen scaffolds a small wrapper flake.nix next to an existing +// Nix expression file so that a directory of Nix expressions can be consumed +// as a local flake (e.g. via a devbox.json `"./pkg": ""` package entry). +package flakegen + +import ( + "bytes" + _ "embed" + "io" + "os" + "path/filepath" + "strings" + "text/template" + + "github.com/pkg/errors" + + "go.jetify.com/devbox/internal/boxcli/usererr" +) + +// DefaultNixpkgsURL is the nixpkgs input pinned in generated flakes when the +// caller does not supply one. +const DefaultNixpkgsURL = "github:NixOS/nixpkgs/nixpkgs-unstable" + +// defaultNixFile is the file name assumed when the caller points at a +// directory rather than a specific .nix file. +const defaultNixFile = "default.nix" + +//go:embed flake-wrapper.nix.tmpl +var wrapperTmplString string + +var wrapperTmpl = template.Must( + template.New("flake-wrapper").Parse(wrapperTmplString), +) + +// Opts controls how Generate scaffolds a wrapper flake. +type Opts struct { + // NixFile is the path to the Nix expression file that the generated + // flake should callPackage. The flake.nix is written next to it (i.e. + // filepath.Dir(NixFile)). It is expected to have already been resolved + // via ResolveNixFile. + NixFile string + + // NixpkgsURL is the nixpkgs input URL pinned in the generated flake. If + // empty, DefaultNixpkgsURL is used. + NixpkgsURL string + + // Attr is the attribute name exposed under `packages.${system}`. If + // empty, "default" is used. + Attr string + + // Force overwrites an existing flake.nix when true. + Force bool + + // Print causes the rendered template to be written to Out instead of a + // flake.nix file. + Print bool + + // Out receives either the rendered template (when Print is true) or is + // unused otherwise. Generate does not print any user-facing summary; + // callers are responsible for that. + Out io.Writer +} + +// ResolveNixFile turns a user-supplied path into the absolute path of the +// Nix expression file that Generate should wrap. +// +// target can be a directory with a default.nix file, or a specific .nix file. +func ResolveNixFile(target string) (string, error) { + abs, err := filepath.Abs(target) + if err != nil { + return "", errors.WithStack(err) + } + info, err := os.Stat(abs) + if err != nil { + if os.IsNotExist(err) { + return "", usererr.New("%s does not exist", abs) + } + return "", errors.WithStack(err) + } + if info.Mode().IsRegular() { + if !strings.HasSuffix(filepath.Base(abs), ".nix") { + return "", usererr.New( + "%s is a file but does not have a .nix extension. "+ + "Pass a .nix file or a directory containing a "+ + "default.nix.", + abs, + ) + } + return abs, nil + } + if !info.IsDir() { + return "", usererr.New("%s is not a regular file or directory", abs) + } + nixPath := filepath.Join(abs, defaultNixFile) + if _, err := os.Stat(nixPath); err != nil { + if os.IsNotExist(err) { + return "", usererr.New( + "no %s found in %s. Pass a directory containing a "+ + "%s, or point directly at a .nix file.", + defaultNixFile, abs, defaultNixFile, + ) + } + return "", errors.WithStack(err) + } + return nixPath, nil +} + +// Generate renders the wrapper template for the Nix file described by opts. +// When opts.Print is true, the rendered flake is written to opts.Out and the +// returned path is empty. Otherwise, a flake.nix file is written into the +// directory containing opts.NixFile and its absolute path is returned. +func Generate(opts Opts) (string, error) { + if opts.NixFile == "" { + return "", errors.New("flakegen: Opts.NixFile is required") + } + nixPath, err := filepath.Abs(opts.NixFile) + if err != nil { + return "", errors.WithStack(err) + } + nixBase := filepath.Base(nixPath) + if !strings.HasSuffix(nixBase, ".nix") { + return "", usererr.New( + "flakegen: NixFile %q must have a .nix extension", opts.NixFile, + ) + } + dir := filepath.Dir(nixPath) + if _, err := os.Stat(nixPath); err != nil { + if os.IsNotExist(err) { + return "", usererr.New( + "no %s found in %s. "+ + "flakegen expects the file to exist next to the "+ + "generated flake.nix.", + nixBase, dir, + ) + } + return "", errors.WithStack(err) + } + + nixpkgsURL := opts.NixpkgsURL + if nixpkgsURL == "" { + nixpkgsURL = DefaultNixpkgsURL + } + attr := opts.Attr + if attr == "" { + attr = "default" + } + + var buf bytes.Buffer + if err := wrapperTmpl.Execute(&buf, struct { + Description string + NixpkgsURL string + Attr string + NixFile string + }{ + Description: "devbox wrapper flake for " + filepath.Base(dir), + NixpkgsURL: nixpkgsURL, + Attr: attr, + NixFile: nixBase, + }); err != nil { + return "", errors.WithStack(err) + } + + if opts.Print { + if opts.Out == nil { + return "", errors.New("flakegen: Opts.Out is required when Print is true") + } + if _, err := opts.Out.Write(buf.Bytes()); err != nil { + return "", errors.WithStack(err) + } + return "", nil + } + + flakePath := filepath.Join(dir, "flake.nix") + if _, err := os.Stat(flakePath); err == nil && !opts.Force { + return "", usererr.New( + "%s already exists. Re-run with --force to overwrite.", + flakePath, + ) + } else if err != nil && !os.IsNotExist(err) { + return "", errors.WithStack(err) + } + + if err := os.WriteFile(flakePath, buf.Bytes(), 0o644); err != nil { + return "", errors.WithStack(err) + } + return flakePath, nil +} diff --git a/internal/devbox/flakegen/flakegen_test.go b/internal/devbox/flakegen/flakegen_test.go new file mode 100644 index 00000000000..2c947b2ad71 --- /dev/null +++ b/internal/devbox/flakegen/flakegen_test.go @@ -0,0 +1,316 @@ +// Copyright 2024 Jetify Inc. and contributors. All rights reserved. +// Use of this source code is governed by the license in the LICENSE file. + +package flakegen_test + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "go.jetify.com/devbox/internal/devbox/flakegen" +) + +func writeNixFile(t *testing.T, path string) { + t.Helper() + if err := os.WriteFile( + path, + []byte("{ stdenv }: stdenv.mkDerivation { name = \"noop\"; }\n"), + 0o644, + ); err != nil { + t.Fatal(err) + } +} + +func writeDefaultNix(t *testing.T, dir string) string { + t.Helper() + p := filepath.Join(dir, "default.nix") + writeNixFile(t, p) + return p +} + +// evalSymlinks resolves symlinks so tests can compare paths on systems (like +// macOS) where t.TempDir lives under /var but resolves to /private/var. +func evalSymlinks(t *testing.T, p string) string { + t.Helper() + resolved, err := filepath.EvalSymlinks(p) + if err != nil { + t.Fatal(err) + } + return resolved +} + +func TestResolveNixFile_Directory(t *testing.T) { + dir := t.TempDir() + writeDefaultNix(t, dir) + + got, err := flakegen.ResolveNixFile(dir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := filepath.Join(dir, "default.nix") + if evalSymlinks(t, got) != evalSymlinks(t, want) { + t.Errorf("ResolveNixFile(%q) = %q, want %q", dir, got, want) + } +} + +func TestResolveNixFile_DirectoryMissingDefaultNix(t *testing.T) { + dir := t.TempDir() + + _, err := flakegen.ResolveNixFile(dir) + if err == nil { + t.Fatal("expected error when directory has no default.nix") + } + if !strings.Contains(err.Error(), "default.nix") { + t.Errorf("error should mention default.nix, got: %v", err) + } +} + +func TestResolveNixFile_DefaultNixFile(t *testing.T) { + dir := t.TempDir() + target := writeDefaultNix(t, dir) + + got, err := flakegen.ResolveNixFile(target) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if evalSymlinks(t, got) != evalSymlinks(t, target) { + t.Errorf("ResolveNixFile(%q) = %q, want %q", target, got, target) + } +} + +func TestResolveNixFile_NamedNixFile(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "my_package.nix") + writeNixFile(t, target) + + got, err := flakegen.ResolveNixFile(target) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if evalSymlinks(t, got) != evalSymlinks(t, target) { + t.Errorf("ResolveNixFile(%q) = %q, want %q", target, got, target) + } +} + +func TestResolveNixFile_NonNixFile(t *testing.T) { + dir := t.TempDir() + other := filepath.Join(dir, "other.txt") + if err := os.WriteFile(other, []byte("{}"), 0o644); err != nil { + t.Fatal(err) + } + + _, err := flakegen.ResolveNixFile(other) + if err == nil { + t.Fatal("expected error for non-.nix file") + } + if !strings.Contains(err.Error(), ".nix") { + t.Errorf("error should mention .nix extension, got: %v", err) + } +} + +func TestResolveNixFile_Missing(t *testing.T) { + _, err := flakegen.ResolveNixFile(filepath.Join(t.TempDir(), "does-not-exist")) + if err == nil { + t.Fatal("expected error for missing path") + } +} + +func TestGenerate_WritesFlake(t *testing.T) { + dir := t.TempDir() + nixFile := writeDefaultNix(t, dir) + + flakePath, err := flakegen.Generate(flakegen.Opts{NixFile: nixFile}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if flakePath != filepath.Join(dir, "flake.nix") { + t.Errorf("unexpected flakePath %q", flakePath) + } + data, err := os.ReadFile(flakePath) + if err != nil { + t.Fatal(err) + } + content := string(data) + for _, want := range []string{ + "pkgs.callPackage ./default.nix {}", + flakegen.DefaultNixpkgsURL, + "default = pkgs.callPackage", + "packages = forAllSystems", + } { + if !strings.Contains(content, want) { + t.Errorf("flake.nix missing %q:\n%s", want, content) + } + } +} + +func TestGenerate_WritesFlakeForNamedNixFile(t *testing.T) { + dir := t.TempDir() + nixFile := filepath.Join(dir, "my_package.nix") + writeNixFile(t, nixFile) + + flakePath, err := flakegen.Generate(flakegen.Opts{NixFile: nixFile}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + data, err := os.ReadFile(flakePath) + if err != nil { + t.Fatal(err) + } + content := string(data) + if !strings.Contains(content, "pkgs.callPackage ./my_package.nix {}") { + t.Errorf("flake.nix should callPackage the custom file:\n%s", content) + } + if strings.Contains(content, "./default.nix") { + t.Errorf("flake.nix should not reference default.nix:\n%s", content) + } +} + +func TestGenerate_DefaultsAttrAndNixpkgs(t *testing.T) { + dir := t.TempDir() + nixFile := writeDefaultNix(t, dir) + + if _, err := flakegen.Generate(flakegen.Opts{ + NixFile: nixFile, + NixpkgsURL: "", + Attr: "", + }); err != nil { + t.Fatalf("unexpected error: %v", err) + } + data, _ := os.ReadFile(filepath.Join(dir, "flake.nix")) + if !strings.Contains(string(data), flakegen.DefaultNixpkgsURL) { + t.Errorf("empty NixpkgsURL should fall back to DefaultNixpkgsURL:\n%s", data) + } + if !strings.Contains(string(data), "default = pkgs.callPackage") { + t.Errorf("empty Attr should fall back to \"default\":\n%s", data) + } +} + +func TestGenerate_RefusesOverwriteWithoutForce(t *testing.T) { + dir := t.TempDir() + nixFile := writeDefaultNix(t, dir) + + if _, err := flakegen.Generate(flakegen.Opts{NixFile: nixFile}); err != nil { + t.Fatal(err) + } + + sentinel := "# user edit\n" + flakePath := filepath.Join(dir, "flake.nix") + if err := os.WriteFile(flakePath, []byte(sentinel), 0o644); err != nil { + t.Fatal(err) + } + + if _, err := flakegen.Generate(flakegen.Opts{NixFile: nixFile}); err == nil { + t.Fatal("expected error without Force") + } + data, _ := os.ReadFile(flakePath) + if string(data) != sentinel { + t.Errorf("flake.nix should be unchanged without Force, got:\n%s", data) + } + + if _, err := flakegen.Generate(flakegen.Opts{NixFile: nixFile, Force: true}); err != nil { + t.Fatalf("unexpected error with Force: %v", err) + } + data, _ = os.ReadFile(flakePath) + if string(data) == sentinel { + t.Errorf("Force should have overwritten the user edit") + } +} + +func TestGenerate_Print(t *testing.T) { + dir := t.TempDir() + nixFile := writeDefaultNix(t, dir) + + buf := &bytes.Buffer{} + flakePath, err := flakegen.Generate(flakegen.Opts{ + NixFile: nixFile, + Print: true, + Out: buf, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if flakePath != "" { + t.Errorf("Print should return empty flakePath, got %q", flakePath) + } + if !strings.Contains(buf.String(), "pkgs.callPackage ./default.nix {}") { + t.Errorf("Print should emit the rendered template, got:\n%s", buf.String()) + } + if _, err := os.Stat(filepath.Join(dir, "flake.nix")); !os.IsNotExist(err) { + t.Errorf("Print should not create flake.nix, stat err=%v", err) + } +} + +func TestGenerate_PrintRequiresOut(t *testing.T) { + dir := t.TempDir() + nixFile := writeDefaultNix(t, dir) + + _, err := flakegen.Generate(flakegen.Opts{NixFile: nixFile, Print: true}) + if err == nil { + t.Fatal("expected error when Print is true but Out is nil") + } +} + +func TestGenerate_CustomAttrAndNixpkgs(t *testing.T) { + dir := t.TempDir() + nixFile := writeDefaultNix(t, dir) + + const customURL = "github:NixOS/nixpkgs/nixos-23.11" + if _, err := flakegen.Generate(flakegen.Opts{ + NixFile: nixFile, + NixpkgsURL: customURL, + Attr: "hello", + }); err != nil { + t.Fatalf("unexpected error: %v", err) + } + data, _ := os.ReadFile(filepath.Join(dir, "flake.nix")) + content := string(data) + if !strings.Contains(content, customURL) { + t.Errorf("expected custom nixpkgs URL %q:\n%s", customURL, content) + } + if strings.Contains(content, flakegen.DefaultNixpkgsURL) { + t.Errorf("default URL should not appear when NixpkgsURL is set:\n%s", content) + } + if !strings.Contains(content, "hello = pkgs.callPackage ./default.nix {}") { + t.Errorf("expected custom attr in flake.nix:\n%s", content) + } +} + +func TestGenerate_MissingNixFile(t *testing.T) { + _, err := flakegen.Generate(flakegen.Opts{NixFile: ""}) + if err == nil { + t.Fatal("expected error when NixFile is empty") + } +} + +func TestGenerate_NixFileDoesNotExist(t *testing.T) { + dir := t.TempDir() + + _, err := flakegen.Generate(flakegen.Opts{ + NixFile: filepath.Join(dir, "my_package.nix"), + }) + if err == nil { + t.Fatal("expected error when the named file does not exist") + } + if !strings.Contains(err.Error(), "my_package.nix") { + t.Errorf("error should mention my_package.nix, got: %v", err) + } +} + +func TestGenerate_NixFileWrongExtension(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "default.txt") + if err := os.WriteFile(target, []byte("{}"), 0o644); err != nil { + t.Fatal(err) + } + + _, err := flakegen.Generate(flakegen.Opts{NixFile: target}) + if err == nil { + t.Fatal("expected error when NixFile lacks a .nix extension") + } + if !strings.Contains(err.Error(), ".nix") { + t.Errorf("error should mention .nix extension, got: %v", err) + } +}