Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 7 additions & 53 deletions cmd/ops/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,14 @@ import (
"sean_seannery/opsfile/internal"
)

const opsFileName string = "Opsfile"

func main() {
slog.SetLogLoggerLevel(slog.LevelWarn)

flags, positionals, err := internal.ParseOpsFlags(os.Args[1:], nil)
if errors.Is(err, internal.ErrHelp) {
// Best-effort: show available commands alongside help
if dir, dirErr := resolveOpsfileDir(flags.Directory); dirErr == nil {
opsfilePath := filepath.Join(dir, opsFileName)
opsfilePath := filepath.Join(dir, internal.OpsFileName)
if parsed, perr := internal.ParseOpsFile(opsfilePath); perr == nil {
fmt.Fprintln(os.Stderr)
formatCommandList(os.Stderr, opsfilePath, parsed.Commands, parsed.CommandOrder, parsed.EnvOrder)
Expand All @@ -43,27 +41,27 @@ func main() {
if flags.Directory != "" {
dir = flags.Directory
} else {
dir, err = getClosestOpsfilePath()
dir, err = internal.FindOpsfileDir()
if err != nil {
slog.Error("finding Opsfile: " + err.Error())
os.Exit(1)
}
}

parsed, err := internal.ParseOpsFile(filepath.Join(dir, opsFileName))
parsed, err := internal.ParseOpsFile(filepath.Join(dir, internal.OpsFileName))
if err != nil {
slog.Error("parsing Opsfile: " + err.Error())
os.Exit(1)
}

envFileVars, err := loadEnvFile(flags.EnvFile, dir)
envFileVars, err := internal.LoadEnvFile(flags.EnvFile, dir)
if err != nil {
slog.Error(err.Error())
os.Exit(1)
}

if flags.List {
absPath := filepath.Join(dir, opsFileName)
absPath := filepath.Join(dir, internal.OpsFileName)
displayPath := absPath
if cwd, cwdErr := os.Getwd(); cwdErr == nil {
if rel, relErr := filepath.Rel(cwd, absPath); relErr == nil {
Expand Down Expand Up @@ -97,54 +95,10 @@ func main() {
}

// resolveOpsfileDir returns the directory containing the Opsfile, preferring
// flagDir when set and falling back to getClosestOpsfilePath.
// flagDir when set and falling back to internal.FindOpsfileDir.
func resolveOpsfileDir(flagDir string) (string, error) {
if flagDir != "" {
return flagDir, nil
}
return getClosestOpsfilePath()
}

const defaultEnvFileName = ".ops_secrets.env"

// loadEnvFile resolves and parses the env file. If envFilePath is set, that
// file is used (error if missing). Otherwise, .ops_secrets.env next to the
// Opsfile is loaded if present (silently skipped if absent).
func loadEnvFile(envFilePath, opsfileDir string) (internal.OpsVariables, error) {
if envFilePath != "" {
return internal.ParseEnvFile(envFilePath)
}
defaultPath := filepath.Join(opsfileDir, defaultEnvFileName)
if _, err := os.Stat(defaultPath); err != nil {
return nil, nil // absent default is silent no-op
}
return internal.ParseEnvFile(defaultPath)
}

// getClosestOpsfilePath returns the directory containing the nearest Opsfile,
// walking up the directory tree from the current working directory.
func getClosestOpsfilePath() (string, error) {
workingDir, err := os.Getwd()
if err != nil {
return "", fmt.Errorf("getting working directory: %w", err)
}
slog.Info("Working Directory: " + workingDir)

currPath := workingDir
file, err := os.Stat(filepath.Join(currPath, opsFileName))

// ignore folders named 'Opsfile'
for (err != nil && os.IsNotExist(err)) || (err == nil && file.IsDir()) {
slog.Info("Opsfile not found in " + currPath)

if currPath == filepath.Dir(currPath) {
return "", errors.New("could not find Opsfile in any parent directory")
}
currPath = filepath.Dir(currPath)
file, err = os.Stat(filepath.Join(currPath, opsFileName))
}
if err != nil {
return "", fmt.Errorf("stat %s: %w", currPath, err)
}
return currPath, nil
return internal.FindOpsfileDir()
}
77 changes: 0 additions & 77 deletions cmd/ops/main_test.go

This file was deleted.

17 changes: 17 additions & 0 deletions internal/envfile_parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,26 @@ import (
"bufio"
"fmt"
"os"
"path/filepath"
"strings"
)

const DefaultEnvFileName = ".ops_secrets.env"

// LoadEnvFile resolves and parses the env file. If envFilePath is set, that
// file is used (error if missing). Otherwise, DefaultEnvFileName next to the
// Opsfile is loaded if present (silently skipped if absent).
func LoadEnvFile(envFilePath, opsfileDir string) (OpsVariables, error) {
if envFilePath != "" {
return ParseEnvFile(envFilePath)
}
defaultPath := filepath.Join(opsfileDir, DefaultEnvFileName)
if _, err := os.Stat(defaultPath); err != nil {
return nil, nil // absent default is silent no-op
}
return ParseEnvFile(defaultPath)
}

// ParseEnvFile reads and parses a .env-format file at the given path.
// It returns the declared variables as an OpsVariables map.
//
Expand Down
44 changes: 44 additions & 0 deletions internal/envfile_parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -163,3 +163,47 @@ func TestParseEnvFile_ErrorFormat(t *testing.T) {
assert.True(t, len(err.Error()) > 0)
assert.Contains(t, err.Error(), `env-file "`+missingPath+`"`)
}

// --- LoadEnvFile tests ---

func TestLoadEnvFile_ExplicitPathLoadsFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "custom.env")
err := os.WriteFile(path, []byte("KEY=value\n"), 0o644)
require.NoError(t, err)

vars, err := LoadEnvFile(path, dir)
require.NoError(t, err)
assert.Equal(t, "value", vars["KEY"])
}

func TestLoadEnvFile_ExplicitPathMissingReturnsError(t *testing.T) {
_, err := LoadEnvFile("/nonexistent/custom.env", t.TempDir())
require.Error(t, err)
assert.ErrorContains(t, err, "env-file")
}

func TestLoadEnvFile_DefaultFileLoadedWhenPresent(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, DefaultEnvFileName)
err := os.WriteFile(path, []byte("SECRET=abc\n"), 0o644)
require.NoError(t, err)

vars, err := LoadEnvFile("", dir)
require.NoError(t, err)
assert.Equal(t, "abc", vars["SECRET"])
}

func TestLoadEnvFile_DefaultFileAbsentIsNoOp(t *testing.T) {
// No .ops_secrets.env in dir — should return nil, nil silently.
vars, err := LoadEnvFile("", t.TempDir())
require.NoError(t, err)
assert.Nil(t, vars)
}

func TestLoadEnvFile_EmptyPathsUsesDefault(t *testing.T) {
// Both envFilePath and opsfileDir empty — default path won't exist, so no-op.
vars, err := LoadEnvFile("", t.TempDir())
require.NoError(t, err)
assert.Nil(t, vars)
}
29 changes: 29 additions & 0 deletions internal/opsfile_parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"os"
"path/filepath"
"slices"
"strings"
)
Expand Down Expand Up @@ -324,6 +325,34 @@ func (p *parser) joinLastShellLine(suffix string) {
}
}

// OpsFileName is the conventional name of the Opsfile that ops searches for.
const OpsFileName = "Opsfile"

// FindOpsfileDir returns the directory containing the nearest Opsfile, walking
// up the directory tree from the current working directory (same discovery
// pattern as git). Directories named "Opsfile" are ignored.
func FindOpsfileDir() (string, error) {
workingDir, err := os.Getwd()
if err != nil {
return "", fmt.Errorf("getting working directory: %w", err)
}

currPath := workingDir
file, err := os.Stat(filepath.Join(currPath, OpsFileName))

for (err != nil && os.IsNotExist(err)) || (err == nil && file.IsDir()) {
if currPath == filepath.Dir(currPath) {
return "", errors.New("could not find Opsfile in any parent directory")
}
currPath = filepath.Dir(currPath)
file, err = os.Stat(filepath.Join(currPath, OpsFileName))
}
if err != nil {
return "", fmt.Errorf("stat %s: %w", currPath, err)
}
return currPath, nil
}

// validate checks post-parse invariants.
func (p *parser) validate() error {
if len(p.commands) == 0 && len(p.variables) == 0 {
Expand Down
69 changes: 69 additions & 0 deletions internal/opsfile_parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -747,3 +747,72 @@ my-cmd:
assert.Equal(t, "-echo hello", lines[0].Text)
assert.True(t, lines[0].IgnoreError)
}

// --- FindOpsfileDir tests ---

// realPath resolves symlinks so tests work on macOS where /var -> /private/var.
func realPath(t *testing.T, path string) string {
t.Helper()
resolved, err := filepath.EvalSymlinks(path)
require.NoError(t, err, "EvalSymlinks(%q)", path)
return resolved
}

func TestFindOpsfileDir_FoundInCwd(t *testing.T) {
tmp := realPath(t, t.TempDir())
err := os.WriteFile(filepath.Join(tmp, OpsFileName), []byte(""), 0o644)
require.NoError(t, err)

orig, _ := os.Getwd()
t.Cleanup(func() { os.Chdir(orig) })
os.Chdir(tmp)

got, err := FindOpsfileDir()
require.NoError(t, err)
assert.Equal(t, tmp, got)
}

func TestFindOpsfileDir_FoundInParent(t *testing.T) {
parent := realPath(t, t.TempDir())
child := filepath.Join(parent, "subdir")
require.NoError(t, os.Mkdir(child, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(parent, OpsFileName), []byte(""), 0o644))

orig, _ := os.Getwd()
t.Cleanup(func() { os.Chdir(orig) })
os.Chdir(child)

got, err := FindOpsfileDir()
require.NoError(t, err)
assert.Equal(t, parent, got)
}

func TestFindOpsfileDir_NotFound(t *testing.T) {
tmp := realPath(t, t.TempDir())

orig, _ := os.Getwd()
t.Cleanup(func() { os.Chdir(orig) })
os.Chdir(tmp)

_, err := FindOpsfileDir()
require.Error(t, err)
assert.ErrorContains(t, err, "could not find Opsfile")
}

func TestFindOpsfileDir_DirectoryNamedOpsfileSkipped(t *testing.T) {
parent := realPath(t, t.TempDir())
child := filepath.Join(parent, "subdir")
require.NoError(t, os.Mkdir(child, 0o755))
// Create a directory named "Opsfile" in child — should be skipped.
require.NoError(t, os.Mkdir(filepath.Join(child, OpsFileName), 0o755))
// Place the real Opsfile in parent.
require.NoError(t, os.WriteFile(filepath.Join(parent, OpsFileName), []byte(""), 0o644))

orig, _ := os.Getwd()
t.Cleanup(func() { os.Chdir(orig) })
os.Chdir(child)

got, err := FindOpsfileDir()
require.NoError(t, err)
assert.Equal(t, parent, got, "directory named Opsfile should be skipped")
}
Loading