diff --git a/README.md b/README.md index 280bd065f..53ffe5534 100644 --- a/README.md +++ b/README.md @@ -263,6 +263,65 @@ shutdown: timeout: 30s ``` +### Runtime configuration composition + +Dependency ranges remain `ns.dependency` registry entries and exact portable +versions remain in `wippy.lock`. For local development, a runtime profile can +replace a locked module with a checkout without changing the lock: + +Pass `--config` more than once to compose any set of runtime configuration +files. Files use the same schema and merge from left to right, so later files +override matching leaves while preserving unrelated settings: + +```sh +wippy run \ + --config .wippy.yaml \ + --config .wippy.dev.yaml \ + --config config/postgres-history.yaml \ + --config .wippy.workspace.yaml \ + --profile workspace +``` + +Every explicitly named file must exist. With no `--config`, `.wippy.yaml` +remains the optional default. The first file defines the project directory used +to resolve relative runtime paths. Profiles are applied after all files merge, +in requested order, and CLI overrides such as `--set` are applied last. + +Configuration filenames have no reserved meaning. Keep private or +machine-specific files out of version control using the repository's normal +ignore policy. For example, the final file above could contain: + +```yaml +version: "1.0" + +profiles: + workspace: + workspace: + replacements: + wippy/runtime: ../runtime +``` + +Use the same composition for commands that load dependencies: + +```sh +wippy update --config .wippy.yaml --config .wippy.workspace.yaml --profile workspace +wippy install --config .wippy.yaml --config .wippy.workspace.yaml --profile workspace +``` + +The runtime configuration stack is not a publishing input, and the `workspace` +section is never exported in module metadata. A module can also restrict which +non-workspace runtime profiles are published from its `wippy.yaml` manifest: + +```yaml +publish: + profiles: + include: [production] +``` + +Existing `replacements:` in `wippy.lock` remain readable for compatibility but +emit a deprecation warning. Move them to any runtime configuration file; new +workspace replacements should not be added to the portable lock. + ## Requirements - Go 1.26+ diff --git a/boot/components/core/registry.go b/boot/components/core/registry.go index 087494a29..40a9f5535 100644 --- a/boot/components/core/registry.go +++ b/boot/components/core/registry.go @@ -4,6 +4,7 @@ package core import ( "context" + "fmt" "io" "path/filepath" "strings" @@ -16,6 +17,7 @@ import ( regapi "github.com/wippyai/runtime/api/registry" bootpkg "github.com/wippyai/runtime/boot" hubdeps "github.com/wippyai/runtime/boot/deps/hub" + "github.com/wippyai/runtime/boot/deps/lock" "github.com/wippyai/runtime/system/registry" regexp "github.com/wippyai/runtime/system/registry/expansion" historymem "github.com/wippyai/runtime/system/registry/history/memory" @@ -231,6 +233,11 @@ func newDependencyHandler( Logger: logger, Resolver: resolver, } + workspaceReplacements, err := lock.WorkspaceReplacements(cfg) + if err != nil { + return nil, fmt.Errorf("load workspace replacements: %w", err) + } + opts.WorkspaceReplacements = workspaceReplacements if registryCfg != nil { opts.ResolveTimeout = registryCfg.GetDuration(RegistryDependencyResolveTimeout, 0) diff --git a/boot/deps/config/config.go b/boot/deps/config/config.go index 83009d8ac..75060e5f4 100644 --- a/boot/deps/config/config.go +++ b/boot/deps/config/config.go @@ -46,8 +46,9 @@ type PublishConfig struct { } type PublishProfilesConfig struct { - Enabled *bool `yaml:"enabled,omitempty"` - Source string `yaml:"source,omitempty"` + Enabled *bool `yaml:"enabled,omitempty"` + Source string `yaml:"source,omitempty"` + Include []string `yaml:"include,omitempty"` } func Load(dir string) (*ModuleConfig, error) { diff --git a/boot/deps/config/config_test.go b/boot/deps/config/config_test.go index e4e21b7cc..bb110b15f 100644 --- a/boot/deps/config/config_test.go +++ b/boot/deps/config/config_test.go @@ -303,6 +303,7 @@ publish: profiles: enabled: false source: config/profiles.yaml + include: [production, staging] ` require.NoError(t, os.WriteFile(filepath.Join(dir, DefaultConfigFile), []byte(content), 0644)) @@ -311,6 +312,24 @@ publish: require.NotNil(t, cfg.Publish.Profiles.Enabled) assert.False(t, *cfg.Publish.Profiles.Enabled) assert.Equal(t, "config/profiles.yaml", cfg.Publish.Profiles.Source) + assert.Equal(t, []string{"production", "staging"}, cfg.Publish.Profiles.Include) +} + +func TestLoad_WithExplicitEmptyPublishProfileInclude(t *testing.T) { + dir := t.TempDir() + content := ` +organization: myorg +module: mymod +publish: + profiles: + include: [] +` + require.NoError(t, os.WriteFile(filepath.Join(dir, DefaultConfigFile), []byte(content), 0o644)) + + cfg, err := Load(dir) + require.NoError(t, err) + require.NotNil(t, cfg.Publish.Profiles.Include) + assert.Empty(t, cfg.Publish.Profiles.Include) } // --- EntryExcludes --- diff --git a/boot/deps/hub/dependency_handler.go b/boot/deps/hub/dependency_handler.go index a02a8ca70..e8da0f450 100644 --- a/boot/deps/hub/dependency_handler.go +++ b/boot/deps/hub/dependency_handler.go @@ -50,21 +50,23 @@ const ( ) type DependencyHandlerOptions struct { - Hub HubClient - Logger *zap.Logger - Resolver regapi.DependencyResolver - LockPath string - VendorDir string - ResolveTimeout time.Duration - DownloadTimeout time.Duration + Hub HubClient + Resolver regapi.DependencyResolver + Logger *zap.Logger + LockPath string + VendorDir string + WorkspaceReplacements []lock.Replacement + ResolveTimeout time.Duration + DownloadTimeout time.Duration } type DependencyHandler struct { hub HubClient + resolver regapi.DependencyResolver manifestCache *ManifestCache logger *zap.Logger - resolver regapi.DependencyResolver - lockPath string + lock *lock.Lock + replacements map[string]lock.Replacement vendorDir string resolveTimeout time.Duration downloadTimeout time.Duration @@ -121,26 +123,41 @@ func NewDependencyHandler(opts DependencyHandlerOptions) (*DependencyHandler, er } } - vendorDir := opts.VendorDir - if vendorDir == "" && lockPath != "" { - if lockObj, err := lock.New(lockPath); err == nil { - lockDir := filepath.Dir(lockObj.Path()) - vendorDir = filepath.Join(lockDir, lockObj.GetVendorPath()) + var lockObj *lock.Lock + if lockPath != "" { + var err error + lockObj, err = lock.New(lockPath, lock.WithWorkspaceReplacements(opts.WorkspaceReplacements)) + if err != nil { + return nil, err } } + + vendorDir := opts.VendorDir + if vendorDir == "" && lockObj != nil { + lockDir := filepath.Dir(lockObj.Path()) + vendorDir = filepath.Join(lockDir, lockObj.GetVendorPath()) + } if vendorDir == "" { vendorDir = filepath.Join(".wippy", "vendor") } + replacements := make(map[string]lock.Replacement) + if lockObj != nil { + for _, replacement := range lockObj.GetReplacements() { + replacements[replacement.From] = replacement + } + } + return &DependencyHandler{ hub: client, manifestCache: NewManifestCache(client), logger: logger, resolver: opts.Resolver, - lockPath: lockPath, vendorDir: vendorDir, resolveTimeout: opts.ResolveTimeout, downloadTimeout: opts.DownloadTimeout, + lock: lockObj, + replacements: replacements, }, nil } @@ -842,18 +859,14 @@ func validateRootDependencyComponents(deps []desiredDependency, operationID rega func (h *DependencyHandler) installedModuleVersions(ctx context.Context, transcoder payload.Transcoder, snapshot regapi.State) (map[string]string, error) { versions := snapshotModuleVersions(snapshot) - if h.lockPath == "" { - return versions, nil - } - lockObj, err := lock.New(h.lockPath) - if err != nil { + if h.lock == nil { return versions, nil } installedRoots, err := rootDependencyModules(ctx, transcoder, snapshot) if err != nil { return nil, err } - for _, mod := range lockObj.GetModules() { + for _, mod := range h.lock.GetModules() { if mod.Name == "" || mod.Version == "" { continue } @@ -1957,20 +1970,16 @@ func (h *DependencyHandler) freshDownloadInfo(ctx context.Context, mod ResolvedM } func (h *DependencyHandler) replacementPath(moduleName string) (string, bool) { - if h.lockPath == "" { - return "", false - } - lockObj, err := lock.New(h.lockPath) - if err != nil { - return "", false - } - replacement, ok := lockObj.GetReplacement(moduleName) + replacement, ok := h.replacements[moduleName] if !ok || strings.TrimSpace(replacement.To) == "" { return "", false } path := replacement.To if !filepath.IsAbs(path) { - path = filepath.Join(filepath.Dir(lockObj.Path()), path) + if h.lock == nil { + return "", false + } + path = filepath.Join(filepath.Dir(h.lock.Path()), path) } return path, true } @@ -2014,14 +2023,10 @@ func topLevelYAMLScalar(data []byte, key string) string { } func (h *DependencyHandler) shouldUnpackModules() bool { - if h.lockPath == "" { + if h.lock == nil { return false } - lockObj, err := lock.New(h.lockPath) - if err != nil { - return false - } - return lockObj.ShouldUnpackModules() + return h.lock.ShouldUnpackModules() } func (h *DependencyHandler) moduleUsesDirectoryMode(moduleName string) bool { @@ -2171,14 +2176,10 @@ func sha256FileHex(path string) (string, error) { } func (h *DependencyHandler) lockedModuleDigests() map[string]string { - if h.lockPath == "" { - return nil - } - lockObj, err := lock.New(h.lockPath) - if err != nil { + if h.lock == nil { return nil } - modules := lockObj.GetModules() + modules := h.lock.GetModules() if len(modules) == 0 { return nil } diff --git a/boot/deps/hub/resolution_hardening_test.go b/boot/deps/hub/resolution_hardening_test.go index 8a6aa490c..b53d0c5b3 100644 --- a/boot/deps/hub/resolution_hardening_test.go +++ b/boot/deps/hub/resolution_hardening_test.go @@ -13,6 +13,7 @@ import ( "github.com/wippyai/runtime/api/payload" regapi "github.com/wippyai/runtime/api/registry" "github.com/wippyai/runtime/boot/deps/graph" + "github.com/wippyai/runtime/boot/deps/lock" "github.com/wippyai/wapp" "go.uber.org/zap" ) @@ -25,6 +26,40 @@ func hardeningRoot(id, component, version string) regapi.Entry { } } +func TestReplacementResolutionRevalidatesCurrentTree(t *testing.T) { + tmpDir := t.TempDir() + lockPath := filepath.Join(tmpDir, lock.DefaultFilename) + replacementPath := filepath.Join(tmpDir, "local-http") + require.NoError(t, os.MkdirAll(replacementPath, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(replacementPath, "entry.yaml"), []byte("first"), 0o600)) + require.NoError(t, os.WriteFile(lockPath, []byte("directories:\n modules: .wippy\n src: ./src\n"), 0o600)) + + handler, err := NewDependencyHandler(DependencyHandlerOptions{ + Hub: &fakeHub{}, + Logger: zap.NewNop(), + LockPath: lockPath, + WorkspaceReplacements: []lock.Replacement{ + {From: "acme/http", To: replacementPath}, + }, + }) + require.NoError(t, err) + + digest, size, err := digestDirectoryTree(replacementPath) + require.NoError(t, err) + module := ResolvedModule{ + Org: "acme", + Name: "http", + Version: "v1.0.0", + Source: moduleSourceReplacementTreeV1, + Digest: digest, + SizeBytes: size, + } + require.True(t, handler.hasCurrentUnpackedModule(module)) + + require.NoError(t, os.WriteFile(filepath.Join(replacementPath, "entry.yaml"), []byte("changed"), 0o600)) + require.False(t, handler.hasCurrentUnpackedModule(module), "history must not trust stale entry metadata after replacement content changes") +} + func hardeningModuleEntry(id, module, version string) regapi.Entry { return regapi.Entry{ ID: regapi.ParseID(id), diff --git a/boot/deps/hub/unpack_effect.go b/boot/deps/hub/unpack_effect.go index 4e0e569b1..9d5f43126 100644 --- a/boot/deps/hub/unpack_effect.go +++ b/boot/deps/hub/unpack_effect.go @@ -8,6 +8,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "sync" regapi "github.com/wippyai/runtime/api/registry" @@ -123,7 +124,15 @@ func (h *DependencyHandler) buildModuleFilesystemEffect( } func (h *DependencyHandler) hasCurrentUnpackedModule(mod ResolvedModule) bool { - if !h.shouldUnpackModules() || mod.Source == moduleSourceReplacementTreeV1 { + if mod.Source == moduleSourceReplacementTreeV1 { + path, ok := h.replacementPath(mod.Org + "/" + mod.Name) + if !ok { + return false + } + digest, size, err := digestDirectoryTree(path) + return err == nil && strings.EqualFold(digest, mod.Digest) && (mod.SizeBytes == 0 || size == mod.SizeBytes) + } + if !h.shouldUnpackModules() { return true } name := graph.Name{Organization: mod.Org, Module: mod.Name} diff --git a/boot/deps/lock/errors.go b/boot/deps/lock/errors.go index f7ae8baa6..9af132ca6 100644 --- a/boot/deps/lock/errors.go +++ b/boot/deps/lock/errors.go @@ -42,6 +42,10 @@ func NewReplacementPathNotExistError(path string) apierror.Error { return apierror.New(apierror.NotFound, fmt.Sprintf("replacement path %q does not exist", path)) } +func NewReplacementPathNotDirectoryError(path string) apierror.Error { + return apierror.New(apierror.Invalid, fmt.Sprintf("replacement path %q is not a directory", path)) +} + func NewCheckReplacementPathError(path string, cause error) apierror.Error { return apierror.New(apierror.Internal, fmt.Sprintf("failed to check replacement path %q", path)).WithCause(cause) } diff --git a/boot/deps/lock/lock.go b/boot/deps/lock/lock.go index b92a8c528..9b17f6387 100644 --- a/boot/deps/lock/lock.go +++ b/boot/deps/lock/lock.go @@ -16,13 +16,27 @@ const DefaultFilename = "wippy.lock" // Lock represents a lock file with operations for reading, writing, and querying. type Lock struct { - path string - data File + path string + workspaceOverlay []Replacement + data File +} + +// Option configures local, non-persistent lock behavior. +type Option func(*Lock) error + +// WithWorkspaceReplacements overlays replacements selected from the effective +// .wippy.yaml workspace configuration. They affect loading only and are never +// serialized into wippy.lock. +func WithWorkspaceReplacements(replacements []Replacement) Option { + return func(l *Lock) error { + l.workspaceOverlay = append([]Replacement(nil), replacements...) + return nil + } } // New creates a new Lock instance from the given path. // If the file exists, it loads the content. Otherwise, creates an empty lock with default directories. -func New(path string) (*Lock, error) { +func New(path string, opts ...Option) (*Lock, error) { absPath, err := filepath.Abs(path) if err != nil { return nil, NewResolveAbsolutePathError(err) @@ -48,9 +62,37 @@ func New(path string) (*Lock, error) { return nil, NewStatLockFileError(err) } + for _, opt := range opts { + if opt != nil { + if err := opt(lock); err != nil { + return nil, err + } + } + } + return lock, nil } +// effectiveReplacements overlays workspace replacements on the portable lock +// set. Order is stable: existing entries are updated in place and workspace-only +// entries are appended. +func (l *Lock) effectiveReplacements() []Replacement { + capacity := len(l.data.Replacements) + len(l.workspaceOverlay) + index := make(map[string]int, capacity) + merged := make([]Replacement, 0, capacity) + for _, layer := range [][]Replacement{l.data.Replacements, l.workspaceOverlay} { + for _, replacement := range layer { + if i, ok := index[replacement.From]; ok { + merged[i] = replacement + continue + } + index[replacement.From] = len(merged) + merged = append(merged, replacement) + } + } + return merged +} + // Read loads the lock file from disk. func (l *Lock) Read() error { data, err := os.ReadFile(l.path) @@ -158,10 +200,10 @@ func (l *Lock) GetModules() []Module { return l.data.Modules } -// GetReplacement retrieves a replacement by from field. +// GetReplacement retrieves a replacement by from field from the effective set. // Returns the replacement and true if found, zero value and false otherwise. func (l *Lock) GetReplacement(from string) (Replacement, bool) { - for _, r := range l.data.Replacements { + for _, r := range l.effectiveReplacements() { if r.From == from { return r, true } @@ -192,8 +234,15 @@ func (l *Lock) RemoveReplacement(from string) { l.data.Replacements = filtered } -// GetReplacements returns all replacements. +// GetReplacements returns the effective tracked and workspace set. func (l *Lock) GetReplacements() []Replacement { + return l.effectiveReplacements() +} + +// GetTrackedReplacements returns only replacements persisted in the lock file. +// Portable lock regeneration must use this view so machine-local state cannot +// leak into wippy.lock. +func (l *Lock) GetTrackedReplacements() []Replacement { return l.data.Replacements } @@ -314,7 +363,8 @@ type ModuleLoadPath struct { // Replacement paths carry Module from replacement "from" and empty Version. func (l *Lock) GetModuleLoadPaths() []ModuleLoadPath { lockDir := filepath.Dir(l.path) - paths := make([]ModuleLoadPath, 0, 1+len(l.data.Replacements)+len(l.data.Modules)) + replacements := l.effectiveReplacements() + paths := make([]ModuleLoadPath, 0, 1+len(replacements)+len(l.data.Modules)) if l.data.Directories.Src != "" { paths = append(paths, ModuleLoadPath{ @@ -322,7 +372,9 @@ func (l *Lock) GetModuleLoadPaths() []ModuleLoadPath { }) } - for _, repl := range l.data.Replacements { + replaced := make(map[string]struct{}, len(replacements)) + for _, repl := range replacements { + replaced[repl.From] = struct{}{} if repl.To != "" { root := ResolveLockPath(lockDir, repl.To) path := moduleEntryLoadPath(root) @@ -338,7 +390,7 @@ func (l *Lock) GetModuleLoadPaths() []ModuleLoadPath { fullVendorDir := ResolveLockPath(lockDir, vendorDir) for _, mod := range l.data.Modules { - if _, hasReplacement := l.GetReplacement(mod.Name); hasReplacement { + if _, hasReplacement := replaced[mod.Name]; hasReplacement { continue } diff --git a/boot/deps/lock/validate.go b/boot/deps/lock/validate.go index 61840ee6f..e2bc6d6e8 100644 --- a/boot/deps/lock/validate.go +++ b/boot/deps/lock/validate.go @@ -21,7 +21,7 @@ func Validate(l *Lock) error { } } - if err := ValidateReplacements(l.path, l.data.Replacements); err != nil { + if err := ValidateReplacements(l.path, l.GetReplacements()); err != nil { return NewInvalidReplacementsError(err) } @@ -56,12 +56,16 @@ func ValidateReplacements(lockPath string, replacements []Replacement) error { replacementPath := ResolveLockPath(lockDir, r.To) - if _, err := os.Stat(replacementPath); err != nil { + info, err := os.Stat(replacementPath) + if err != nil { if os.IsNotExist(err) { return NewReplacementPathNotExistError(r.To) } return NewCheckReplacementPathError(r.To, err) } + if !info.IsDir() { + return NewReplacementPathNotDirectoryError(r.To) + } } return nil diff --git a/boot/deps/lock/validate_test.go b/boot/deps/lock/validate_test.go index 4ed348ce8..8848b4ce8 100644 --- a/boot/deps/lock/validate_test.go +++ b/boot/deps/lock/validate_test.go @@ -150,6 +150,18 @@ func TestValidateReplacements(t *testing.T) { t.Errorf("expected no error, got %v", err) } }) + + t.Run("existing file fails", func(t *testing.T) { + localFile := filepath.Join(tmpDir, "not-a-directory") + if err := os.WriteFile(localFile, []byte("not a module"), 0o600); err != nil { + t.Fatalf("create local file: %v", err) + } + + replacements := []Replacement{{From: "wippy/test", To: localFile}} + if err := ValidateReplacements(lockPath, replacements); err == nil { + t.Error("expected a replacement file to be rejected") + } + }) } func TestValidateModuleName(t *testing.T) { diff --git a/boot/deps/lock/workspace.go b/boot/deps/lock/workspace.go new file mode 100644 index 000000000..6c6267183 --- /dev/null +++ b/boot/deps/lock/workspace.go @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: MPL-2.0 + +package lock + +import ( + "fmt" + "path/filepath" + "sort" + "strings" + + "github.com/wippyai/runtime/api/boot" +) + +const workspaceReplacementPrefix = "replacements." + +// WithWorkspaceConfig applies the effective .wippy.yaml workspace settings to +// a lock without making them part of the persisted lock data. +func WithWorkspaceConfig(cfg boot.Config) Option { + return func(l *Lock) error { + replacements, err := WorkspaceReplacements(cfg) + if err != nil { + return err + } + l.workspaceOverlay = append([]Replacement(nil), replacements...) + return nil + } +} + +// WorkspaceReplacements reads the effective workspace replacement map from +// .wippy.yaml after profile application. A null or empty value disables a +// workspace replacement inherited from an earlier config/profile layer. +// +// workspace: +// +// replacements: +// acme/http: ../http +func WorkspaceReplacements(cfg boot.Config) ([]Replacement, error) { + if cfg == nil { + return nil, nil + } + workspace := cfg.Sub("workspace") + configDir := cfg.GetString("boot.config_dir", "") + keys := workspace.Keys() + sort.Strings(keys) + + replacements := make([]Replacement, 0, len(keys)) + for _, key := range keys { + if !strings.HasPrefix(key, workspaceReplacementPrefix) { + continue + } + module := strings.TrimPrefix(key, workspaceReplacementPrefix) + if module == "" { + return nil, fmt.Errorf("workspace replacement has an empty module name") + } + value, ok := workspace.Get(key) + if !ok || value == nil { + continue + } + path, ok := value.(string) + if !ok { + return nil, fmt.Errorf("workspace replacement %q path must be a string or null", module) + } + path = strings.TrimSpace(path) + if path == "" { + continue + } + if configDir != "" && !filepath.IsAbs(path) { + path = filepath.Join(configDir, path) + } + replacements = append(replacements, Replacement{From: module, To: path}) + } + return replacements, nil +} diff --git a/boot/deps/lock/workspace_test.go b/boot/deps/lock/workspace_test.go new file mode 100644 index 000000000..b317ea46b --- /dev/null +++ b/boot/deps/lock/workspace_test.go @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: MPL-2.0 + +package lock + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + "github.com/wippyai/runtime/api/boot" +) + +func TestWorkspaceReplacementsUsesConfigDirectory(t *testing.T) { + configDir := t.TempDir() + cfg := boot.NewConfig( + boot.WithSection("boot", map[string]any{"config_dir": configDir}), + boot.WithSection("workspace", map[string]any{ + "replacements.acme/http": "../http", + "replacements.acme/disabled": nil, + }), + ) + + replacements, err := WorkspaceReplacements(cfg) + require.NoError(t, err) + require.Equal(t, []Replacement{{ + From: "acme/http", + To: filepath.Join(configDir, "../http"), + }}, replacements) +} + +func TestWorkspaceReplacementsRejectsNonStringPath(t *testing.T) { + cfg := boot.NewConfig(boot.WithSection("workspace", map[string]any{ + "replacements.acme/http": true, + })) + + _, err := WorkspaceReplacements(cfg) + require.ErrorContains(t, err, "path must be a string or null") +} + +func TestWorkspaceReplacementWinsOverTracked(t *testing.T) { + tmpDir := t.TempDir() + lockPath := filepath.Join(tmpDir, DefaultFilename) + require.NoError(t, os.WriteFile(lockPath, []byte(`directories: + modules: .wippy + src: ./src +replacements: + - from: acme/http + to: ./tracked +`), 0o600)) + + lockObj, err := New(lockPath, WithWorkspaceReplacements([]Replacement{ + {From: "acme/http", To: "./workspace"}, + })) + require.NoError(t, err) + replacement, ok := lockObj.GetReplacement("acme/http") + require.True(t, ok) + require.Equal(t, "./workspace", replacement.To) + require.Len(t, lockObj.GetReplacements(), 1) +} diff --git a/cmd/internal/bootconfig/load.go b/cmd/internal/bootconfig/load.go index 2ce1a1ba5..f9cc43e9e 100644 --- a/cmd/internal/bootconfig/load.go +++ b/cmd/internal/bootconfig/load.go @@ -3,6 +3,7 @@ package bootconfig import ( + "fmt" "os" "regexp" @@ -17,14 +18,35 @@ type config struct { var osEnvRefPattern = regexp.MustCompile(`\$\{env:([A-Za-z_][A-Za-z0-9_]*)\}`) +// LoadFiles loads required configuration files in order. Later files override +// matching leaves from earlier files. +func LoadFiles(paths []string) (boot.Config, error) { + var merged boot.Config + for _, path := range paths { + if path == "" { + return nil, fmt.Errorf("load config file: path is empty") + } + cfg, err := load(path, true) + if err != nil { + return nil, fmt.Errorf("load config file %s: %w", path, err) + } + merged = Merge(merged, cfg) + } + return merged, nil +} + func Load(path string) (boot.Config, error) { + return load(path, false) +} + +func load(path string, required bool) (boot.Config, error) { if path == "" { return nil, nil //nolint:nilnil // empty path means no config } data, err := os.ReadFile(path) if err != nil { - if os.IsNotExist(err) { + if os.IsNotExist(err) && !required { return nil, nil //nolint:nilnil // missing file means no config } return nil, NewReadConfigFileError(err) diff --git a/cmd/internal/bootconfig/load_test.go b/cmd/internal/bootconfig/load_test.go index 73d3702d2..fc38f6b21 100644 --- a/cmd/internal/bootconfig/load_test.go +++ b/cmd/internal/bootconfig/load_test.go @@ -268,6 +268,92 @@ registry: }) } +func TestLoadFilesMergesInOrder(t *testing.T) { + tmpDir := t.TempDir() + basePath := filepath.Join(tmpDir, "base.yaml") + devPath := filepath.Join(tmpDir, "dev.yaml") + workspacePath := filepath.Join(tmpDir, "workspace.yaml") + require.NoError(t, os.WriteFile(basePath, []byte(`version: "1.0" +logger: + level: info + encoding: json +network: + name: shared +profiles: + dev: + logger: + encoding: console +`), 0o600)) + require.NoError(t, os.WriteFile(devPath, []byte(`version: "1.0" +logger: + level: debug +network: + bind: 127.0.0.1 +profiles: + dev: + network: + name: docker +`), 0o600)) + require.NoError(t, os.WriteFile(workspacePath, []byte(`version: "1.0" +workspace: + replacements: + acme/http: ../http +profiles: + dev: + logger: + level: trace +`), 0o600)) + + cfg, err := LoadFiles([]string{basePath, devPath, workspacePath}) + require.NoError(t, err) + require.Equal(t, "debug", cfg.GetString("logger.level", "")) + require.Equal(t, "json", cfg.GetString("logger.encoding", "")) + require.Equal(t, "shared", cfg.GetString("network.name", "")) + require.Equal(t, "127.0.0.1", cfg.GetString("network.bind", "")) + require.Equal(t, "../http", cfg.GetString("workspace.replacements.acme/http", "")) + require.Equal(t, "console", cfg.GetString("profiles.dev.logger.encoding", "")) + require.Equal(t, "trace", cfg.GetString("profiles.dev.logger.level", "")) + require.Equal(t, "docker", cfg.GetString("profiles.dev.network.name", "")) +} + +func TestLoadFilesRequiresEveryPath(t *testing.T) { + tmpDir := t.TempDir() + basePath := filepath.Join(tmpDir, "base.yaml") + missingPath := filepath.Join(tmpDir, "missing.yaml") + require.NoError(t, os.WriteFile(basePath, []byte("version: \"1.0\"\n"), 0o600)) + + cfg, err := LoadFiles([]string{basePath, missingPath}) + require.Error(t, err) + require.ErrorContains(t, err, missingPath) + require.ErrorContains(t, err, "failed to read config file") + require.Nil(t, cfg) +} + +func TestLoadFilesIdentifiesInvalidFile(t *testing.T) { + tmpDir := t.TempDir() + basePath := filepath.Join(tmpDir, "base.yaml") + invalidPath := filepath.Join(tmpDir, "invalid.yaml") + require.NoError(t, os.WriteFile(basePath, []byte("version: \"1.0\"\n"), 0o600)) + require.NoError(t, os.WriteFile(invalidPath, []byte("logger:\n level: debug\n"), 0o600)) + + cfg, err := LoadFiles([]string{basePath, invalidPath}) + require.ErrorContains(t, err, invalidPath) + require.ErrorContains(t, err, "missing version field") + require.Nil(t, cfg) +} + +func TestLoadFilesEmpty(t *testing.T) { + cfg, err := LoadFiles(nil) + require.NoError(t, err) + require.Nil(t, cfg) +} + +func TestLoadFilesRejectsEmptyExplicitPath(t *testing.T) { + cfg, err := LoadFiles([]string{""}) + require.ErrorContains(t, err, "path is empty") + require.Nil(t, cfg) +} + func TestBuildBootConfig(t *testing.T) { t.Run("builds config from flat sections", func(t *testing.T) { sections := map[string]map[string]any{ diff --git a/cmd/internal/entries/loader.go b/cmd/internal/entries/loader.go index b9c077f7d..2cf03797e 100644 --- a/cmd/internal/entries/loader.go +++ b/cmd/internal/entries/loader.go @@ -43,10 +43,11 @@ func LoadFromLockFile(ctx context.Context, logger *zap.Logger) error { logger.Info("loading entries from lock file", zap.String("path", lockPath)) - lockObj, err := lock.New(lockPath) + lockObj, err := lock.New(lockPath, lock.WithWorkspaceConfig(boot.GetConfig(ctx))) if err != nil { return NewLoadLockFileError(fmt.Errorf("lock file %s: %w", lockPath, err)) } + warnTrackedLockReplacements(lockObj, logger) if err := lock.Validate(lockObj); err != nil { return NewInvalidLockFileError(fmt.Errorf("lock file %s: %w", lockObj.Path(), err)) @@ -87,10 +88,11 @@ func LoadFromLockFile(ctx context.Context, logger *zap.Logger) error { // EnsureModulesInstalled checks if modules from the lock file are installed, // and auto-installs them if missing using the hub client. func EnsureModulesInstalled(ctx context.Context, lockPath string, logger *zap.Logger) error { - lockObj, err := lock.New(lockPath) + lockObj, err := lock.New(lockPath, lock.WithWorkspaceConfig(boot.GetConfig(ctx))) if err != nil { return NewLoadLockFileError(fmt.Errorf("lock file %s: %w", lockPath, err)) } + warnTrackedLockReplacements(lockObj, logger) if err := lock.Validate(lockObj); err != nil { return NewInvalidLockFileError(fmt.Errorf("lock file %s: %w", lockObj.Path(), err)) @@ -99,6 +101,14 @@ func EnsureModulesInstalled(ctx context.Context, lockPath string, logger *zap.Lo return ensureModulesInstalledFromLock(ctx, lockObj, logger) } +func warnTrackedLockReplacements(lockObj *lock.Lock, logger *zap.Logger) { + if lockObj == nil || logger == nil || len(lockObj.GetTrackedReplacements()) == 0 { + return + } + logger.Warn("DEPRECATED: lock-file replacements are loaded only for compatibility; move them to workspace.replacements in a runtime config file", + zap.String("lock_file", lockObj.Path())) +} + func ensureModulesInstalledFromLock(ctx context.Context, lockObj *lock.Lock, logger *zap.Logger) error { modules := lockObj.GetModules() if len(modules) == 0 { diff --git a/cmd/wippy/cmd/config_test.go b/cmd/wippy/cmd/config_test.go new file mode 100644 index 000000000..46523dffc --- /dev/null +++ b/cmd/wippy/cmd/config_test.go @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: MPL-2.0 + +package cmd + +import "testing" + +func setTestConfigFiles(t *testing.T, paths ...string) { + t.Helper() + previous := configFiles + configFiles = append([]string(nil), paths...) + t.Cleanup(func() { + configFiles = previous + }) +} + +func TestRuntimeConfigPaths(t *testing.T) { + t.Run("uses optional default when flag is absent", func(t *testing.T) { + setTestConfigFiles(t) + paths := runtimeConfigPaths() + if len(paths) != 1 || paths[0] != defaultConfigFile { + t.Fatalf("runtimeConfigPaths() = %v, want [%q]", paths, defaultConfigFile) + } + }) + + t.Run("preserves every explicit path in order", func(t *testing.T) { + setTestConfigFiles(t, "base.yaml", "dev.yaml", "workspace.yaml") + paths := runtimeConfigPaths() + want := []string{"base.yaml", "dev.yaml", "workspace.yaml"} + if len(paths) != len(want) { + t.Fatalf("runtimeConfigPaths() = %v, want %v", paths, want) + } + for i := range want { + if paths[i] != want[i] { + t.Fatalf("runtimeConfigPaths()[%d] = %q, want %q", i, paths[i], want[i]) + } + } + + paths[0] = "mutated.yaml" + if configFiles[0] != "base.yaml" { + t.Fatal("runtimeConfigPaths returned the mutable global slice") + } + }) +} + +func TestRuntimeConfigFlagIsRepeatable(t *testing.T) { + flag := rootCmd.PersistentFlags().Lookup("config") + if flag == nil { + t.Fatal("missing --config flag") + } + if got := flag.Value.Type(); got != "stringArray" { + t.Fatalf("--config flag type = %q, want stringArray", got) + } +} + +func TestPublishConfigFlagRemainsManifestDirectory(t *testing.T) { + flag := publishCmd.Flags().Lookup("config") + if flag == nil { + t.Fatal("missing publish --config flag") + } + if got := flag.Value.Type(); got != "string" { + t.Fatalf("publish --config flag type = %q, want string", got) + } +} diff --git a/cmd/wippy/cmd/init.go b/cmd/wippy/cmd/init.go index 7b2282f51..a4b76125c 100644 --- a/cmd/wippy/cmd/init.go +++ b/cmd/wippy/cmd/init.go @@ -69,7 +69,6 @@ func runInit(cmd *cobra.Command, _ []string) error { if err := lockObj.Write(); err != nil { return NewWriteLockFileError(fmt.Errorf("lock file %s: %w", lockObj.Path(), err)) } - logger.Info("lock file initialized successfully") return nil } diff --git a/cmd/wippy/cmd/install.go b/cmd/wippy/cmd/install.go index 98ec4f2a0..5bc70f02b 100644 --- a/cmd/wippy/cmd/install.go +++ b/cmd/wippy/cmd/install.go @@ -44,8 +44,8 @@ Downloads and installs all modules specified in the lock file. If the lock file is missing, runs 'wippy init' followed by 'wippy update'. Modules are installed to the vendor directory specified in the lock file. -Local replacements are validated by the lock file and skipped by install because -the runtime loads those modules directly from their replacement paths. +Workspace replacements selected from the merged runtime config are validated and skipped by +install because the runtime loads those modules directly from local source. When module names are provided as arguments, only those modules are processed. Use with --refresh to re-download modules when cache might be stale: @@ -62,6 +62,8 @@ func init() { installCmd.Flags().Bool("force", false, "alias for --refresh") installCmd.Flags().Bool("repair", false, "alias for --refresh") installCmd.Flags().String("registry", "", "registry URL (default: from credentials)") + installCmd.Flags().StringArray("profile", nil, "apply a workspace profile from the merged runtime config (repeatable, applied in order)") + installCmd.Flags().StringArray("set", nil, "override a merged runtime config value (format: section.path=value, repeatable)") } func runInstall(cmd *cobra.Command, args []string) error { @@ -71,6 +73,10 @@ func runInstall(cmd *cobra.Command, args []string) error { } logger := app.Logger.Named("install") + runtimeCfg, err := loadRuntimeConfig(cmd, logger) + if err != nil { + return err + } lockPath, _ := cmd.Flags().GetString("lock-file") registryURL, _ := cmd.Flags().GetString("registry") @@ -87,7 +93,7 @@ func runInstall(cmd *cobra.Command, args []string) error { logger.Info("installing dependencies", zap.String("lock_file", lockPath)) - lockObj, err := lock.New(lockPath) + lockObj, err := newConfiguredLock(lockPath, runtimeCfg, logger) if err != nil { return NewLoadLockFileError(fmt.Errorf("lock file %s: %w", lockPath, err)) } diff --git a/cmd/wippy/cmd/lint.go b/cmd/wippy/cmd/lint.go index b98371c77..4669fc809 100644 --- a/cmd/wippy/cmd/lint.go +++ b/cmd/wippy/cmd/lint.go @@ -22,6 +22,7 @@ import ( "github.com/wippyai/go-lua/compiler/parse" "github.com/wippyai/go-lua/types/diag" "github.com/wippyai/go-lua/types/io" + "github.com/wippyai/runtime/api/boot" regapi "github.com/wippyai/runtime/api/registry" luaapi "github.com/wippyai/runtime/api/runtime/lua" bootpkg "github.com/wippyai/runtime/boot" @@ -76,6 +77,8 @@ func init() { lintCmd.Flags().Int("limit", 0, "limit number of diagnostics shown (0 = unlimited)") lintCmd.Flags().Bool("rules", false, "enable lint rules (style and quality warnings)") lintCmd.Flags().Bool("cache-reset", false, "clear lua cache before linting") + lintCmd.Flags().StringArray("profile", nil, "apply a workspace profile from the merged runtime config (repeatable, applied in order)") + lintCmd.Flags().StringArray("set", nil, "override a merged runtime config value (format: section.path=value, repeatable)") } // ---------------------------------------------------------------------------- @@ -364,14 +367,19 @@ func bootstrapLintContext() (ctx context.Context, loader *bootpkg.Loader, err er func loadLuaEntries(cmd *cobra.Command, lockFile string, nsFilters []string) ([]regapi.Entry, map[regapi.ID]bool, error) { logger := zap.NewNop() - lockPath, lockObj, err := loadValidatedLock(".", lockFile) + app, err := appinit.Init(cmd.Context(), verbose, veryVerbose, console, silentLogs, appStartTime) + if err != nil { + return nil, nil, NewInitAppError(err) + } + runtimeCfg, err := loadRuntimeConfig(cmd, logger) if err != nil { return nil, nil, err } + boot.WithConfig(app.Ctx, runtimeCfg) - app, err := appinit.Init(cmd.Context(), verbose, veryVerbose, console, silentLogs, appStartTime) + lockPath, lockObj, err := loadValidatedLock(".", lockFile, runtimeCfg, logger) if err != nil { - return nil, nil, NewInitAppError(err) + return nil, nil, err } allEntries, err := ensureModulesAndLoadEntries(app.Ctx, lockPath, lockObj, logger, false) diff --git a/cmd/wippy/cmd/lock_helpers.go b/cmd/wippy/cmd/lock_helpers.go index 32ccf1888..7eeb54ed7 100644 --- a/cmd/wippy/cmd/lock_helpers.go +++ b/cmd/wippy/cmd/lock_helpers.go @@ -6,19 +6,20 @@ import ( "context" "fmt" + "github.com/wippyai/runtime/api/boot" regapi "github.com/wippyai/runtime/api/registry" "github.com/wippyai/runtime/boot/deps/lock" "github.com/wippyai/runtime/cmd/internal/entries" "go.uber.org/zap" ) -func loadValidatedLock(folderPath, lockFile string) (string, *lock.Lock, error) { +func loadValidatedLock(folderPath, lockFile string, cfg boot.Config, logger *zap.Logger) (string, *lock.Lock, error) { lockPath, err := lock.Find(folderPath, lockFile) if err != nil { return "", nil, NewLockFileNotFoundError(err) } - lockObj, err := lock.New(lockPath) + lockObj, err := newConfiguredLock(lockPath, cfg, logger) if err != nil { return lockPath, nil, NewLoadLockFileError(fmt.Errorf("lock file %s: %w", lockPath, err)) } diff --git a/cmd/wippy/cmd/logger_helpers.go b/cmd/wippy/cmd/logger_helpers.go index 2079c99a2..5c33a0ae8 100644 --- a/cmd/wippy/cmd/logger_helpers.go +++ b/cmd/wippy/cmd/logger_helpers.go @@ -3,7 +3,6 @@ package cmd import ( - "github.com/wippyai/runtime/cmd/internal/bootconfig" clilogger "github.com/wippyai/runtime/cmd/internal/logger" "go.uber.org/zap" ) @@ -13,7 +12,7 @@ import ( // // Logger encoding resolution (highest precedence first): // 1. --console / -c flag (humanized console) -// 2. logger.encoding from .wippy.yaml (canonical config) +// 2. logger.encoding from the merged runtime config // 3. development console default func createCommandLogger() (*zap.Logger, error) { return clilogger.CreateLogger(clilogger.Config{ @@ -31,11 +30,7 @@ func createCommandLogger() (*zap.Logger, error) { // JSON from the first line. Returns "" on any failure — the caller // falls back to the humanized development encoder. func preloadLoggerEncoding() string { - path := configFile - if path == "" { - path = defaultConfigFile - } - cfg, err := bootconfig.Load(path) + cfg, err := loadRuntimeConfigFiles() if err != nil || cfg == nil { return "" } diff --git a/cmd/wippy/cmd/pack.go b/cmd/wippy/cmd/pack.go index 2e6d114ca..f4590977b 100644 --- a/cmd/wippy/cmd/pack.go +++ b/cmd/wippy/cmd/pack.go @@ -59,7 +59,7 @@ func init() { packCmd.Flags().StringSlice("exclude-ns", nil, "exclude entries by namespace patterns (e.g., app.**,test.*)") packCmd.Flags().StringSlice("exclude", nil, "exclude entries by ID patterns (e.g., app:internal,test:*)") packCmd.Flags().StringSlice("bytecode", nil, "compile Lua to bytecode (** for all, or patterns: app:**, lib:utils)") - packCmd.Flags().StringArray("profile", nil, "Apply a runtime profile from .wippy.yaml before packing (repeatable, applied in order)") + packCmd.Flags().StringArray("profile", nil, "apply a profile from the merged runtime config before packing (repeatable, applied in order)") } type packStage string @@ -337,6 +337,16 @@ func runPack(cmd *cobra.Command, args []string) error { if err != nil { return NewInitAppError(err) } + bootCfg, err := loadBootConfig() + if err != nil { + return fmt.Errorf("load runtime config: %w", err) + } + profiles, _ := cmd.Flags().GetStringArray("profile") + bootCfg, err = applyRuntimeProfilesAndVariables(bootCfg, profiles) + if err != nil { + return fmt.Errorf("apply runtime profiles: %w", err) + } + boot.WithConfig(app.Ctx, bootCfg) outputFile := args[0] lockFile, _ := cmd.Flags().GetString("lock-file") @@ -418,7 +428,8 @@ func performPack(cmd *cobra.Command, args []string, app *appinit.Context, p *tea return NewLockFileNotFoundError(err) } - lockObj, err := lock.New(lockPath) + bootCfg := boot.GetConfig(app.Ctx) + lockObj, err := newConfiguredLock(lockPath, bootCfg, logger) if err != nil { return NewLoadLockFileError(fmt.Errorf("lock file %s: %w", lockPath, err)) } @@ -448,16 +459,8 @@ func performPack(cmd *cobra.Command, args []string, app *appinit.Context, p *tea p.Send(progressMsg{stage: stagePipeline, percent: 0.5, status: "Executing pipeline stages..."}) - // Load .wippy.yaml config so Override stages apply overrides to packed entries - bootCfg, err := loadBootConfig() - if err != nil { - return fmt.Errorf("load boot config: %w", err) - } - profiles, _ := cmd.Flags().GetStringArray("profile") - bootCfg, err = applyRuntimeProfilesAndVariables(bootCfg, profiles) - if err != nil { - return fmt.Errorf("apply runtime profiles: %w", err) - } + // Apply entry overrides from the same effective profile that selected + // workspace replacements. if bootCfg != nil { boot.WithConfig(app.Ctx, bootCfg) } @@ -865,7 +868,7 @@ func parseMetadataValue(value string) any { } func runListMode(app *appinit.Context, lockPath, _ string) error { - lockObj, err := lock.New(lockPath) + lockObj, err := newConfiguredLock(lockPath, boot.GetConfig(app.Ctx), app.Logger) if err != nil { return NewLoadLockFileError(fmt.Errorf("lock file %s: %w", lockPath, err)) } diff --git a/cmd/wippy/cmd/publish_profiles.go b/cmd/wippy/cmd/publish_profiles.go index b62f76540..018849078 100644 --- a/cmd/wippy/cmd/publish_profiles.go +++ b/cmd/wippy/cmd/publish_profiles.go @@ -44,7 +44,7 @@ func addPublishedRuntimeProfileMetadata(metadata attrs.Bag, configDir string, pr return nil } - profiles, err := runtimeProfilesFromConfig(cfg) + profiles, err := runtimeProfilesFromConfig(cfg, profileCfg.Include) if err != nil { return err } @@ -70,10 +70,10 @@ func addPublishedRuntimeProfileMetadata(metadata attrs.Bag, configDir string, pr return nil } -func runtimeProfilesFromConfig(cfg boot.Config) (map[string]any, error) { - profiles := make(map[string]any) +func runtimeProfilesFromConfig(cfg boot.Config, include []string) (map[string]any, error) { + allProfiles := make(map[string]any) if cfg == nil { - return profiles, nil + return allProfiles, nil } for _, key := range cfg.Keys() { @@ -90,11 +90,16 @@ func runtimeProfilesFromConfig(cfg boot.Config) (map[string]any, error) { if !ok || section == "" || subkey == "" { return nil, fmt.Errorf("invalid runtime profile key %q", key) } + // Workspace configuration is machine-local by definition. It must never + // enter package metadata, even when the containing profile is published. + if section == "workspace" { + continue + } - profileMap, ok := profiles[profileName].(map[string]any) + profileMap, ok := allProfiles[profileName].(map[string]any) if !ok { profileMap = make(map[string]any) - profiles[profileName] = profileMap + allProfiles[profileName] = profileMap } sectionMap, ok := profileMap[section].(map[string]any) @@ -107,6 +112,27 @@ func runtimeProfilesFromConfig(cfg boot.Config) (map[string]any, error) { sectionMap[subkey] = value } + if include == nil { + return allProfiles, nil + } + + profiles := make(map[string]any, len(include)) + seen := make(map[string]struct{}, len(include)) + for _, name := range include { + name = strings.TrimSpace(name) + if name == "" { + return nil, fmt.Errorf("publish.profiles.include contains an empty profile name") + } + if _, duplicate := seen[name]; duplicate { + continue + } + seen[name] = struct{}{} + profile, ok := allProfiles[name] + if !ok { + return nil, fmt.Errorf("publish profile %q not found in runtime profile source", name) + } + profiles[name] = profile + } return profiles, nil } diff --git a/cmd/wippy/cmd/publish_profiles_test.go b/cmd/wippy/cmd/publish_profiles_test.go index 43ef038b7..25c386b11 100644 --- a/cmd/wippy/cmd/publish_profiles_test.go +++ b/cmd/wippy/cmd/publish_profiles_test.go @@ -79,6 +79,114 @@ profiles: require.Contains(t, profiles, "public") } +func TestAddPublishedRuntimeProfileMetadataIncludesOnlySelectedProfiles(t *testing.T) { + dir := t.TempDir() + writeRuntimeProfileConfig(t, dir, `.wippy.yaml`, `version: "1.0" +profiles: + local: + logger: + level: debug + production: + logger: + level: info +`) + + metadata := attrs.Bag{} + require.NoError(t, addPublishedRuntimeProfileMetadata(metadata, dir, config.PublishProfilesConfig{ + Include: []string{"production"}, + })) + + profiles := requireMap(t, requireMap(t, metadata["runtime"])["profiles"]) + require.NotContains(t, profiles, "local") + require.Contains(t, profiles, "production") +} + +func TestPublishedRuntimeProfilesIgnoreRuntimeConfigSelection(t *testing.T) { + dir := t.TempDir() + writeRuntimeProfileConfig(t, dir, `.wippy.yaml`, `version: "1.0" +profiles: + production: + logger: + level: info +`) + overlayDir := t.TempDir() + writeRuntimeProfileConfig(t, overlayDir, `developer.yaml`, `version: "1.0" +profiles: + production: + logger: + level: debug + workspace: + workspace: + replacements: + acme/http: ../http +`) + setTestConfigFiles(t, filepath.Join(dir, ".wippy.yaml"), filepath.Join(overlayDir, "developer.yaml")) + + metadata := attrs.Bag{} + require.NoError(t, addPublishedRuntimeProfileMetadata(metadata, dir, config.PublishProfilesConfig{})) + + profiles := requireMap(t, requireMap(t, metadata["runtime"])["profiles"]) + require.NotContains(t, profiles, "workspace") + production := requireMap(t, profiles["production"]) + require.Equal(t, "info", requireMap(t, production["logger"])["level"]) +} + +func TestAddPublishedRuntimeProfileMetadataEmptyIncludePublishesNoProfiles(t *testing.T) { + dir := t.TempDir() + writeRuntimeProfileConfig(t, dir, `.wippy.yaml`, `version: "1.0" +profiles: + local: + logger: + level: debug +`) + + metadata := attrs.Bag{} + require.NoError(t, addPublishedRuntimeProfileMetadata(metadata, dir, config.PublishProfilesConfig{ + Include: []string{}, + })) + require.NotContains(t, metadata, "runtime") +} + +func TestAddPublishedRuntimeProfileMetadataNeverExportsWorkspace(t *testing.T) { + dir := t.TempDir() + writeRuntimeProfileConfig(t, dir, `.wippy.yaml`, `version: "1.0" +workspace: + replacements: + acme/http: ../http +profiles: + local: + workspace: + replacements: + acme/http: ../local-http + logger: + level: debug +`) + + metadata := attrs.Bag{} + require.NoError(t, addPublishedRuntimeProfileMetadata(metadata, dir, config.PublishProfilesConfig{})) + + runtime := requireMap(t, metadata["runtime"]) + require.NotContains(t, runtime, "workspace") + local := requireMap(t, requireMap(t, runtime["profiles"])["local"]) + require.NotContains(t, local, "workspace") + require.Equal(t, "debug", requireMap(t, local["logger"])["level"]) +} + +func TestAddPublishedRuntimeProfileMetadataRejectsUnknownIncludedProfile(t *testing.T) { + dir := t.TempDir() + writeRuntimeProfileConfig(t, dir, `.wippy.yaml`, `version: "1.0" +profiles: + production: + logger: + level: info +`) + + err := addPublishedRuntimeProfileMetadata(attrs.Bag{}, dir, config.PublishProfilesConfig{ + Include: []string{"prodution"}, + }) + require.ErrorContains(t, err, `publish profile "prodution" not found`) +} + func TestAddPublishedRuntimeProfileMetadataDisabled(t *testing.T) { dir := t.TempDir() writeRuntimeProfileConfig(t, dir, `.wippy.yaml`, `version: "1.0" diff --git a/cmd/wippy/cmd/registry.go b/cmd/wippy/cmd/registry.go index 1c4a88cbb..92a666f77 100644 --- a/cmd/wippy/cmd/registry.go +++ b/cmd/wippy/cmd/registry.go @@ -11,6 +11,7 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/spf13/cobra" "github.com/wippyai/runtime/api/attrs" + "github.com/wippyai/runtime/api/boot" regapi "github.com/wippyai/runtime/api/registry" appinit "github.com/wippyai/runtime/cmd/internal/app" clilogger "github.com/wippyai/runtime/cmd/internal/logger" @@ -84,6 +85,8 @@ func init() { rootCmd.AddCommand(registryCmd) registryCmd.AddCommand(registryListCmd) registryCmd.AddCommand(registryShowCmd) + registryCmd.PersistentFlags().StringArray("profile", nil, "apply a workspace profile from the merged runtime config (repeatable, applied in order)") + registryCmd.PersistentFlags().StringArray("set", nil, "override a merged runtime config value (format: section.path=value, repeatable)") // List flags registryListCmd.Flags().StringP("kind", "k", "", "filter by kind (glob pattern)") @@ -232,14 +235,19 @@ func loadRegistryEntries(cmd *cobra.Command, lockFile string) ([]regapi.Entry, e } defer func() { _ = logger.Sync() }() - lockPath, lockObj, err := loadValidatedLock(".", lockFile) + app, err := appinit.Init(cmd.Context(), false, false, false, true, appStartTime) + if err != nil { + return nil, NewInitAppError(err) + } + runtimeCfg, err := loadRuntimeConfig(cmd, logger) if err != nil { return nil, err } + boot.WithConfig(app.Ctx, runtimeCfg) - app, err := appinit.Init(cmd.Context(), false, false, false, true, appStartTime) + lockPath, lockObj, err := loadValidatedLock(".", lockFile, runtimeCfg, logger) if err != nil { - return nil, NewInitAppError(err) + return nil, err } allEntries, err := ensureModulesAndLoadEntries(app.Ctx, lockPath, lockObj, logger, false) diff --git a/cmd/wippy/cmd/root.go b/cmd/wippy/cmd/root.go index e5d2a0df2..b1c353992 100644 --- a/cmd/wippy/cmd/root.go +++ b/cmd/wippy/cmd/root.go @@ -21,7 +21,7 @@ var ( silentLogs bool eventStreams bool profiler bool - configFile string + configFiles []string memoryLimit string appStartTime = time.Now() ) @@ -33,6 +33,13 @@ const ( var defaultLockFile = lock.DefaultFilename +func runtimeConfigPaths() []string { + if len(configFiles) > 0 { + return append([]string(nil), configFiles...) + } + return []string{defaultConfigFile} +} + var rootCmd = &cobra.Command{ Use: "wippy", Short: "Adaptive Application Runtime", @@ -55,7 +62,7 @@ func IsConsoleMode() bool { } func init() { - rootCmd.PersistentFlags().StringVar(&configFile, "config", "", "config file (default is .wippy.yaml)") + rootCmd.PersistentFlags().StringArrayVar(&configFiles, "config", nil, "config file, repeatable; later files override earlier files (default: .wippy.yaml)") rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "enable verbose debug logging") rootCmd.PersistentFlags().BoolVar(&veryVerbose, "very-verbose", false, "enable very verbose debug logging with stack traces") rootCmd.PersistentFlags().BoolVarP(&console, "console", "c", false, "enable colorful humanized console logging") diff --git a/cmd/wippy/cmd/run.go b/cmd/wippy/cmd/run.go index bd93a8d3e..fa3e5f633 100644 --- a/cmd/wippy/cmd/run.go +++ b/cmd/wippy/cmd/run.go @@ -90,18 +90,20 @@ func init() { rootCmd.AddCommand(runCmd) rootCmd.AddCommand(testCmd) runCmd.AddCommand(listCmd) + listCmd.Flags().StringArray("profile", nil, "apply a workspace profile from the merged runtime config (repeatable, applied in order)") + listCmd.Flags().StringArray("set", nil, "override a merged runtime config value (format: section.path=value, repeatable)") runCmd.Flags().StringSliceP("override", "o", nil, "Override entry values (format: namespace:entry:field=value)") runCmd.Flags().StringP("exec", "x", "", "Execute process and exit (format: namespace:entry)") runCmd.Flags().String("host", "", "Terminal host ID for exec (auto-detected if only one terminal.host exists)") runCmd.Flags().String("registry", "", "Registry URL for hub modules (default: from credentials)") - runCmd.Flags().StringArray("set", nil, "Override a .wippy.yaml config value (format: section.path=value, repeatable)") - runCmd.Flags().StringArray("profile", nil, "Apply a runtime profile from .wippy.yaml or packed runtime metadata (repeatable, applied in order)") + runCmd.Flags().StringArray("set", nil, "override a merged runtime config value (format: section.path=value, repeatable)") + runCmd.Flags().StringArray("profile", nil, "apply a profile from the merged runtime config or packed runtime metadata (repeatable, applied in order)") testCmd.Flags().StringSliceP("override", "o", nil, "Override entry values (format: namespace:entry:field=value)") testCmd.Flags().String("host", "", "Terminal host ID for exec (auto-detected if only one terminal.host exists)") testCmd.Flags().String("registry", "", "Registry URL for hub modules (default: from credentials)") - testCmd.Flags().StringArray("set", nil, "Override a .wippy.yaml config value (format: section.path=value, repeatable)") - testCmd.Flags().StringArray("profile", nil, "Apply a runtime profile from .wippy.yaml or packed runtime metadata (repeatable, applied in order)") + testCmd.Flags().StringArray("set", nil, "override a merged runtime config value (format: section.path=value, repeatable)") + testCmd.Flags().StringArray("profile", nil, "apply a profile from the merged runtime config or packed runtime metadata (repeatable, applied in order)") } // commandMeta represents the command metadata from entry.Meta @@ -394,8 +396,13 @@ func runList(cmd *cobra.Command, _ []string) error { if err != nil { return NewInitAppError(err) } + runtimeCfg, err := loadRuntimeConfig(cmd, app.Logger) + if err != nil { + return err + } + boot.WithConfig(app.Ctx, runtimeCfg) - lockPath, lockObj, err := loadValidatedLock(".", defaultLockFile) + lockPath, lockObj, err := loadValidatedLock(".", defaultLockFile, runtimeCfg, app.Logger) if err != nil { return err } @@ -472,20 +479,23 @@ func runList(cmd *cobra.Command, _ []string) error { // loadBootConfig reads config from file, applies defaults, and injects boot // metadata (config path + directory) into the effective config. func loadBootConfig() (boot.Config, error) { - cfgPath := configFile - if cfgPath == "" { - cfgPath = defaultConfigFile - } - cfgPathAbs, err := filepath.Abs(cfgPath) - if err != nil { - cfgPathAbs = cfgPath + cfgPaths := runtimeConfigPaths() + configPathsAbs := make([]string, len(cfgPaths)) + for i, path := range cfgPaths { + absolute, err := filepath.Abs(path) + if err != nil { + absolute = path + } + configPathsAbs[i] = absolute } + cfgPathAbs := configPathsAbs[0] configMeta := boot.NewConfig(boot.WithSection("boot", map[string]any{ - "config_path": cfgPathAbs, - "config_dir": filepath.Dir(cfgPathAbs), + "config_path": cfgPathAbs, + "config_paths": configPathsAbs, + "config_dir": filepath.Dir(cfgPathAbs), })) - cfg, err := bootconfig.Load(cfgPath) + cfg, err := loadRuntimeConfigFiles() if err != nil { return nil, err } @@ -498,6 +508,13 @@ func loadBootConfig() (boot.Config, error) { return bootconfig.Merge(bootconfig.Merge(defaults, cfg), configMeta), nil } +func loadRuntimeConfigFiles() (boot.Config, error) { + if len(configFiles) == 0 { + return bootconfig.Load(defaultConfigFile) + } + return bootconfig.LoadFiles(configFiles) +} + // createDefaultConfig returns the baseline config implied by global CLI flags. func createDefaultConfig() boot.Config { var opts []boot.ConfigOption diff --git a/cmd/wippy/cmd/run_pack_shutdown_test.go b/cmd/wippy/cmd/run_pack_shutdown_test.go index 3ab2ac036..3e5b47c1c 100644 --- a/cmd/wippy/cmd/run_pack_shutdown_test.go +++ b/cmd/wippy/cmd/run_pack_shutdown_test.go @@ -58,9 +58,7 @@ func TestRunPackEntries_GracefulShutdownStopsRunningService(t *testing.T) { t.Fatalf("write config: %v", err) } - prevConfigFile := configFile - configFile = cfgPath - t.Cleanup(func() { configFile = prevConfigFile }) + setTestConfigFiles(t, cfgPath) core, logs := observer.New(zapcore.DebugLevel) baseLogger := zap.New(core) diff --git a/cmd/wippy/cmd/run_pack_test.go b/cmd/wippy/cmd/run_pack_test.go index f38087e1a..e054bd370 100644 --- a/cmd/wippy/cmd/run_pack_test.go +++ b/cmd/wippy/cmd/run_pack_test.go @@ -33,11 +33,7 @@ func TestRunPackEntries_InvalidRequirementFailsNormalizationPipeline(t *testing. t.Fatalf("write config: %v", err) } - prevConfigFile := configFile - configFile = cfgPath - t.Cleanup(func() { - configFile = prevConfigFile - }) + setTestConfigFiles(t, cfgPath) ctx, loader, logger, embedReg, err := bootstrapPackRuntime(nil, zap.NewNop()) if err != nil { @@ -91,11 +87,7 @@ func TestRunPackEntries_NoEntrypointRunsAsServer(t *testing.T) { t.Fatalf("write config: %v", err) } - prevConfigFile := configFile - configFile = cfgPath - t.Cleanup(func() { - configFile = prevConfigFile - }) + setTestConfigFiles(t, cfgPath) ctx, loader, _, embedReg, err := bootstrapPackRuntime(nil, zap.NewNop()) if err != nil { @@ -135,11 +127,7 @@ func TestRunPackEntries_TestModeWithoutTestEntrypointErrors(t *testing.T) { t.Fatalf("write config: %v", err) } - prevConfigFile := configFile - configFile = cfgPath - t.Cleanup(func() { - configFile = prevConfigFile - }) + setTestConfigFiles(t, cfgPath) ctx, loader, _, embedReg, err := bootstrapPackRuntime(nil, zap.NewNop()) if err != nil { @@ -165,11 +153,7 @@ func TestRunPackEntries_RunModeUnknownCommandErrors(t *testing.T) { t.Fatalf("write config: %v", err) } - prevConfigFile := configFile - configFile = cfgPath - t.Cleanup(func() { - configFile = prevConfigFile - }) + setTestConfigFiles(t, cfgPath) ctx, loader, _, embedReg, err := bootstrapPackRuntime(nil, zap.NewNop()) if err != nil { @@ -195,11 +179,7 @@ func TestRunFromPackFiles_InvalidRequirementFailsNormalizationPipeline(t *testin t.Fatalf("write config: %v", err) } - prevConfigFile := configFile - configFile = cfgPath - t.Cleanup(func() { - configFile = prevConfigFile - }) + setTestConfigFiles(t, cfgPath) packPath := createTestPackFile(t, tmpDir, "snapshot", []wapp.Entry{ { @@ -447,11 +427,7 @@ func TestCollectPackCommandsFiltersDependencyModules(t *testing.T) { t.Fatalf("write config: %v", err) } - prevConfigFile := configFile - configFile = cfgPath - t.Cleanup(func() { - configFile = prevConfigFile - }) + setTestConfigFiles(t, cfgPath) ctx, loader, _, embedReg, err := bootstrapPackRuntime(nil, zap.NewNop()) if err != nil { @@ -564,11 +540,7 @@ func TestBootstrapPackRuntimeWithDefaults_Harness(t *testing.T) { t.Fatalf("write config: %v", err) } - prevConfigFile := configFile - configFile = cfgPath - t.Cleanup(func() { - configFile = prevConfigFile - }) + setTestConfigFiles(t, cfgPath) runtimeDefaults := boot.NewConfig(boot.WithSection("lsp", map[string]any{ "enabled": true, @@ -598,11 +570,7 @@ func TestBootstrapPackRuntimeWithDefaults_Harness(t *testing.T) { t.Fatalf("write config: %v", err) } - prevConfigFile := configFile - configFile = cfgPath - t.Cleanup(func() { - configFile = prevConfigFile - }) + setTestConfigFiles(t, cfgPath) runtimeDefaults := boot.NewConfig(boot.WithSection("lsp", map[string]any{ "enabled": true, @@ -966,11 +934,7 @@ override: t.Fatalf("write config: %v", err) } - prevConfigFile := configFile - configFile = cfgPath - t.Cleanup(func() { - configFile = prevConfigFile - }) + setTestConfigFiles(t, cfgPath) cfg, err := loadBootConfig() if err != nil { @@ -1030,15 +994,10 @@ override: } }) - t.Run("no wippy.yaml does not break pipeline", func(t *testing.T) { + t.Run("missing optional default does not break pipeline", func(t *testing.T) { tmpDir := t.TempDir() - cfgPath := filepath.Join(tmpDir, ".wippy.yaml.nonexistent") - - prevConfigFile := configFile - configFile = cfgPath - t.Cleanup(func() { - configFile = prevConfigFile - }) + t.Chdir(tmpDir) + setTestConfigFiles(t) cfg, err := loadBootConfig() if err != nil { diff --git a/cmd/wippy/cmd/run_test.go b/cmd/wippy/cmd/run_test.go index 19fc54bfe..48189a1b5 100644 --- a/cmd/wippy/cmd/run_test.go +++ b/cmd/wippy/cmd/run_test.go @@ -19,12 +19,10 @@ func TestLoadBootConfigSetsConfigDir(t *testing.T) { cfgBody := []byte("version: \"1.0\"\nlua:\n proto_cache_size: 1\n") require.NoError(t, os.WriteFile(cfgPath, cfgBody, 0o644)) - prevConfigFile := configFile prevProfiler := profiler - configFile = cfgPath + setTestConfigFiles(t, cfgPath) profiler = false t.Cleanup(func() { - configFile = prevConfigFile profiler = prevProfiler }) @@ -38,6 +36,50 @@ func TestLoadBootConfigSetsConfigDir(t *testing.T) { require.Equal(t, expectedPath, cfg.GetString("boot.config_path", "")) require.Equal(t, expectedDir, cfg.GetString("boot.config_dir", "")) + configPaths, ok := cfg.Get("boot.config_paths") + require.True(t, ok) + require.Equal(t, []string{expectedPath}, configPaths) +} + +func TestLoadBootConfigComposesExplicitFilesInOrder(t *testing.T) { + baseDir := t.TempDir() + overlayDir := t.TempDir() + basePath := filepath.Join(baseDir, "base.yaml") + overlayPath := filepath.Join(overlayDir, "postgres.yaml") + require.NoError(t, os.WriteFile(basePath, []byte(`version: "1.0" +logger: + level: info + encoding: json +registry: + history_type: memory +`), 0o600)) + require.NoError(t, os.WriteFile(overlayPath, []byte(`version: "1.0" +logger: + level: debug +registry: + history_type: postgres +`), 0o600)) + + previousProfiler := profiler + setTestConfigFiles(t, basePath, overlayPath) + profiler = false + t.Cleanup(func() { profiler = previousProfiler }) + + cfg, err := loadBootConfig() + require.NoError(t, err) + require.Equal(t, "debug", cfg.GetString("logger.level", "")) + require.Equal(t, "json", cfg.GetString("logger.encoding", "")) + require.Equal(t, "postgres", cfg.GetString("registry.history_type", "")) + + baseAbs, err := filepath.Abs(basePath) + require.NoError(t, err) + overlayAbs, err := filepath.Abs(overlayPath) + require.NoError(t, err) + require.Equal(t, baseAbs, cfg.GetString("boot.config_path", "")) + require.Equal(t, filepath.Dir(baseAbs), cfg.GetString("boot.config_dir", "")) + paths, ok := cfg.Get("boot.config_paths") + require.True(t, ok) + require.Equal(t, []string{baseAbs, overlayAbs}, paths) } func TestLoadRuntimeConfigAppliesOverridesAndCLISettings(t *testing.T) { @@ -46,21 +88,19 @@ func TestLoadRuntimeConfigAppliesOverridesAndCLISettings(t *testing.T) { cfgBody := []byte("version: \"1.0\"\n") require.NoError(t, os.WriteFile(cfgPath, cfgBody, 0o644)) - prevConfigFile := configFile prevProfiler := profiler prevVerbose := verbose prevVeryVerbose := veryVerbose prevConsole := console prevEventStreams := eventStreams - configFile = cfgPath + setTestConfigFiles(t, cfgPath) profiler = false verbose = true veryVerbose = false console = false eventStreams = true t.Cleanup(func() { - configFile = prevConfigFile profiler = prevProfiler verbose = prevVerbose veryVerbose = prevVeryVerbose @@ -101,12 +141,10 @@ func TestLoadRuntimeConfigWithDefaultsAppliesPackDefaultsWhenFileMissingKey(t *t cfgBody := []byte("version: \"1.0\"\n") require.NoError(t, os.WriteFile(cfgPath, cfgBody, 0o644)) - prevConfigFile := configFile prevProfiler := profiler - configFile = cfgPath + setTestConfigFiles(t, cfgPath) profiler = false t.Cleanup(func() { - configFile = prevConfigFile profiler = prevProfiler }) @@ -125,12 +163,10 @@ func TestLoadRuntimeConfigWithDefaultsFileOverridesPackDefaults(t *testing.T) { cfgBody := []byte("version: \"1.0\"\nlsp:\n enabled: false\n") require.NoError(t, os.WriteFile(cfgPath, cfgBody, 0o644)) - prevConfigFile := configFile prevProfiler := profiler - configFile = cfgPath + setTestConfigFiles(t, cfgPath) profiler = false t.Cleanup(func() { - configFile = prevConfigFile profiler = prevProfiler }) @@ -143,6 +179,49 @@ func TestLoadRuntimeConfigWithDefaultsFileOverridesPackDefaults(t *testing.T) { require.False(t, cfg.GetBool("lsp.enabled", true)) } +func TestLoadRuntimeConfigMergesFilesBeforeProfiles(t *testing.T) { + tempDir := t.TempDir() + basePath := filepath.Join(tempDir, "base.yaml") + overlayPath := filepath.Join(tempDir, "docker.yaml") + require.NoError(t, os.WriteFile(basePath, []byte(`version: "1.0" +logger: + level: info +profiles: + docker: + logger: + encoding: console +`), 0o644)) + require.NoError(t, os.WriteFile(overlayPath, []byte(`version: "1.0" +logger: + level: debug +profiles: + docker: + network: + name: docker + workspace: + replacements: + acme/http: ./http +`), 0o600)) + + previousProfiler := profiler + setTestConfigFiles(t, basePath, overlayPath) + profiler = false + t.Cleanup(func() { + profiler = previousProfiler + }) + + cmd := &cobra.Command{} + cmd.Flags().StringArray("profile", nil, "") + require.NoError(t, cmd.Flags().Set("profile", "docker")) + + cfg, err := loadRuntimeConfig(cmd, zap.NewNop()) + require.NoError(t, err) + require.Equal(t, "debug", cfg.GetString("logger.level", "")) + require.Equal(t, "console", cfg.GetString("logger.encoding", "")) + require.Equal(t, "docker", cfg.GetString("network.name", "")) + require.Equal(t, "./http", cfg.GetString("workspace.replacements.acme/http", "")) +} + func TestLoadRuntimeConfig_ProfileAndSetPrecedence(t *testing.T) { tempDir := t.TempDir() cfgPath := filepath.Join(tempDir, "wippy.yaml") @@ -176,16 +255,14 @@ profiles: `) require.NoError(t, os.WriteFile(cfgPath, cfgBody, 0o644)) - prevConfigFile := configFile prevProfiler := profiler prevVerbose := verbose prevVeryVerbose := veryVerbose prevConsole := console prevEventStreams := eventStreams - configFile = cfgPath + setTestConfigFiles(t, cfgPath) profiler, verbose, veryVerbose, console, eventStreams = false, false, false, false, false t.Cleanup(func() { - configFile = prevConfigFile profiler = prevProfiler verbose = prevVerbose veryVerbose = prevVeryVerbose @@ -221,12 +298,10 @@ func TestLoadRuntimeConfig_ProfileFromPackDefaults(t *testing.T) { cfgPath := filepath.Join(tempDir, "wippy.yaml") require.NoError(t, os.WriteFile(cfgPath, []byte("version: \"1.0\"\n"), 0o644)) - prevConfigFile := configFile prevProfiler := profiler - configFile = cfgPath + setTestConfigFiles(t, cfgPath) profiler = false t.Cleanup(func() { - configFile = prevConfigFile profiler = prevProfiler }) @@ -248,23 +323,21 @@ func TestLoadRuntimeConfig_ProfileFromPackDefaults(t *testing.T) { require.Equal(t, "db.sql.postgres", cfg.GetString("override.app:db:kind", "")) } -func TestLoadRuntimeConfig_LocalProfileOverridesPackProfile(t *testing.T) { +func TestLoadRuntimeConfig_FileProfileOverridesPackProfile(t *testing.T) { tempDir := t.TempDir() cfgPath := filepath.Join(tempDir, "wippy.yaml") cfgBody := []byte(`version: "1.0" profiles: pg: override: - "app:db:kind": db.sql.local + "app:db:kind": db.sql.file `) require.NoError(t, os.WriteFile(cfgPath, cfgBody, 0o644)) - prevConfigFile := configFile prevProfiler := profiler - configFile = cfgPath + setTestConfigFiles(t, cfgPath) profiler = false t.Cleanup(func() { - configFile = prevConfigFile profiler = prevProfiler }) @@ -280,5 +353,5 @@ profiles: cfg, err := loadRuntimeConfigWithDefaults(cmd, zap.NewNop(), runtimeDefaults) require.NoError(t, err) - require.Equal(t, "db.sql.local", cfg.GetString("override.app:db:kind", "")) + require.Equal(t, "db.sql.file", cfg.GetString("override.app:db:kind", "")) } diff --git a/cmd/wippy/cmd/set_override_test.go b/cmd/wippy/cmd/set_override_test.go index b37e344b0..fa91bac3c 100644 --- a/cmd/wippy/cmd/set_override_test.go +++ b/cmd/wippy/cmd/set_override_test.go @@ -73,7 +73,7 @@ func TestApplySetOverrides_Malformed(t *testing.T) { } // TestLoadRuntimeConfig_SetFlag exercises --set end to end: through the cobra -// flag, into loadRuntimeConfig, merged over an on-disk .wippy.yaml. +// flag, into loadRuntimeConfig, merged over the on-disk runtime config. func TestLoadRuntimeConfig_SetFlag(t *testing.T) { tempDir := t.TempDir() cfgPath := filepath.Join(tempDir, "wippy.yaml") @@ -85,12 +85,12 @@ func TestLoadRuntimeConfig_SetFlag(t *testing.T) { " join_addrs: \"seed:7946\"\n") require.NoError(t, os.WriteFile(cfgPath, cfgBody, 0o644)) - prevConfigFile, prevProfiler := configFile, profiler + prevProfiler := profiler prevVerbose, prevVeryVerbose, prevConsole, prevEventStreams := verbose, veryVerbose, console, eventStreams - configFile = cfgPath + setTestConfigFiles(t, cfgPath) profiler, verbose, veryVerbose, console, eventStreams = false, false, false, false, false t.Cleanup(func() { - configFile, profiler = prevConfigFile, prevProfiler + profiler = prevProfiler verbose, veryVerbose, console, eventStreams = prevVerbose, prevVeryVerbose, prevConsole, prevEventStreams }) diff --git a/cmd/wippy/cmd/update.go b/cmd/wippy/cmd/update.go index da2fb441d..d15438f68 100644 --- a/cmd/wippy/cmd/update.go +++ b/cmd/wippy/cmd/update.go @@ -48,6 +48,8 @@ func init() { updateCmd.Flags().StringP("src-dir", "d", "./src", "source directory path") updateCmd.Flags().String("modules-dir", ".wippy", "modules directory path") updateCmd.Flags().String("registry", "", "registry URL (default: from credentials)") + updateCmd.Flags().StringArray("profile", nil, "apply a workspace profile from the merged runtime config (repeatable, applied in order)") + updateCmd.Flags().StringArray("set", nil, "override a merged runtime config value (format: section.path=value, repeatable)") } func runUpdate(cmd *cobra.Command, args []string) error { @@ -57,6 +59,10 @@ func runUpdate(cmd *cobra.Command, args []string) error { } logger := app.Logger.Named("update") + runtimeCfg, err := loadRuntimeConfig(cmd, logger) + if err != nil { + return err + } lockFilePath, _ := cmd.Flags().GetString("lock-file") registryURL, _ := cmd.Flags().GetString("registry") @@ -112,7 +118,7 @@ func runUpdate(cmd *cobra.Command, args []string) error { // Targeted update if modules specified if len(args) > 0 { - return runTargetedUpdate(cmd, lockFilePath, srcDir, modulesDir, args, app, hubClient) + return runTargetedUpdate(cmd, lockFilePath, srcDir, modulesDir, args, app, hubClient, runtimeCfg) } // Full update otherwise @@ -121,11 +127,12 @@ func runUpdate(cmd *cobra.Command, args []string) error { // Load old lock file for comparison var oldLockObj *lock.Lock if stat, err := os.Stat(lockFilePath); err == nil && !stat.IsDir() { - oldLockObj, _ = lock.New(lockFilePath) - if oldLockObj != nil { - if err := lock.Validate(oldLockObj); err != nil { - return NewInvalidExistingLockFileError(fmt.Errorf("lock file %s: %w", lockFilePath, err)) - } + oldLockObj, err = newConfiguredLock(lockFilePath, runtimeCfg, logger) + if err != nil { + return NewLoadLockFileError(fmt.Errorf("lock file %s: %w", lockFilePath, err)) + } + if err := lock.Validate(oldLockObj); err != nil { + return NewInvalidExistingLockFileError(fmt.Errorf("lock file %s: %w", lockFilePath, err)) } } @@ -146,7 +153,7 @@ func runUpdate(cmd *cobra.Command, args []string) error { // Build set of replaced modules to exclude from hub resolution replacedModules := make(map[string]bool) if oldLockObj != nil { - for _, repl := range oldLockObj.GetReplacements() { + for _, repl := range oldLockObj.GetTrackedReplacements() { replacedModules[repl.From] = true } } @@ -196,7 +203,7 @@ func runUpdate(cmd *cobra.Command, args []string) error { // Preserve all replacements from old lock file if oldLockObj != nil { - preserveReplacements(newLockObj, oldLockObj.GetReplacements()) + preserveReplacements(newLockObj, oldLockObj.GetTrackedReplacements()) } // Save lock file @@ -299,12 +306,12 @@ func convertResolvedToLock(lockFilePath string, modules []hub.ResolvedModule, mo return lockObj, nil } -func runTargetedUpdate(cmd *cobra.Command, lockFilePath, srcDir, modulesDir string, targetModules []string, app *appinit.Context, hubClient *hub.Client) error { +func runTargetedUpdate(cmd *cobra.Command, lockFilePath, srcDir, modulesDir string, targetModules []string, app *appinit.Context, hubClient *hub.Client, runtimeCfg boot.Config) error { logger := app.Logger.Named("update") logger.Info("updating specific modules", zap.Strings("modules", targetModules)) // Load current lock file - lockObj, err := lock.New(lockFilePath) + lockObj, err := newConfiguredLock(lockFilePath, runtimeCfg, logger) if err != nil { return NewLoadLockFileError(fmt.Errorf("lock file %s: %w", lockFilePath, err)) } @@ -313,10 +320,10 @@ func runTargetedUpdate(cmd *cobra.Command, lockFilePath, srcDir, modulesDir stri return NewInvalidLockFileError(fmt.Errorf("lock file %s: %w", lockObj.Path(), err)) } - oldLockObj, _ := lock.New(lockFilePath) + oldLockObj := lockObj replacedModules := make(map[string]bool) - for _, repl := range lockObj.GetReplacements() { + for _, repl := range lockObj.GetTrackedReplacements() { replacedModules[repl.From] = true } @@ -416,7 +423,7 @@ func runTargetedUpdate(cmd *cobra.Command, lockFilePath, srcDir, modulesDir stri } // Preserve all replacements from current lock file - preserveReplacements(newLockObj, lockObj.GetReplacements()) + preserveReplacements(newLockObj, lockObj.GetTrackedReplacements()) // Detect changes changes := lock.Diff(oldLockObj, newLockObj) @@ -498,7 +505,7 @@ func loadDependencyScanEntries(ctx context.Context, ldr boot.Loader, srcDir stri if lockObj != nil { replacements := make(map[string]bool) - for _, repl := range lockObj.GetReplacements() { + for _, repl := range lockObj.GetTrackedReplacements() { replacements[repl.From] = true } for _, mp := range lockObj.GetModuleLoadPaths() { diff --git a/cmd/wippy/cmd/workspace.go b/cmd/wippy/cmd/workspace.go new file mode 100644 index 000000000..2c4b2496d --- /dev/null +++ b/cmd/wippy/cmd/workspace.go @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MPL-2.0 + +package cmd + +import ( + "fmt" + "os" + + "github.com/wippyai/runtime/api/boot" + "github.com/wippyai/runtime/boot/deps/lock" + "go.uber.org/zap" +) + +func newConfiguredLock(path string, cfg boot.Config, logger *zap.Logger) (*lock.Lock, error) { + lockObj, err := lock.New(path, lock.WithWorkspaceConfig(cfg)) + if err != nil { + return nil, err + } + warnTrackedLockReplacements(lockObj, logger) + return lockObj, nil +} + +func warnTrackedLockReplacements(lockObj *lock.Lock, logger *zap.Logger) { + if lockObj == nil || logger == nil || len(lockObj.GetTrackedReplacements()) == 0 { + return + } + if silentLogs { + _, _ = fmt.Fprintf(os.Stderr, "\nWARNING: DEPRECATED replacements in %s\nMove them to workspace.replacements in a runtime config file; lock-file replacement support will be removed.\n\n", lockObj.Path()) + return + } + logger.Warn("DEPRECATED: lock-file replacements are loaded only for compatibility; move them to workspace.replacements in a runtime config file", + zap.String("lock_file", lockObj.Path())) +} diff --git a/cmd/wippy/cmd/workspace_test.go b/cmd/wippy/cmd/workspace_test.go new file mode 100644 index 000000000..96d2fc097 --- /dev/null +++ b/cmd/wippy/cmd/workspace_test.go @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: MPL-2.0 + +package cmd + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + "github.com/wippyai/runtime/boot/deps/lock" + "github.com/wippyai/runtime/cmd/internal/bootconfig" + "go.uber.org/zap" + "go.uber.org/zap/zaptest/observer" +) + +func TestWorkspaceReplacementsComposeThroughProfiles(t *testing.T) { + configPath := filepath.Join(t.TempDir(), ".wippy.yaml") + require.NoError(t, os.WriteFile(configPath, []byte(`version: "1.0" +workspace: + replacements: + acme/base: ../base + acme/http: ../default-http +profiles: + local: + workspace: + replacements: + acme/http: ../local-http + acme/extra: ../extra + clean: + workspace: + replacements: + acme/base: null +`), 0o600)) + + cfg, err := bootconfig.Load(configPath) + require.NoError(t, err) + cfg, err = bootconfig.ApplyProfiles(cfg, []string{"local", "clean"}) + require.NoError(t, err) + + replacements, err := lock.WorkspaceReplacements(cfg) + require.NoError(t, err) + require.Equal(t, []lock.Replacement{ + {From: "acme/extra", To: "../extra"}, + {From: "acme/http", To: "../local-http"}, + }, replacements) +} + +func TestConfiguredLockWarnsForTrackedReplacements(t *testing.T) { + oldSilentLogs := silentLogs + silentLogs = false + t.Cleanup(func() { silentLogs = oldSilentLogs }) + tmpDir := t.TempDir() + lockPath := filepath.Join(tmpDir, lock.DefaultFilename) + require.NoError(t, os.WriteFile(lockPath, []byte(`directories: + modules: .wippy + src: ./src +replacements: + - from: acme/http + to: ./http +`), 0o600)) + + core, observed := observer.New(zap.WarnLevel) + _, err := newConfiguredLock(lockPath, nil, zap.New(core)) + require.NoError(t, err) + require.Len(t, observed.All(), 1) + require.Contains(t, observed.All()[0].Message, "DEPRECATED") + require.Contains(t, observed.All()[0].Message, "workspace.replacements") +}