Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
8 changes: 4 additions & 4 deletions go.work.sum
Original file line number Diff line number Diff line change
Expand Up @@ -321,10 +321,12 @@ github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo=
github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs=
github.com/tursodatabase/libsql-client-go v0.0.0-20251219100830-236aa1ff8acc h1:lzi/5fg2EfinRlh3v//YyIhnc4tY7BTqazQGwb1ar+0=
github.com/tursodatabase/libsql-client-go v0.0.0-20251219100830-236aa1ff8acc/go.mod h1:08inkKyguB6CGGssc/JzhmQWwBgFQBgjlYFjxjRh7nU=
github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
github.com/vertica/vertica-sql-go v1.3.5 h1:IrfH2WIgzZ45yDHyjVFrXU2LuKNIjF5Nwi90a6cfgUI=
github.com/vertica/vertica-sql-go v1.3.5/go.mod h1:jnn2GFuv+O2Jcjktb7zyc4Utlbu9YVqpHH/lx63+1M4=
github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc=
Expand All @@ -340,10 +342,7 @@ go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
go.opentelemetry.io/contrib/detectors/gcp v1.39.0 h1:kWRNZMsfBHZ+uHjiH4y7Etn2FK26LAGkNFw7RHv1DhE=
go.opentelemetry.io/contrib/detectors/gcp v1.39.0/go.mod h1:t/OGqzHBa5v6RHZwrDBJ2OirWc+4q/w2fTbLZwAKjTk=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4/go.mod h1:g5NllXBEermZrmR51cJDQxmJUHUOfRAaNyWBM+R+548=
golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM=
golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM=
Expand All @@ -355,3 +354,4 @@ google.golang.org/genproto/googleapis/bytestream v0.0.0-20250804133106-a7a43d27e
google.golang.org/genproto/googleapis/bytestream v0.0.0-20250804133106-a7a43d27e69b/go.mod h1:h6yxum/C2qRb4txaZRLDHK8RyS0H/o2oEDeKY4onY/Y=
howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM=
howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8=
29 changes: 22 additions & 7 deletions packages/engine/internal/cel/cel.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ const maxProgramCacheSize = 10000
type Evaluator struct {
env *cel.Env
configEnv map[string]string // config-sourced env values from mantle.yaml
valuesEnv map[string]string // values-file env overrides (between config and OS)
envCache map[string]string // cached, filtered environment variables
programMu sync.Mutex
programCache map[string]cel.Program // expression string -> compiled cel.Program
Expand All @@ -51,7 +52,7 @@ func NewEvaluator() (*Evaluator, error) {
if err != nil {
return nil, fmt.Errorf("creating CEL environment: %w", err)
}
return &Evaluator{env: env, envCache: mergeEnvVars(nil), programCache: make(map[string]cel.Program)}, nil
return &Evaluator{env: env, envCache: mergeEnvVars(nil, nil), programCache: make(map[string]cel.Program)}, nil
}

// Eval evaluates a CEL expression and returns the result as a Go value.
Expand Down Expand Up @@ -229,24 +230,38 @@ func (e *Evaluator) resolveValue(v any, ctx *Context) (any, error) {
// OS env vars take precedence over config values.
func (e *Evaluator) SetConfigEnv(configEnv map[string]string) {
e.configEnv = configEnv
e.envCache = mergeEnvVars(configEnv)
e.envCache = mergeEnvVars(configEnv, e.valuesEnv)
}

// SetValuesEnv sets the values-file env overrides and rebuilds the env cache.
// Values-file env sits between config env and MANTLE_ENV_* OS variables in
// the precedence stack: config < values < MANTLE_ENV_*.
func (e *Evaluator) SetValuesEnv(valuesEnv map[string]string) {
e.valuesEnv = valuesEnv
e.envCache = mergeEnvVars(e.configEnv, e.valuesEnv)
}

// mergeEnvVars builds the env map available to CEL expressions.
// It starts with config-sourced values (from the env: section in mantle.yaml),
// then overlays MANTLE_ENV_* environment variables (stripping the prefix).
// When a key exists in both, the OS env var wins and an info log is emitted.
// then overlays values-file overrides, then overlays MANTLE_ENV_* environment
// variables (stripping the prefix). When a key exists in both config/values and
// the OS env, the OS env var wins and an info log is emitted.
// This prevents CEL expressions from reading sensitive variables like
// MANTLE_ENCRYPTION_KEY, MANTLE_DATABASE_URL, or AWS_SECRET_ACCESS_KEY.
func mergeEnvVars(configEnv map[string]string) map[string]string {
func mergeEnvVars(configEnv map[string]string, valuesEnv map[string]string) map[string]string {
env := make(map[string]string)

// Start with config values.
// Layer 1: config values (lowest precedence).
for k, v := range configEnv {
env[k] = v
}

// Overlay MANTLE_ENV_* from OS environment (env var wins).
// Layer 2: values-file overrides.
for k, v := range valuesEnv {
env[k] = v
}

// Layer 3: MANTLE_ENV_* from OS environment (highest precedence).
const prefix = "MANTLE_ENV_"
for _, e := range os.Environ() {
parts := strings.SplitN(e, "=", 2)
Expand Down
2 changes: 1 addition & 1 deletion packages/engine/internal/cel/cel_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,7 @@ func TestEnvVars_PrefixStripping(t *testing.T) {
os.Setenv("MANTLE_DATABASE_URL", "should-not-appear")
defer os.Unsetenv("MANTLE_DATABASE_URL")

result := mergeEnvVars(nil)
result := mergeEnvVars(nil, nil)

if v, ok := result["TEST_KEY"]; !ok || v != "test-value" {
t.Errorf("mergeEnvVars()[TEST_KEY] = %q, %v; want %q, true", v, ok, "test-value")
Expand Down
113 changes: 113 additions & 0 deletions packages/engine/internal/cel/cel_values_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package cel

import (
"testing"
)

func TestSetValuesEnv_LayersBetweenConfigAndOS(t *testing.T) {
eval, err := NewEvaluator()
if err != nil {
t.Fatalf("NewEvaluator() error: %v", err)
}

eval.SetConfigEnv(map[string]string{
"LOG_LEVEL": "info",
"REGION": "us-west-2",
"APP_NAME": "config-app",
})

eval.SetValuesEnv(map[string]string{
"LOG_LEVEL": "error",
"DB_HOST": "prod-db.example.com",
})

ctx := &Context{
Steps: make(map[string]map[string]any),
Inputs: make(map[string]any),
}

// Values override config.
result, err := eval.Eval(`env.LOG_LEVEL`, ctx)
if err != nil {
t.Fatalf("Eval(env.LOG_LEVEL) error: %v", err)
}
if result != "error" {
t.Errorf("env.LOG_LEVEL = %v, want %q (values overrides config)", result, "error")
}

// Config value preserved when values doesn't override.
result, err = eval.Eval(`env.REGION`, ctx)
if err != nil {
t.Fatalf("Eval(env.REGION) error: %v", err)
}
if result != "us-west-2" {
t.Errorf("env.REGION = %v, want %q (config preserved)", result, "us-west-2")
}

// Values-only key is accessible.
result, err = eval.Eval(`env.DB_HOST`, ctx)
if err != nil {
t.Fatalf("Eval(env.DB_HOST) error: %v", err)
}
if result != "prod-db.example.com" {
t.Errorf("env.DB_HOST = %v, want %q", result, "prod-db.example.com")
}
}

func TestSetValuesEnv_OSEnvTakesPrecedence(t *testing.T) {
eval, err := NewEvaluator()
if err != nil {
t.Fatalf("NewEvaluator() error: %v", err)
}

t.Setenv("MANTLE_ENV_LOG_LEVEL", "debug")

eval.SetConfigEnv(map[string]string{
"LOG_LEVEL": "info",
})
eval.SetValuesEnv(map[string]string{
"LOG_LEVEL": "error",
})

ctx := &Context{
Steps: make(map[string]map[string]any),
Inputs: make(map[string]any),
}

result, err := eval.Eval(`env.LOG_LEVEL`, ctx)
if err != nil {
t.Fatalf("Eval(env.LOG_LEVEL) error: %v", err)
}
if result != "debug" {
t.Errorf("env.LOG_LEVEL = %v, want %q (OS env wins)", result, "debug")
}
}

func TestSetValuesEnv_NilClearsValuesLayer(t *testing.T) {
eval, err := NewEvaluator()
if err != nil {
t.Fatalf("NewEvaluator() error: %v", err)
}

eval.SetConfigEnv(map[string]string{
"LOG_LEVEL": "info",
})
eval.SetValuesEnv(map[string]string{
"LOG_LEVEL": "error",
})

eval.SetValuesEnv(nil)

ctx := &Context{
Steps: make(map[string]map[string]any),
Inputs: make(map[string]any),
}

result, err := eval.Eval(`env.LOG_LEVEL`, ctx)
if err != nil {
t.Fatalf("Eval(env.LOG_LEVEL) error: %v", err)
}
if result != "info" {
t.Errorf("env.LOG_LEVEL = %v, want %q (back to config after clear)", result, "info")
}
}
172 changes: 172 additions & 0 deletions packages/engine/internal/cli/env.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
package cli

import (
"fmt"
"text/tabwriter"

"github.com/dvflw/mantle/internal/config"
"github.com/dvflw/mantle/internal/db"
"github.com/dvflw/mantle/internal/environment"
"github.com/dvflw/mantle/internal/workflow"
"github.com/spf13/cobra"
)

func newEnvCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "env",
Short: "Manage named environments",
Long: "Create, list, show, and delete named environments for parameterized workflow execution.",
}

cmd.AddCommand(newEnvCreateCommand())
cmd.AddCommand(newEnvListCommand())
cmd.AddCommand(newEnvShowCommand())
cmd.AddCommand(newEnvDeleteCommand())

return cmd
}

func newEnvCreateCommand() *cobra.Command {
var fromFile string

cmd := &cobra.Command{
Use: "create <name>",
Short: "Create a named environment",
Long: "Creates a named environment from a values file.",
Example: ` mantle env create production --from prod.values.yaml
mantle env create staging --from staging.values.yaml`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
name := args[0]

vals, err := workflow.LoadValues(fromFile)
if err != nil {
return fmt.Errorf("loading values file: %w", err)
}

store, cleanup, err := newEnvStore(cmd)
if err != nil {
return err
}
defer cleanup()

env, err := store.Create(cmd.Context(), name, vals.Inputs, vals.Env)
if err != nil {
return err
}

fmt.Fprintf(cmd.OutOrStdout(), "Created environment %q\n", env.Name)
return nil
},
}

cmd.Flags().StringVar(&fromFile, "from", "", "Values file to load inputs and env from (required)")
_ = cmd.MarkFlagRequired("from")

return cmd
}

func newEnvListCommand() *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List all environments",
RunE: func(cmd *cobra.Command, args []string) error {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
store, cleanup, err := newEnvStore(cmd)
if err != nil {
return err
}
defer cleanup()

envs, err := store.List(cmd.Context())
if err != nil {
return err
}

if len(envs) == 0 {
fmt.Fprintln(cmd.OutOrStdout(), "(no environments)")
return nil
}

w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "NAME\tCREATED")
for _, e := range envs {
fmt.Fprintf(w, "%s\t%s\n", e.Name, e.CreatedAt.Format("2006-01-02 15:04:05"))
}
return w.Flush()
},
}
}

func newEnvShowCommand() *cobra.Command {
return &cobra.Command{
Use: "show <name>",
Short: "Show environment details",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
store, cleanup, err := newEnvStore(cmd)
if err != nil {
return err
}
defer cleanup()

env, err := store.Get(cmd.Context(), args[0])
if err != nil {
return err
}

fmt.Fprintf(cmd.OutOrStdout(), "Name: %s\n", env.Name)
if len(env.Inputs) > 0 {
fmt.Fprintln(cmd.OutOrStdout(), "\nInputs:")
for k, v := range env.Inputs {
fmt.Fprintf(cmd.OutOrStdout(), " %s: %v\n", k, v)
}
}
if len(env.Env) > 0 {
fmt.Fprintln(cmd.OutOrStdout(), "\nEnv:")
for k, v := range env.Env {
fmt.Fprintf(cmd.OutOrStdout(), " %s: %s\n", k, v)
}
}
return nil
},
}
}

func newEnvDeleteCommand() *cobra.Command {
return &cobra.Command{
Use: "delete <name>",
Short: "Delete an environment",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
store, cleanup, err := newEnvStore(cmd)
if err != nil {
return err
}
defer cleanup()

if err := store.Delete(cmd.Context(), args[0]); err != nil {
return err
}

fmt.Fprintf(cmd.OutOrStdout(), "Deleted environment %q\n", args[0])
return nil
},
}
}
Comment on lines +156 to +176

@coderabbitai coderabbitai Bot Apr 6, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if environment.Store.Delete emits audit events
rg -n -A 15 'func.*Delete.*context' packages/engine/internal/environment/store.go | head -40

Repository: dvflw/mantle

Length of output: 554


🏁 Script executed:

cat -n packages/engine/internal/environment/store.go | sed -n '125,160p'

Repository: dvflw/mantle

Length of output: 709


🏁 Script executed:

rg -n -B 5 -A 10 'Audit.*Delete|EmitAudit.*Delete' packages/engine/internal/environment/

Repository: dvflw/mantle

Length of output: 38


🏁 Script executed:

rg -n -B 2 -A 20 'func.*Store.*Create.*ctx context' packages/engine/internal/environment/store.go | head -50

Repository: dvflw/mantle

Length of output: 843


🏁 Script executed:

rg -n -B 5 -A 15 'type Store struct' packages/engine/internal/environment/store.go

Repository: dvflw/mantle

Length of output: 750


🏁 Script executed:

rg -n -A 35 'func.*Store.*Create.*ctx context' packages/engine/internal/environment/store.go | head -70

Repository: dvflw/mantle

Length of output: 1245


🏁 Script executed:

rg -n 'AuditEmitter|EmitAudit' packages/engine/internal/environment/store.go

Repository: dvflw/mantle

Length of output: 38


🏁 Script executed:

rg -n 'type AuditEmitter|interface.*AuditEmitter' packages/engine/internal/

Repository: dvflw/mantle

Length of output: 38


🏁 Script executed:

rg -n 'AuditEmitter' packages/engine/internal/ | head -20

Repository: dvflw/mantle

Length of output: 38


🏁 Script executed:

rg -r 'AuditEmitter' packages/ --type go

Repository: dvflw/mantle

Length of output: 38


🏁 Script executed:

cat -n CLAUDE.md | head -50

Repository: dvflw/mantle

Length of output: 2632


Emit audit event for delete operation.

The Delete method performs a state-changing operation but does not emit an audit event. Per the architecture principle ("Audit from day one — every state-changing operation emits an audit event via the AuditEmitter interface"), this operation must emit an audit event via the AuditEmitter interface. The Store struct will need an AuditEmitter dependency injected to support this.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/engine/internal/cli/env.go` around lines 135 - 155, The delete
command currently calls store.Delete(...) but does not emit an audit event;
update the Store to accept an AuditEmitter dependency (add an AuditEmitter field
on the Store struct and wire it in via newEnvStore), then after a successful
call to Store.Delete(ctx, name) inside newEnvDeleteCommand call the emitter
(e.g., store.AuditEmitter.Emit or EmitEvent) with the command context, the
operation name ("environment.delete"), target resource identifier (args[0]) and
any relevant metadata (actor, timestamp, outcome) so every state-changing delete
emits an audit event; ensure nil-checking of store.AuditEmitter so the code
remains safe if not provided.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pushing back on this one. secret.Store — the closest analog — does not have an AuditEmitter. Audit events in the CLI layer are emitted by commands that modify execution state (cancel.go, rotate_key.go), not by CRUD stores. Adding an AuditEmitter to environment.Store alone would be inconsistent.

If we want audit coverage for all CRUD operations (secrets, environments, etc.), that should be a separate cross-cutting concern. Happy to file an issue for it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@michaelmcnees, that's a fair point — applying audit emission only to environment.Store while secret.Store and other CRUD stores are unadjusted would be inconsistent, and the existing pattern of emitting audit events in execution-state commands (not stores) is a reasonable architectural boundary.

If you'd like, I can open a GitHub issue to track cross-cutting audit coverage for all CRUD stores (secrets, environments, etc.) as a follow-up. Just say the word.


✏️ Learnings added
Learnt from: michaelmcnees
Repo: dvflw/mantle PR: 127
File: packages/engine/internal/cli/env.go:146-166
Timestamp: 2026-04-06T04:47:39.490Z
Learning: In the Mantle project, audit events are emitted at the CLI command layer (e.g., `cancel.go`, `rotate_key.go`) only for execution-state-changing operations, not inside CRUD stores (e.g., `secret.Store`, `environment.Store`). Do not flag missing audit event emission in CRUD store methods; that is a separate cross-cutting concern planned for a future milestone.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: CR
Repo: dvflw/mantle PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-27T05:02:25.695Z
Learning: Applies to packages/engine/cmd/**/*.go : Use Cobra for CLI framework implementation.

Learnt from: CR
Repo: dvflw/mantle PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-27T05:02:25.695Z
Learning: Applies to packages/engine/**/*.go : Configuration should use mantle.yaml with CLI flag and env var overrides.

Learnt from: CR
Repo: dvflw/mantle PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-27T04:56:56.455Z
Learning: Applies to packages/engine/internal/**/*.go : Every state-changing operation must emit an audit event via the AuditEmitter interface.

Learnt from: CR
Repo: dvflw/mantle PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-27T05:02:25.695Z
Learning: Applies to packages/engine/**/*.go : Every state-changing operation must emit an audit event via the AuditEmitter interface.

Learnt from: michaelmcnees
Repo: dvflw/mantle PR: 0
File: :0-0
Timestamp: 2026-03-25T12:58:26.665Z
Learning: In the Mantle project, the audit emitter is a `NoopEmitter` in V1. Rollback audit events (e.g., `ActionArtifactDeleted`) are intentionally deferred to V1.1 when the Postgres-backed auditor is implemented. Do not flag missing rollback audit events as a review issue until then.

Learnt from: CR
Repo: dvflw/mantle PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-27T05:02:25.695Z
Learning: Applies to packages/engine/**/*.go : Engine development happens in `packages/engine/`. Run `go test ./...` and `go build ./cmd/mantle` from that directory.

Learnt from: CR
Repo: dvflw/mantle PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-27T04:56:56.455Z
Learning: Applies to packages/engine/**/*.go : Engine development happens in `packages/engine/`. Run `go test ./...` and `go build ./cmd/mantle` from that directory.

Learnt from: CR
Repo: dvflw/mantle PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-27T04:56:56.455Z
Learning: Applies to mantle.yaml : Use mantle.yaml for configuration with CLI flag and env var overrides.

Learnt from: michaelmcnees
Repo: dvflw/mantle PR: 45
File: internal/connector/email_receive.go:126-135
Timestamp: 2026-03-25T19:44:56.827Z
Learning: When reviewing Go code that uses go-imap v2 `github.com/emersion/go-imap/v2/imapclient.Client.Store(numSet, store, options)`, do not require a separate `UIDStore` method call. The method decides between `UID STORE` and plain `STORE` at runtime based on `numSet`’s concrete type: passing an `imap.UIDSet` should be treated as correct and will send `UID STORE`, while `imap.SeqSet` will send plain `STORE`. Only flag `client.Store` usage if `numSet` is of an unexpected type or the call signature is otherwise incorrect.


// newEnvStore builds an environment.Store from the current command context.
func newEnvStore(cmd *cobra.Command) (*environment.Store, func(), error) {
cfg := config.FromContext(cmd.Context())
if cfg == nil {
return nil, nil, fmt.Errorf("config not loaded")
}

database, err := db.Open(cfg.Database)
if err != nil {
return nil, nil, fmt.Errorf("failed to connect to database: %w", err)
}

store := &environment.Store{DB: database}
cleanup := func() { database.Close() }
return store, cleanup, nil
}
Loading
Loading