···11+export DIRENV_WARN_TIMEOUT=20s
22+33+eval "$(devenv direnvrc)"
44+55+# The use_devenv function supports passing flags to the devenv command
66+# For example: use devenv --impure --option services.postgres.enable:bool true
77+use devenv
···11+# yaml-language-server: $schema=https://devenv.sh/devenv.schema.json
22+inputs:
33+ nixpkgs:
44+ url: github:cachix/devenv-nixpkgs/rolling
55+66+# If you're using non-OSS software, you can set allowUnfree to true.
77+# allowUnfree: true
88+99+# If you're willing to use a package that's vulnerable
1010+# permittedInsecurePackages:
1111+# - "openssl-1.1.1w"
1212+1313+# If you have more than one devenv you can merge them
1414+#imports:
1515+# - ./backend
···11+package process
22+33+import (
44+ "fmt"
55+ "log"
66+ "os"
77+88+ "github.com/aottr/nox/internal/config"
99+ "github.com/aottr/nox/internal/crypto"
1010+ "github.com/aottr/nox/internal/gitrepo"
1111+ "github.com/aottr/nox/internal/state"
1212+ "github.com/go-git/go-git/v5/plumbing/object"
1313+)
1414+1515+// ProcessApps clones and decrypts configured app secrets efficiently.
1616+func ProcessApps(cfg *config.Config) error {
1717+ type repoKey struct {
1818+ Repo string
1919+ Branch string
2020+ }
2121+2222+ clones := map[repoKey]*object.Tree{}
2323+2424+ identities, err := crypto.LoadAgeIdentities(cfg.AgeKeyPath)
2525+ if err != nil {
2626+ return fmt.Errorf("Failed to load age identities: %w", err)
2727+ }
2828+2929+ st, err := state.Load()
3030+ if err != nil {
3131+ return fmt.Errorf("Failed to load state: %w", err)
3232+ }
3333+3434+ for appName, app := range cfg.Apps {
3535+3636+ fmt.Printf("Processing app %s\n", appName)
3737+3838+ repoUrl := app.Repo
3939+ if repoUrl == "" {
4040+ repoUrl = cfg.DefaultRepo
4141+ }
4242+ key := repoKey{Repo: repoUrl, Branch: app.Branch}
4343+4444+ clone, ok := clones[key]
4545+ if !ok {
4646+ repo, err := gitrepo.CloneRepoInMemory(gitrepo.GitFetchOptions{
4747+ RepoURL: repoUrl,
4848+ Branch: app.Branch,
4949+ })
5050+ if err != nil {
5151+ log.Printf("Clone failed for %s/%s: %v", repoUrl, app.Branch, err)
5252+ continue
5353+ }
5454+ clone = repo.Tree
5555+ clones[key] = clone
5656+ }
5757+5858+ for _, file := range app.Files {
5959+ content, err := gitrepo.GetFileContentFromTree(clone, file.Path)
6060+ if err != nil {
6161+ log.Printf("Failed to get file %s: %v", file, err)
6262+ continue
6363+ }
6464+6565+ hash := state.HashContent(content)
6666+ cacheKey := state.GenerateKey(appName, file.Path)
6767+6868+ if prevHash, ok := st.Data[cacheKey]; ok && prevHash == hash {
6969+ log.Printf("File %s is up to date", file.Path)
7070+ continue
7171+ }
7272+7373+ plaintext, err := crypto.DecryptBytes(content, identities)
7474+ if err != nil {
7575+ log.Printf("Failed to decrypt file %s: %v", file.Path, err)
7676+ continue
7777+ }
7878+7979+ outPath := file.Output
8080+ // if outPath == "" {
8181+ // // Default output filename if none specified, e.g. replace .age with .env
8282+ // outPath = filepath.Base(fileCfg.Path)
8383+ // if filepath.Ext(outPath) == ".age" {
8484+ // outPath = outPath[:len(outPath)-4] + ".env"
8585+ // }
8686+ // }
8787+8888+ // if err := os.MkdirAll(filepath.Dir(outPath), 0755); err != nil {
8989+ // log.Printf("Failed to create directories for %s: %v", outPath, err)
9090+ // continue
9191+ // }
9292+9393+ if err := os.WriteFile(outPath, plaintext, 0600); err != nil {
9494+ log.Printf("Failed to write decrypted file to %s: %v", outPath, err)
9595+ continue
9696+ }
9797+9898+ log.Printf("decrypted %s for app %s (size: %d bytes)", file, appName, len(plaintext))
9999+100100+ st.Data[cacheKey] = hash
101101+ st.Touch()
102102+103103+ log.Printf("Decrypted file %s", file)
104104+ }
105105+106106+ }
107107+108108+ if err := state.Save(st); err != nil {
109109+ return fmt.Errorf("Failed to save state: %w", err)
110110+ }
111111+112112+ return nil
113113+}
+47
internal/process/validate.go
···11+package process
22+33+import (
44+ "fmt"
55+ // "os"
66+ // "path/filepath"
77+ // "strings"
88+99+ "github.com/aottr/nox/internal/config"
1010+ "github.com/aottr/nox/internal/gitrepo"
1111+)
1212+1313+func Validate(cfg *config.Config) error {
1414+ if cfg.AgeKeyPath == "" {
1515+ return fmt.Errorf("age key path is required")
1616+ }
1717+1818+ if cfg.StatePath == "" {
1919+ fmt.Printf("state path is not set, defaulting to default.\n")
2020+ }
2121+2222+ for appName, app := range cfg.Apps {
2323+ fmt.Printf("✅ Validating app %s\n", appName)
2424+2525+ repoURL := app.Repo
2626+ if repoURL == "" {
2727+ repoURL = cfg.DefaultRepo
2828+ }
2929+3030+ repo, err := gitrepo.CloneRepoInMemory(gitrepo.GitFetchOptions{
3131+ RepoURL: repoURL,
3232+ Branch: app.Branch,
3333+ })
3434+ if err != nil {
3535+ return fmt.Errorf("failed to clone for app %s: %w", appName, err)
3636+ }
3737+3838+ for _, file := range app.Files {
3939+ if !gitrepo.FileExistsInTree(repo.Tree, file.Path) {
4040+ return fmt.Errorf("❌ file %s missing in app %s", file, appName)
4141+ }
4242+ fmt.Printf("✔️ Found file %s in repo\n", file)
4343+ }
4444+ }
4545+ fmt.Println("✨ All checks passed!")
4646+ return nil
4747+}
+65
internal/state/state.go
···11+package state
22+33+import (
44+ "encoding/json"
55+ "log"
66+ "os"
77+ "time"
88+)
99+1010+// State holds metadata for the cache, including
1111+// the last update timestamp and a map of file hashes.
1212+type State struct {
1313+ LastUpdated int64
1414+ Data map[string]string
1515+}
1616+1717+var defaultPath = ".nox-state.json" // fallback default
1818+1919+// SetPath updates the default file path used for saving and loading state
2020+func SetPath(path string) {
2121+ defaultPath = path
2222+}
2323+2424+// Touch updates the LastUpdated timestamp to the current time.
2525+func (s *State) Touch() {
2626+ s.LastUpdated = time.Now().Unix()
2727+}
2828+2929+// Load reads the state from the state file
3030+// Returns an error if the file cannot be read or unmarshaled.
3131+func Load() (*State, error) {
3232+ return loadFromFile(defaultPath)
3333+}
3434+3535+// Save writes the given State to the state file.
3636+// Overwrites any existing state file.
3737+func Save(state *State) error {
3838+ return saveToFile(defaultPath, state)
3939+}
4040+4141+// loadFromFile reads the state JSON from the specified file path.
4242+func loadFromFile(path string) (*State, error) {
4343+ data, err := os.ReadFile(path)
4444+ if err != nil {
4545+ log.Printf("⚠️ No previous state found, starting fresh: %v", err)
4646+ return &State{Data: make(map[string]string)}, nil
4747+ }
4848+4949+ var state State
5050+ if err := json.Unmarshal(data, &state); err != nil {
5151+ return nil, err
5252+ }
5353+5454+ return &state, nil
5555+}
5656+5757+// saveToFile writes the State as JSON to the specified file path.
5858+func saveToFile(path string, state *State) error {
5959+ data, err := json.Marshal(state)
6060+ if err != nil {
6161+ return err
6262+ }
6363+6464+ return os.WriteFile(path, data, 0644)
6565+}
+17
internal/state/utils.go
···11+package state
22+33+import (
44+ "fmt"
55+ "crypto/sha256"
66+ "encoding/hex"
77+)
88+99+// HashContent returns the SHA256 hash of the given data.
1010+func HashContent(data []byte) string {
1111+ sum := sha256.Sum256(data)
1212+ return hex.EncodeToString(sum[:])
1313+}
1414+1515+func GenerateKey(appName, file string) string {
1616+ return fmt.Sprintf("%s:%s", appName, file)
1717+}