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
31 changes: 30 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ The core ownership boundaries are:
as the generic known non-sensitive env string type in the store cutover.
- `internal/projection/dotenv`: dotenv projection, legacy `.env.example`
comment parsing, materialization, and dotenv rendering.
- `internal/store`: target home for the v2 store lifecycle.
- `internal/state`: EffectiveState machine, projections, and envelopes.
- `pkg/owl`: small public API. Export what callers need; do not re-export every
internal model detail by default.
- `cmd`: command wiring and rendering only.
Expand Down Expand Up @@ -93,6 +93,35 @@ Avoid:
old v1 GraphQL-backed runtime as a second owner of state semantics
```

## Graph Facade Layering

Keep Owl operation facades layered deliberately:

- Friendly API: typed Owl input to typed Owl output. Normal Go callers should
use methods such as `Store.Snapshot`, `Store.Source`, `Store.Check`,
`Store.Resolve`, and `Store.ApplyPromptAnswers`.
- Operation builders: typed Owl input to canonical GraphQL document plus
schema-shaped variables. Builders such as `BuildSnapshotOperation` should be
pure and useful for tests, bindings, and debug tooling.
- Graph escape hatch: caller-provided GraphQL document plus variables to raw
graph result. Expose as an advanced/debug path such as `ExecuteGraphQL`, not
as the normal CLI, Extension, or Runme API.

Edges own I/O. CLI, Extension, Runme gRPC, and resolver/provider code should
read files, process env, protobuf streams, prompts, and external systems before
calling Owl. Graph execution receives already-materialized bytes/strings and
typed variables; it should not open project files, read process env, prompt
users, call secret managers, or speak gRPC directly.

`cmd/` is a public consumer. CLI code may use `pkg/...` APIs and command-local
wiring only; it must not import `github.com/runmedev/owl/internal/...`
packages. Move needed behavior behind `pkg/owl` or `pkg/owl/seed` instead.

Normal command/public paths must not call legacy helper shapes such as
`SnapshotItems`, `Dotenv(policy)`, `CheckState`, `LoadDotenv`,
`LoadDotenvLines`, or legacy `Update`/`Delete` helpers. Use typed graph-backed
facades such as `Snapshot`, `Source`, `Check`, and `ApplyUpdate`.

## Store Cutover Decisions

- Implement the cutover as one cohesive PR with focused commits, not separate
Expand Down
6 changes: 3 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ GIT_SHA := $(shell git rev-parse HEAD)
DATE := $(shell date -u +"%Y-%m-%dT%H:%M:%SZ")
VERSION := $(shell git describe --tags --match 'v[0-9]*' --always --dirty)
LDFLAGS := -s -w \
-X 'github.com/runmedev/owl/internal/version.BuildDate=$(DATE)' \
-X 'github.com/runmedev/owl/internal/version.BuildVersion=$(subst v,,$(VERSION))' \
-X 'github.com/runmedev/owl/internal/version.Commit=$(GIT_SHA)'
-X 'github.com/runmedev/owl/pkg/owl.BuildDate=$(DATE)' \
-X 'github.com/runmedev/owl/pkg/owl.BuildVersion=$(subst v,,$(VERSION))' \
-X 'github.com/runmedev/owl/pkg/owl.Commit=$(GIT_SHA)'

.PHONY: build
build: BUILD_OUTPUT ?= owl
Expand Down
14 changes: 4 additions & 10 deletions cmd/cue_catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,16 @@ import (
"fmt"
"os"

"github.com/runmedev/owl/internal/registry"
"github.com/runmedev/owl/pkg/owl"
)

const cueRootEnv = "OWL_CUE_ROOT"

var lookupEnv = os.LookupEnv

func commandTypeProvider() (registry.TypeProvider, error) {
root, configured := lookupEnv(cueRootEnv)
if !configured {
return registry.NewBuiltInRegistry(), nil
}
if root == "" {
return nil, fmt.Errorf("%s is set but empty", cueRootEnv)
}
types, err := registry.NewBuiltInRegistryFromDirectory(root)
func commandTypeProvider() (owl.TypeProvider, error) {
root, _ := lookupEnv(cueRootEnv)
types, err := owl.TypeProviderFromCatalogInput(owl.TypeCatalogInput{Root: root})
if err != nil {
return nil, fmt.Errorf("load CUE catalog from %s=%q: %w", cueRootEnv, root, err)
}
Expand Down
19 changes: 8 additions & 11 deletions cmd/cue_catalog_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,8 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/runmedev/owl/internal/model"
"github.com/runmedev/owl/internal/registry"
"github.com/runmedev/owl/internal/seed"
"github.com/runmedev/owl/pkg/owl"
"github.com/runmedev/owl/pkg/owl/seed"
)

func TestCommandCUECatalogPrecedence(t *testing.T) {
Expand Down Expand Up @@ -51,11 +50,9 @@ func TestCommandCUECatalogPrecedence(t *testing.T) {
require.Error(t, runCheck(t))
})

t.Run("empty is rejected", func(t *testing.T) {
t.Run("empty uses embedded", func(t *testing.T) {
withLookupEnv(t, func(string) (string, bool) { return "", true })
err := runCheck(t)
require.Error(t, err)
assert.Contains(t, err.Error(), "OWL_CUE_ROOT is set but empty")
require.NoError(t, runCheck(t))
})

t.Run("invalid never falls back", func(t *testing.T) {
Expand Down Expand Up @@ -91,7 +88,7 @@ func TestCUERootControlVariableIsNotObserved(t *testing.T) {
func TestProjectSpecReceivesSelectedTypeProvider(t *testing.T) {
t.Parallel()

provider := &trackingTypeProvider{BuiltInRegistry: registry.NewBuiltInRegistry()}
provider := &trackingTypeProvider{TypeProvider: owl.NewBuiltInTypeProvider()}
client := NewLocalStoreClient(LocalStoreOptions{TypeProvider: provider})
result, err := client.ProjectSpec(context.Background(), ProjectSpecRequest{
ConfigPath: filepath.Join(commandRepoRoot(t), "examples/redis/owl.toml"),
Expand All @@ -102,13 +99,13 @@ func TestProjectSpecReceivesSelectedTypeProvider(t *testing.T) {
}

type trackingTypeProvider struct {
registry.BuiltInRegistry
owl.TypeProvider
resolveTypeRefs int
}

func (p *trackingTypeProvider) ResolveTypeRef(ref string) (model.TypeDef, bool, error) {
func (p *trackingTypeProvider) ResolveTypeRef(ref string) (owl.TypeDef, bool, error) {
p.resolveTypeRefs++
return p.BuiltInRegistry.ResolveTypeRef(ref)
return p.TypeProvider.ResolveTypeRef(ref)
}

func withLookupEnv(t *testing.T, fn func(string) (string, bool)) {
Expand Down
66 changes: 66 additions & 0 deletions cmd/layering_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package cmd

import (
"os"
"path/filepath"
"strings"
"testing"

"github.com/stretchr/testify/require"
)

func TestCommandLayeringUsesPublicPackages(t *testing.T) {
t.Parallel()

internalImport := `"github.com/runmedev/owl/` + `internal/`
for _, file := range goFiles(t, ".") {
raw, err := os.ReadFile(file)
require.NoError(t, err)
require.NotContains(t, string(raw), internalImport, file)
}
}

func TestCommandAndSeedAvoidLegacyStoreHelpers(t *testing.T) {
t.Parallel()

forbidden := []string{
".SnapshotItems(",
".CheckState(",
".LoadDotenv(",
".LoadDotenvLines(",
"store.Update(",
"store.Delete(",
"roundTripped.Update(",
"roundTripped.Delete(",
}
for _, root := range []string{".", "../internal/seed"} {
for _, file := range goFiles(t, root) {
if strings.HasSuffix(file, "_test.go") {
continue
}
raw, err := os.ReadFile(file)
require.NoError(t, err)
for _, pattern := range forbidden {
require.NotContains(t, string(raw), pattern, file)
}
}
}
}

func goFiles(t *testing.T, root string) []string {
t.Helper()
var files []string
require.NoError(t, filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
if strings.HasSuffix(path, ".go") {
files = append(files, path)
}
return nil
}))
return files
}
55 changes: 28 additions & 27 deletions cmd/local.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,8 @@ import (

"github.com/spf13/cobra"

"github.com/runmedev/owl/internal/model"
"github.com/runmedev/owl/internal/registry"
"github.com/runmedev/owl/internal/requirements"
"github.com/runmedev/owl/internal/seed"
"github.com/runmedev/owl/pkg/owl"
"github.com/runmedev/owl/pkg/owl/seed"
)

type LocalStoreOptions struct {
Expand All @@ -23,7 +20,7 @@ type LocalStoreOptions struct {
Direnv seed.DirenvPolicy
DirenvDir string
DirenvRunner seed.DirenvExportRunner
TypeProvider registry.TypeProvider
TypeProvider owl.TypeProvider
}

type LocalStoreClient struct {
Expand Down Expand Up @@ -82,12 +79,15 @@ func (c *LocalStoreClient) Snapshot(ctx context.Context, req SnapshotRequest) (*
return nil, err
}

items, err := store.Snapshot(owl.SnapshotPolicy{Reveal: req.Reveal && req.Insecure})
snapshot, err := store.Snapshot(ctx, owl.SnapshotInput{
Policy: owl.SnapshotPolicy{Reveal: req.Reveal && req.Insecure},
Filter: owl.SnapshotFilter{All: req.All, Limit: req.Limit},
})
if err != nil {
return nil, err
}

return &SnapshotResult{Envs: snapshotEnvsFromItems(items)}, nil
return &SnapshotResult{Envs: snapshotEnvsFromItems(snapshot.Envs)}, nil
}

func (c *LocalStoreClient) Source(ctx context.Context, req SourceRequest) (*SourceResult, error) {
Expand All @@ -96,12 +96,14 @@ func (c *LocalStoreClient) Source(ctx context.Context, req SourceRequest) (*Sour
return nil, err
}

envs, err := store.Dotenv(owl.DotenvPolicy{Insecure: req.Insecure})
source, err := store.Source(ctx, owl.SourceInput{
Policy: owl.DotenvPolicy{Insecure: req.Insecure},
})
if err != nil {
return nil, err
}

return &SourceResult{Envs: envs}, nil
return &SourceResult{Envs: source.Envs}, nil
}

func (c *LocalStoreClient) Check(ctx context.Context, req CheckRequest) (*CheckResult, error) {
Expand All @@ -110,19 +112,18 @@ func (c *LocalStoreClient) Check(ctx context.Context, req CheckRequest) (*CheckR
return nil, err
}

check := store.Check()
items, err := store.Snapshot(owl.SnapshotPolicy{})
check, err := store.Check(ctx, owl.CheckInput{})
if err != nil {
return nil, err
}
return &CheckResult{
OK: check.OK,
Diagnostics: append(diagnosticStrings(c.lastSourceDiagnostics, req.Details), diagnosticStrings(check.Diagnostics, req.Details)...),
Checked: len(items),
Checked: check.Checked,
}, nil
}

func (c *LocalStoreClient) Type(_ context.Context, req TypeRequest) (*TypeResult, error) {
func (c *LocalStoreClient) Type(ctx context.Context, req TypeRequest) (*TypeResult, error) {
if req.SpecPath == "" {
req.SpecPath = ".env.spec"
}
Expand All @@ -133,7 +134,7 @@ func (c *LocalStoreClient) Type(_ context.Context, req TypeRequest) (*TypeResult
return nil, err
}

result, err := store.Type(owl.TypePolicy{All: req.All})
result, err := store.Type(ctx, owl.TypeInput{Policy: owl.TypePolicy{All: req.All}})
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -263,37 +264,37 @@ func processEnvForOptions(options LocalStoreOptions) []string {
return filtered
}

func (c *LocalStoreClient) ProjectSpec(_ context.Context, req ProjectSpecRequest) (*ProjectSpecResult, error) {
func (c *LocalStoreClient) ProjectSpec(ctx context.Context, req ProjectSpecRequest) (*ProjectSpecResult, error) {
configPath, err := resolveConfigPath(req.ConfigPath, true)
if err != nil {
return nil, err
}
input, err := requirements.ReadConfigFile(configPath)
if err != nil {
return nil, err
}
storeOptions := []owl.StoreOption{owl.WithConfigSource(configPath, input)}
if c.options.TypeProvider != nil {
storeOptions = append(storeOptions, owl.WithTypeProvider(c.options.TypeProvider))
types := c.options.TypeProvider
if types == nil {
types = owl.NewBuiltInTypeProvider()
}
storeOptions := []owl.StoreOption{owl.WithTypeProvider(types), owl.WithConfigFile(configPath)}
store, err := owl.NewStore(storeOptions...)
if err != nil {
return nil, err
}
rendered, err := store.DotenvSpec()
rendered, err := store.ProjectSpec(ctx, owl.ProjectSpecInput{
Load: owl.LoadInput{},
})
if err != nil {
return nil, err
}
outputText := rendered.Rendered
output := req.Output
if req.Write {
output = ".env.spec"
}
if output != "" && output != "-" {
if err := writeGeneratedDotenvSpec(output, rendered); err != nil {
if err := writeGeneratedDotenvSpec(output, outputText); err != nil {
return nil, err
}
}
return &ProjectSpecResult{Rendered: rendered}, nil
return &ProjectSpecResult{Rendered: outputText}, nil
}

func resolveConfigPath(explicit string, required bool) (string, error) {
Expand Down Expand Up @@ -336,7 +337,7 @@ func writeGeneratedDotenvSpec(path string, rendered string) error {
}

func isGeneratedDotenvSpec(raw []byte) bool {
return strings.HasPrefix(string(raw), requirements.GeneratedDotenvSpecHeaderPrefix)
return strings.HasPrefix(string(raw), owl.GeneratedDotenvSpecHeaderPrefix)
}

func renderDotenvSpecTypeProposals(proposals []owl.TypeProposal) string {
Expand Down Expand Up @@ -529,7 +530,7 @@ func resolveResultFromOwl(result owl.ResolveResult) *ResolveResult {
ProjectionKey: string(action.Prompt.ProjectionKey),
Label: action.Prompt.Label,
Description: action.Prompt.Description,
Sensitive: action.Prompt.Sensitivity == model.SensitivitySensitive,
Sensitive: action.Prompt.Sensitivity == owl.SensitivitySensitive,
Required: action.Prompt.Required,
AllowEmpty: action.Prompt.AllowEmpty,
}
Expand Down
6 changes: 3 additions & 3 deletions cmd/project_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/runmedev/owl/internal/requirements"
"github.com/runmedev/owl/pkg/owl"
)

func TestProjectSpecRendersGeneratedSpec(t *testing.T) {
Expand Down Expand Up @@ -146,9 +146,9 @@ needs:
instance: default
`), 0o600))

jsonConfig, err := requirements.ReadConfigFile(jsonPath)
jsonConfig, err := owl.ReadConfigFile(jsonPath)
require.NoError(t, err)
yamlConfig, err := requirements.ReadConfigFile(yamlPath)
yamlConfig, err := owl.ReadConfigFile(yamlPath)
require.NoError(t, err)

require.Len(t, jsonConfig.Needs, 1)
Expand Down
4 changes: 2 additions & 2 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import (

"github.com/spf13/cobra"

"github.com/runmedev/owl/internal/version"
"github.com/runmedev/owl/pkg/owl"
)

var errSilentExit = stderrors.New("silent exit")
Expand All @@ -18,7 +18,7 @@ func NewRootCommand() *cobra.Command {
cmd := cobra.Command{
Use: "owl",
Short: "Typed environment variable store",
Version: version.BaseVersionInfo(),
Version: owl.BaseVersionInfo(),
SilenceErrors: true,
SilenceUsage: true,
}
Expand Down
Loading