diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..1f6ecd2 Binary files /dev/null and b/.DS_Store differ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..911b304 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,80 @@ +name: CI + +on: + push: + branches: [dev, main] + pull_request: + branches: [dev, main] + +permissions: + contents: read + +env: + GOFLAGS: -buildvcs=false + GOWORK: "off" + GOPROXY: "direct" + GOSUMDB: "off" + +jobs: + test: + name: Test + Coverage + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - uses: actions/setup-go@v6 + with: + go-version: '1.26' + - name: Test with coverage + working-directory: go + run: go test -race -coverprofile=coverage.out -covermode=atomic -count=1 ./... + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: go/coverage.out + flags: unittests + fail_ci_if_error: false + + lint: + name: golangci-lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-go@v6 + with: + go-version: '1.26' + - uses: golangci/golangci-lint-action@v9 + with: + version: latest + working-directory: go + args: --timeout=5m --tests=false + + sonarcloud: + name: SonarCloud + runs-on: ubuntu-latest + needs: test + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - uses: actions/setup-go@v6 + with: + go-version: '1.26' + - name: Test for coverage + working-directory: go + run: go test -coverprofile=coverage.out -covermode=atomic -count=1 ./... + - name: SonarCloud Scan + uses: SonarSource/sonarqube-scan-action@v6 + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + with: + args: > + -Dsonar.organization=dappcore + -Dsonar.projectKey=dappcore_config + -Dsonar.sources=go + -Dsonar.exclusions=**/vendor/**,**/third_party/**,**/.tmp/**,**/*_test.go + -Dsonar.tests=go + -Dsonar.test.inclusions=**/*_test.go + -Dsonar.go.coverage.reportPaths=go/coverage.out diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..0bca628 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,12 @@ +[submodule "external/go"] + path = external/go + url = https://github.com/dappcore/go.git + branch = dev +[submodule "external/io"] + path = external/io + url = https://github.com/dappcore/go-io.git + branch = dev +[submodule "external/log"] + path = external/log + url = https://github.com/dappcore/go-log.git + branch = dev diff --git a/.woodpecker.yml b/.woodpecker.yml index 107f0e6..60358ee 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -14,7 +14,7 @@ steps: GOFLAGS: -buildvcs=false GOWORK: "off" commands: - - golangci-lint run --timeout=5m ./... + - cd go && golangci-lint run --timeout=5m ./... - name: go-test image: golang:1.26-alpine @@ -25,7 +25,7 @@ steps: CGO_ENABLED: "1" commands: - apk add --no-cache git build-base - - go test -race -coverprofile=coverage.out -covermode=atomic -count=1 ./... + - cd go && go test -race -coverprofile=coverage.out -covermode=atomic -count=1 ./... - name: sonar image: sonarsource/sonar-scanner-cli:latest depends_on: [go-test] diff --git a/AGENTS.md b/AGENTS.md index 4387c77..9b3cb37 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,49 +1,32 @@ -# Agent Guide +# Agent Notes -This repository is the `dappco.re/go/config` consumer module for Core -configuration. Work here should follow the current `dappco.re/go` conventions: -public operations return `core.Result`, tests use the Core assertion helpers, -and examples print through `core.Println`. +This repository is the `dappco.re/go/config` module. It provides layered +configuration, manifest loading, discovery, feature flags, XDG paths, and the +Core framework service adapter used by other Core projects. -## Repository Shape +When changing code here, keep the public API aligned with `dappco.re/go` v0.9 +patterns. Use the Core wrappers for formatting, JSON, filesystem paths, +environment reads, and assertions in tests. Do not add direct imports of the +stdlib packages banned by the upgrade audit, and do not create compatibility +packages that shadow those stdlib names. -- `config.go` contains the `Config` type, functional options, file loading, - typed retrieval, persistence, and legacy `Load` / `Save` helpers. -- `env.go` contains environment scanning helpers. It maps prefixed variables to - lower-case dot keys without importing banned stdlib packages directly. -- `service.go` wraps `Config` as a Core lifecycle service. -- Each production file has a matching `_test.go` and `_example_test.go` sibling. - Keep tests next to their source file; do not create aggregate compliance - files. -- `docs/` contains the human-facing architecture and development notes. +Tests are source-file aware. Public symbols in `config.go` are tested in +`config_test.go`, public symbols in `resolve.go` are tested in +`resolve_test.go`, and so on. Each public symbol needs the Good, Bad, and Ugly +triplet in that sibling file plus a runnable example in the matching +`*_example_test.go` file. Supplemental tests may exist, but their names should +describe the behaviour they cover rather than pretending to be another +symbol's canonical triplet. -## Compliance Rules - -The audit script is the work provider: +The normal verification gate for this repository is: ```bash +GOWORK=off go mod tidy +GOWORK=off go vet ./... +GOWORK=off go test -count=1 ./... +gofmt -l . bash /Users/snider/Code/core/go/tests/cli/v090-upgrade/audit.sh . ``` -Do not stop on partial progress. A compliant run has every counter at `0` and -prints `verdict: COMPLIANT`. - -The important local patterns are: - -- Use `core.Result` with `core.Ok`, `core.Fail`, or `core.ResultOf`. -- Do not import banned stdlib packages such as `fmt`, `os`, `strings`, - `path/filepath`, or `encoding/json` in this consumer module. -- Use `core.Path*`, `core.Environ`, `core.SplitN`, `core.Replace`, and - `core.Sprintf` instead of direct stdlib helpers. -- Public symbols require `Good`, `Bad`, and `Ugly` tests in the matching source - test file. -- Public symbols require examples in the matching source example file. - -## Editing Notes - -Keep the dual-view config invariant intact. File-backed and explicitly set -values belong in `Config.file`; the read view in `Config.full` is rebuilt from -file settings, environment variables, and explicit overrides. That separation is -what prevents environment-derived secrets from being written during `Commit`. - -Do not edit `BRIEF.md`, `.git`, `.codex`, or any `third_party` directory. +`BRIEF.md` is a local work brief and should be left untracked. Do not edit +`third_party/`, `.git/`, or `.codex/` while applying compliance changes. diff --git a/CLAUDE.md b/CLAUDE.md index 6810827..c768f78 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,59 +1,100 @@ # CLAUDE.md -This file gives Claude Code agents repository-specific context for -`dappco.re/go/config`. +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Repo Layout + +```text +core/config/ +├── go/ +│ ├── *.go ← Go source files moved from repository root +│ ├── tests/ ← Go CLI tests moved from repository root +│ ├── go.mod +│ ├── go.sum +│ ├── README.md -> ../README.md ← symlink +│ ├── CLAUDE.md -> ../CLAUDE.md ← symlink +│ ├── AGENTS.md -> ../AGENTS.md ← symlink +│ └── docs -> ../docs ← symlink +├── docs/ +├── schema/ +├── README.md +├── CLAUDE.md +├── AGENTS.md +├── LICENSE/ +├── SONAR-project files +└── other non-Go/cross-language assets +``` + +The Go module path remains `dappco.re/go/config`; only repository layout changed. + +## Go Resolution Modes + +This repository is module-local under `go/` and does not use a local workspace file in this module root. Consumer commands should either: -## Commands +1. Run from `go/` directly. +2. Prefix Go commands with `cd go &&` when executed from repository root. -Use the explicit verification commands from the compliance brief: +Examples: ```bash -GOWORK=off go mod tidy -GOWORK=off go vet ./... -GOWORK=off go test -count=1 ./... -gofmt -l . -bash /Users/snider/Code/core/go/tests/cli/v090-upgrade/audit.sh . +cd go && go test ./... +cd go && golangci-lint run ./... +cd go && core go qa ``` -The audit script is the completion contract. The repository is not complete -until every audit counter is `0` and the verdict is `COMPLIANT`. +For CI-style reproducible builds, force module-only behavior: -## Architecture +```bash +GOWORK=off go test ./... +GOFLAGS=-mod=mod go vet ./... +``` -`Config` uses two Viper instances: +## Build & Test Commands -- `file` stores values loaded from config files and values explicitly set at - runtime. This is the only source written by `Commit`. -- `full` is the read view. It is rebuilt from file settings, environment - variables, and explicit overrides. +This project uses the Core CLI (`core` binary), not `go` directly. -This shape keeps environment-derived secrets out of persisted YAML while still -letting `Get` and `All` see the effective runtime configuration. +```bash +cd go && go test ./... # run all tests +cd go && GOWORK=off go test -run TestConfig_Get_Good ./... # run a single test +cd go && GOWORK=off go test -cover ./... # test with coverage + +cd go && core go qa # format, vet, lint, test +cd go && core go qa full # adds race detector, vuln scan, security audit + +cd go && core go fmt # format +cd go && core go vet # vet +cd go && core go lint # lint +``` + +This is a library package — there is no binary to build or run. + +## Architecture -## API Conventions +**Dual-Viper pattern**: `Config` holds two `*viper.Viper` instances: +- `v` (full) — file + env + defaults; used for all reads (`Get`, `All`) +- `f` (file-only) — file + explicit `Set()` calls; used for persistence (`Commit`) -This consumer repo follows `dappco.re/go` v0.9.0 conventions: +This prevents environment variables from leaking into saved config files. When implementing new features, maintain this invariant: writes go to both `v` and `f`; reads come from `v`; persistence comes from `f`. -- Public operations return `core.Result`. -- Callers branch on `r.OK` and read `r.Value` or `r.Error()`. -- Use `core.Ok`, `core.Fail`, and `core.ResultOf` rather than struct literals. -- Do not import banned stdlib packages directly. Reach through Core wrappers - such as `core.PathExt`, `core.PathDir`, `core.Environ`, `core.SplitN`, - `core.Replace`, `core.NewReader`, and `core.Sprintf`. +**Resolution priority** (ascending): defaults → file → env vars (`CORE_CONFIG_*`) → `Set()` -## Tests And Examples +**Service wrapper**: `Service` in `service.go` wraps `Config` with framework lifecycle (`core.Startable`). Both `Config` and `Service` satisfy `core.Config`, enforced by compile-time assertions. -Each production file with public symbols has a sibling test file and a sibling -example file. Keep coverage file-aware: +**Storage abstraction**: All file I/O goes through `coreio.Medium` (from `go-io`). Tests use `coreio.NewMockMedium()` with an in-memory `Files` map — never touch the real filesystem. -- `config.go` -> `config_test.go` and `config_example_test.go` -- `env.go` -> `env_test.go` and `env_example_test.go` -- `service.go` -> `service_test.go` and `service_example_test.go` +## Conventions -Tests use `*core.T`, dot-import `dappco.re/go`, and the Core assertion helpers. -Triplets are named `Test__{Good,Bad,Ugly}`. Examples use -`core.Println`, not `fmt.Println`. +- **UK English** in comments and documentation (colour, organisation, centre) +- **Error wrapping**: `coreerr.E(caller, message, underlying)` from `go-log` +- **Test naming**: `_Good` (happy path), `_Bad` (expected errors), `_Ugly` (panics/edge cases) +- **Functional options**: `New()` takes `...Option` (e.g. `WithMedium`, `WithPath`, `WithEnvPrefix`) +- **Conventional commits**: `type(scope): description` +- **Go workspace**: no per-repo `go.work`; this module resolves with `GOWORK=off` -## Do Not Touch +## Dependencies -Do not edit `BRIEF.md`, `.git`, `.codex`, or any `third_party` directory. +- `dappco.re/go/core/io` — `Medium` interface for storage +- `dappco.re/go/core/log` — `coreerr.E()` error helper +- `dappco.re/go/core` — `core.Core`, `core.Startable`, `core.ServiceRuntime`, primitives +- `github.com/spf13/viper` — configuration engine +- `github.com/stretchr/testify` — test assertions diff --git a/README.md b/README.md index 0219a8f..a49791e 100644 --- a/README.md +++ b/README.md @@ -1,85 +1,81 @@ -# go-config - -`dappco.re/go/config` provides layered configuration loading for Core-based Go -services. It combines file-backed settings, environment variables, and explicit -runtime overrides behind a small `Result`-returning API that matches -`dappco.re/go`. - -The package keeps two Viper instances internally. One represents the complete -read view and the other represents only values that should be persisted. This -lets `Commit` write file-backed and explicitly set values without copying -environment secrets into `config.yaml`. - -## Install - -```bash -go get dappco.re/go/config -``` - -The module requires Go 1.26 or newer. - -## Quick Start + + +# config + +> Config primitives — schemas, conclave, env, watch, resolve, workspace + +[![CI](https://github.com/dappcore/config/actions/workflows/ci.yml/badge.svg?branch=dev)](https://github.com/dappcore/config/actions/workflows/ci.yml) +[![Quality Gate](https://sonarcloud.io/api/project_badges/measure?project=dappcore_config&metric=alert_status)](https://sonarcloud.io/dashboard?id=dappcore_config) +[![Coverage](https://codecov.io/gh/dappcore/config/branch/dev/graph/badge.svg)](https://codecov.io/gh/dappcore/config) +[![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=dappcore_config&metric=security_rating)](https://sonarcloud.io/dashboard?id=dappcore_config) +[![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=dappcore_config&metric=sqale_rating)](https://sonarcloud.io/dashboard?id=dappcore_config) +[![Reliability Rating](https://sonarcloud.io/api/project_badges/measure?project=dappcore_config&metric=reliability_rating)](https://sonarcloud.io/dashboard?id=dappcore_config) +[![Code Smells](https://sonarcloud.io/api/project_badges/measure?project=dappcore_config&metric=code_smells)](https://sonarcloud.io/dashboard?id=dappcore_config) +[![Lines of Code](https://sonarcloud.io/api/project_badges/measure?project=dappcore_config&metric=ncloc)](https://sonarcloud.io/dashboard?id=dappcore_config) +[![Go Reference](https://pkg.go.dev/badge/dappco.re/go/config.svg)](https://pkg.go.dev/dappco.re/go/config) +[![License: EUPL-1.2](https://img.shields.io/badge/License-EUPL--1.2-blue.svg)](https://eupl.eu/1.2/en/) + + +`dappco.re/go/config` is the Core configuration module. It gives Core services +and command-line tools a single way to resolve configuration from defaults, +project `.core/` files, user-global files, environment variables, and explicit +runtime writes. + +The package is built around a `Config` type that keeps two views of state. The +read view includes file data, defaults, environment values, and in-memory +updates. The write view contains only file-backed and explicit values, so +`Commit` can persist configuration without leaking environment overrides into +the YAML file. + +## Main Capabilities + +- Load and save YAML configuration through the `dappco.re/go/io` medium + abstraction. +- Discover `.core/config.yaml` files by walking up from a project directory. +- Resolve typed Core manifests such as `build.yaml`, `test.yaml`, + `workspace.yaml`, `manifest.yaml`, `agent.yaml`, and `zone.yaml`. +- Sign and verify view and package manifests with ed25519 signatures. +- Expose feature flags through config, process-level defaults, and environment + overrides. +- Run as a Core service with lifecycle startup, actions, commands, and optional + filesystem watching. + +## Basic Usage ```go package main import ( - config "dappco.re/go/config" - core "dappco.re/go" + core "dappco.re/go" + config "dappco.re/go/config" + coreio "dappco.re/go/io" ) func main() { - r := config.New() - if !r.OK { - panic(r.Error()) - } - cfg := r.Value.(*config.Config) - - if set := cfg.Set("dev.editor", "vim"); !set.OK { - panic(set.Error()) - } - if commit := cfg.Commit(); !commit.OK { - panic(commit.Error()) - } - - var editor string - if get := cfg.Get("dev.editor", &editor); get.OK { - core.Println(editor) - } + cfg, err := config.New( + config.WithMedium(coreio.Local), + config.WithPath(".core/config.yaml"), + ) + if err != nil { + panic(err) + } + + _ = cfg.Set("dev.editor", "vim") + _ = cfg.Commit() + + var editor string + _ = cfg.Get("dev.editor", &editor) + core.Println(editor) } ``` -## Configuration Sources - -Values are resolved in this order: - -1. File values loaded from the configured path. -2. Environment variables using `CORE_CONFIG_` by default. -3. Explicit values written with `Set`. - -Keys use dot notation, so `CORE_CONFIG_DEV_EDITOR=nano` resolves as -`dev.editor`. `WithEnvPrefix("MYAPP")` changes the environment prefix to -`MYAPP_`. - -## Service Mode - -`NewConfigService` adapts the package to the Core service lifecycle. Register -it with `core.WithService(config.NewConfigService)`, then call -`ServiceFor[*config.Service]` when a service needs configuration access. - ## Development -The compliance gate for this repository is the v0.9.0 audit script from -`core/go`. Local verification should run: - -```bash -GOWORK=off go mod tidy -GOWORK=off go vet ./... -GOWORK=off go test -count=1 ./... -gofmt -l . -bash /Users/snider/Code/core/go/tests/cli/v090-upgrade/audit.sh . -``` - -## Licence +This module follows the Core v0.9 compliance shape. Keep tests next to the +source file they cover, keep examples in sibling `*_example_test.go` files, and +use Core assertion and wrapper helpers throughout tests. The repository audit +at `/Users/snider/Code/core/go/tests/cli/v090-upgrade/audit.sh` is the final +contract for compliance. -EUPL-1.2 +See `docs/index.md`, `docs/architecture.md`, and `docs/development.md` for the +longer API, design, and contribution notes. diff --git a/config.go b/config.go deleted file mode 100644 index b0b633f..0000000 --- a/config.go +++ /dev/null @@ -1,359 +0,0 @@ -// Package config provides layered configuration management for the Core framework. -// -// Configuration values are resolved in priority order: defaults -> file -> env -> Set(). -// Values are stored in a YAML file at ~/.core/config.yaml by default. -// -// Keys use dot notation for nested access: -// -// cfg.Set("dev.editor", "vim") -// var editor string -// cfg.Get("dev.editor", &editor) -package config - -import ( - "iter" - "sort" - "sync" - - core "dappco.re/go" - "github.com/spf13/viper" - "gopkg.in/yaml.v3" -) - -const ( - errConfigIsNil = "config is nil" - errStorageMediumIsNil = "storage medium is nil" - errUnsupportedConfigFileType = "unsupported config file type: " - operationConfigGet = "config.Get" - operationConfigLoad = "config.Load" - operationConfigLoadFile = "config.LoadFile" - operationConfigSave = "config.Save" -) - -// Medium is the storage surface Config needs for file-backed settings. -type Medium interface { - Exists(path string) bool - Read(path string) core.Result - Write(path, content string) core.Result - EnsureDir(path string) core.Result -} - -// Config implements the core.Config interface with layered resolution. -// It uses viper as the underlying configuration engine. -type Config struct { - mu sync.RWMutex - full *viper.Viper // Full configuration (file + env + defaults) - file *viper.Viper // File-backed configuration only (for persistence) - medium Medium - path string - envPrefix string - overrides map[string]any -} - -// Option is a functional option for configuring a Config instance. -type Option func(*Config) - -// WithMedium sets the storage medium for configuration file operations. -func WithMedium(m Medium) Option { - return func(c *Config) { - c.medium = m - } -} - -// WithPath sets the path to the configuration file. -func WithPath(path string) Option { - return func(c *Config) { - c.path = path - } -} - -// WithEnvPrefix sets the prefix for environment variables. -func WithEnvPrefix(prefix string) Option { - return func(c *Config) { - c.envPrefix = core.TrimSuffix(prefix, "_") - } -} - -// New creates a new Config instance with the given options. -// If no medium is provided, it defaults to io.Local. -// If no path is provided, it defaults to ~/.core/config.yaml. -func New(opts ...Option) core.Result { - c := &Config{ - full: viper.New(), - file: viper.New(), - envPrefix: "CORE_CONFIG", - overrides: make(map[string]any), - } - - for _, opt := range opts { - opt(c) - } - - if c.medium == nil { - c.medium = (&core.Fs{}).New("/") - } - - if c.path == "" { - home := core.UserHomeDir() - if !home.OK { - return core.Fail(core.E("config.New", "failed to determine home directory", core.NewError(home.Error()))) - } - c.path = core.Path(home.Value.(string), ".core", "config.yaml") - } - - // Load existing config file if it exists - if c.medium.Exists(c.path) { - if r := c.LoadFile(c.medium, c.path); !r.OK { - return core.Fail(core.E("config.New", "failed to load config file", core.NewError(r.Error()))) - } - } else if r := c.refreshFull(); !r.OK { - return r - } - - return core.Ok(c) -} - -func configTypeForPath(path string) core.Result { - ext := core.Lower(core.PathExt(path)) - if ext == "" && core.PathBase(path) == ".env" { - return core.Ok("env") - } - if ext == "" { - return core.Ok("yaml") - } - - switch ext { - case ".yaml", ".yml": - return core.Ok("yaml") - case ".json": - return core.Ok("json") - case ".toml": - return core.Ok("toml") - case ".env": - return core.Ok("env") - default: - return core.Fail(core.E("config.configTypeForPath", errUnsupportedConfigFileType+path, nil)) - } -} - -func (c *Config) refreshFull() core.Result { - c.mu.Lock() - defer c.mu.Unlock() - return c.refreshFullLocked() -} - -func (c *Config) refreshFullLocked() core.Result { - next := viper.New() - if r := core.ResultOf(nil, next.MergeConfigMap(c.file.AllSettings())); !r.OK { - return core.Fail(core.E("config.refreshFull", "failed to merge file settings", core.NewError(r.Error()))) - } - for key, value := range Env(c.envPrefix) { - next.Set(key, value) - } - for key, value := range c.overrides { - next.Set(key, value) - } - c.full = next - return core.Ok(nil) -} - -// LoadFile reads a configuration file from the given medium and path and merges it into the current config. -// It supports YAML, JSON, TOML, and dotenv files (.env). -func (c *Config) LoadFile(m Medium, path string) core.Result { - if c == nil { - return core.Fail(core.E(operationConfigLoadFile, errConfigIsNil, nil)) - } - c.mu.Lock() - defer c.mu.Unlock() - - if m == nil { - return core.Fail(core.E(operationConfigLoadFile, errStorageMediumIsNil, nil)) - } - - configType := configTypeForPath(path) - if !configType.OK { - return core.Fail(core.E(operationConfigLoadFile, "failed to determine config file type: "+path, core.NewError(configType.Error()))) - } - - read := m.Read(path) - if !read.OK { - return core.Fail(core.E(operationConfigLoadFile, core.Sprintf("failed to read config file: %s", path), core.NewError(read.Error()))) - } - - content, ok := read.Value.(string) - if !ok { - return core.Fail(core.E(operationConfigLoadFile, core.Sprintf("config file was not text: %s", path), nil)) - } - - parsed := viper.New() - parsed.SetConfigType(configType.Value.(string)) - if r := core.ResultOf(nil, parsed.MergeConfig(core.NewReader(content))); !r.OK { - return core.Fail(core.E(operationConfigLoadFile, core.Sprintf("failed to parse config file: %s", path), core.NewError(r.Error()))) - } - - settings := parsed.AllSettings() - - // Keep the persisted and runtime views aligned with the same parsed data. - if r := core.ResultOf(nil, c.file.MergeConfigMap(settings)); !r.OK { - return core.Fail(core.E(operationConfigLoadFile, "failed to merge config into file settings", core.NewError(r.Error()))) - } - - return c.refreshFullLocked() -} - -// Get retrieves a configuration value by dot-notation key and stores it in out. -// If key is empty, it unmarshals the entire configuration into out. -// The out parameter must be a pointer to the target type. -func (c *Config) Get(key string, out any) core.Result { - if c == nil { - return core.Fail(core.E(operationConfigGet, errConfigIsNil, nil)) - } - c.mu.Lock() - defer c.mu.Unlock() - - if r := c.refreshFullLocked(); !r.OK { - return r - } - - if key == "" { - if r := core.ResultOf(nil, c.full.Unmarshal(out)); !r.OK { - return core.Fail(core.E(operationConfigGet, "failed to unmarshal full config", core.NewError(r.Error()))) - } - return core.Ok(out) - } - - if !c.full.IsSet(key) { - return core.Fail(core.E(operationConfigGet, core.Sprintf("key not found: %s", key), nil)) - } - - if r := core.ResultOf(nil, c.full.UnmarshalKey(key, out)); !r.OK { - return core.Fail(core.E(operationConfigGet, core.Sprintf("failed to unmarshal key: %s", key), core.NewError(r.Error()))) - } - return core.Ok(out) -} - -// Set stores a configuration value in memory. -// Call Commit() to persist changes to disk. -func (c *Config) Set(key string, v any) core.Result { - if c == nil { - return core.Fail(core.E("config.Set", errConfigIsNil, nil)) - } - c.mu.Lock() - defer c.mu.Unlock() - - c.file.Set(key, v) - c.overrides[key] = v - return c.refreshFullLocked() -} - -// Commit persists any changes made via Set() to the configuration file on disk. -// This will only save the configuration that was loaded from the file or explicitly Set(), -// preventing environment variable leakage. -func (c *Config) Commit() core.Result { - if c == nil { - return core.Fail(core.E("config.Commit", errConfigIsNil, nil)) - } - c.mu.Lock() - defer c.mu.Unlock() - - if r := Save(c.medium, c.path, c.file.AllSettings()); !r.OK { - return core.Fail(core.E("config.Commit", "failed to save config", core.NewError(r.Error()))) - } - return core.Ok(nil) -} - -// All returns an iterator over all configuration values in lexical key order -// (including environment variables). -func (c *Config) All() iter.Seq2[string, any] { - c.mu.Lock() - defer c.mu.Unlock() - - if r := c.refreshFullLocked(); !r.OK { - return func(func(string, any) bool) {} - } - - settings := c.full.AllSettings() - keys := make([]string, 0, len(settings)) - for key := range settings { - keys = append(keys, key) - } - sort.Strings(keys) - - return func(yield func(string, any) bool) { - for _, key := range keys { - if !yield(key, settings[key]) { - return - } - } - } -} - -// Path returns the path to the configuration file. -func (c *Config) Path() string { - return c.path -} - -// Load reads a YAML configuration file from the given medium and path. -// Returns the parsed data as a map, or an error if the file cannot be read or parsed. -// Deprecated: Use Config.LoadFile instead. -func Load(m Medium, path string) core.Result { - switch ext := core.Lower(core.PathExt(path)); ext { - case "", ".yaml", ".yml": - // These paths are safe to treat as YAML sources. - default: - return core.Fail(core.E(operationConfigLoad, errUnsupportedConfigFileType+path, nil)) - } - - if m == nil { - return core.Fail(core.E(operationConfigLoad, errStorageMediumIsNil, nil)) - } - - read := m.Read(path) - if !read.OK { - return core.Fail(core.E(operationConfigLoad, "failed to read config file: "+path, core.NewError(read.Error()))) - } - - content, ok := read.Value.(string) - if !ok { - return core.Fail(core.E(operationConfigLoad, "config file was not text: "+path, nil)) - } - - v := viper.New() - v.SetConfigType("yaml") - if r := core.ResultOf(nil, v.ReadConfig(core.NewReader(content))); !r.OK { - return core.Fail(core.E(operationConfigLoad, "failed to parse config file: "+path, core.NewError(r.Error()))) - } - - return core.Ok(v.AllSettings()) -} - -// Save writes configuration data to a YAML file at the given path. -// It ensures the parent directory exists before writing. -func Save(m Medium, path string, data map[string]any) core.Result { - switch ext := core.Lower(core.PathExt(path)); ext { - case "", ".yaml", ".yml": - // These paths are safe to treat as YAML destinations. - default: - return core.Fail(core.E(operationConfigSave, errUnsupportedConfigFileType+path, nil)) - } - - if m == nil { - return core.Fail(core.E(operationConfigSave, errStorageMediumIsNil, nil)) - } - - out := core.ResultOf(yaml.Marshal(data)) - if !out.OK { - return core.Fail(core.E(operationConfigSave, "failed to marshal config", core.NewError(out.Error()))) - } - - dir := core.PathDir(path) - if r := m.EnsureDir(dir); !r.OK { - return core.Fail(core.E(operationConfigSave, "failed to create config directory: "+dir, core.NewError(r.Error()))) - } - - if r := m.Write(path, string(out.Value.([]byte))); !r.OK { - return core.Fail(core.E(operationConfigSave, "failed to write config file: "+path, core.NewError(r.Error()))) - } - - return core.Ok(nil) -} diff --git a/config_example_test.go b/config_example_test.go deleted file mode 100644 index 30f99de..0000000 --- a/config_example_test.go +++ /dev/null @@ -1,185 +0,0 @@ -package config_test - -import ( - . "dappco.re/go" - config "dappco.re/go/config" -) - -func exampleConfigMedium(prefix string) (*Fs, string, func()) { - root := (&Fs{}).New("/") - dir := root.TempDir(prefix) - resolved := PathEvalSymlinks(dir) - if resolved.OK { - dir = resolved.Value.(string) - } - return (&Fs{}).New(dir), testConfigYAMLPath, func() { - _ = root.DeleteAll(dir) - } -} - -func ExampleWithMedium() { - fs, path, cleanup := exampleConfigMedium("go-config-medium") - defer cleanup() - _ = fs.Write(path, testAgentCodexYAML) - - r := config.New(config.WithMedium(fs), config.WithPath(path)) - cfg := r.Value.(*config.Config) - var agent string - _ = cfg.Get("agent", &agent) - - Println(agent) - // Output: codex -} - -func ExampleWithPath() { - fs, _, cleanup := exampleConfigMedium("go-config-path") - defer cleanup() - - r := config.New(config.WithMedium(fs), config.WithPath("/custom.yaml")) - cfg := r.Value.(*config.Config) - - Println(cfg.Path()) - // Output: /custom.yaml -} - -func ExampleWithEnvPrefix() { - _ = Setenv("APP_MODE", "test") - defer func() { - _ = Unsetenv("APP_MODE") - }() - fs, path, cleanup := exampleConfigMedium("go-config-env-prefix") - defer cleanup() - - r := config.New(config.WithMedium(fs), config.WithPath(path), config.WithEnvPrefix("APP")) - cfg := r.Value.(*config.Config) - var mode string - _ = cfg.Get("mode", &mode) - - Println(mode) - // Output: test -} - -func ExampleNew() { - fs, path, cleanup := exampleConfigMedium("go-config-new") - defer cleanup() - - r := config.New(config.WithMedium(fs), config.WithPath(path)) - - Println(r.OK) - // Output: true -} - -func ExampleConfig_LoadFile() { - fs, path, cleanup := exampleConfigMedium("go-config-loadfile") - defer cleanup() - _ = fs.Write(testExtraYAMLPath, testAgentCodexYAML) - cfg := config.New(config.WithMedium(fs), config.WithPath(path)).Value.(*config.Config) - - r := cfg.LoadFile(fs, testExtraYAMLPath) - var agent string - _ = cfg.Get("agent", &agent) - - Println(r.OK) - Println(agent) - // Output: - // true - // codex -} - -func ExampleConfig_Get() { - fs, path, cleanup := exampleConfigMedium("go-config-get") - defer cleanup() - cfg := config.New(config.WithMedium(fs), config.WithPath(path)).Value.(*config.Config) - _ = cfg.Set("agent", "codex") - - var agent string - r := cfg.Get("agent", &agent) - - Println(r.OK) - Println(agent) - // Output: - // true - // codex -} - -func ExampleConfig_Set() { - fs, path, cleanup := exampleConfigMedium("go-config-set") - defer cleanup() - cfg := config.New(config.WithMedium(fs), config.WithPath(path)).Value.(*config.Config) - - r := cfg.Set("agent", "codex") - var agent string - _ = cfg.Get("agent", &agent) - - Println(r.OK) - Println(agent) - // Output: - // true - // codex -} - -func ExampleConfig_Commit() { - fs, path, cleanup := exampleConfigMedium("go-config-commit") - defer cleanup() - cfg := config.New(config.WithMedium(fs), config.WithPath(path)).Value.(*config.Config) - _ = cfg.Set("agent", "codex") - - r := cfg.Commit() - content := fs.Read(path) - - Println(r.OK) - Println(Contains(content.Value.(string), testAgentCodexText)) - // Output: - // true - // true -} - -func ExampleConfig_All() { - fs, path, cleanup := exampleConfigMedium("go-config-all") - defer cleanup() - cfg := config.New(config.WithMedium(fs), config.WithPath(path)).Value.(*config.Config) - _ = cfg.Set("zulu", "last") - _ = cfg.Set("alpha", "first") - - for key, value := range cfg.All() { - Println(key, value) - } - // Output: - // alpha first - // zulu last -} - -func ExampleConfig_Path() { - fs, _, cleanup := exampleConfigMedium("go-config-path-method") - defer cleanup() - cfg := config.New(config.WithMedium(fs), config.WithPath("/settings.yaml")).Value.(*config.Config) - - Println(cfg.Path()) - // Output: /settings.yaml -} - -func ExampleLoad() { - fs, _, cleanup := exampleConfigMedium("go-config-load") - defer cleanup() - _ = fs.Write(testConfigYAMLPath, testAgentCodexYAML) - - r := config.Load(fs, testConfigYAMLPath) - data := r.Value.(map[string]any) - - Println(data["agent"]) - // Output: codex -} - -func ExampleSave() { - fs, _, cleanup := exampleConfigMedium("go-config-save") - defer cleanup() - - r := config.Save(fs, testConfigYAMLPath, map[string]any{"agent": "codex"}) - content := fs.Read(testConfigYAMLPath) - - Println(r.OK) - Println(Contains(content.Value.(string), testAgentCodexText)) - // Output: - // true - // true -} diff --git a/config_test.go b/config_test.go deleted file mode 100644 index 92ad114..0000000 --- a/config_test.go +++ /dev/null @@ -1,487 +0,0 @@ -package config_test - -import ( - . "dappco.re/go" - config "dappco.re/go/config" -) - -const ( - testAppCoreYAML = "app:\n name: core\n" - testAppNameKey = "app.name" - testConfigJSONPath = "/config.json" - testConfigNotLoaded = "config not loaded" - testConfigTextPath = "/config.txt" - testConfigYAMLPath = "/config.yaml" - testDefaultConfigPathSuffix = ".core/config.yaml" - testDevEditorKey = "dev.editor" - testDotEnvPath = "/.env" - testExtraYAMLPath = "/extra.yaml" - testMissingYAMLPath = "/missing.yaml" - testCustomConfigJSONPath = "/custom/config.json" - testUnsupportedConfigFileType = "unsupported config file type" - testAgentCodexText = "agent: codex" - testAgentCodexYAML = testAgentCodexText + "\n" - testFooBarEnv = "AX_CONFIG_FOO_BAR" - testFooBarKey = "foo.bar" - testFooBarValue = "baz" - testAXConfigPrefix = "AX_CONFIG" - testAXConfigPrefixWithSeparator = "AX_CONFIG_" - testOtherConfigPrefix = "OTHER_CONFIG_" -) - -type refusingMedium struct{} - -func (refusingMedium) Exists(string) bool { return true } - -func (refusingMedium) Read(string) Result { - return Fail(NewError("read refused")) -} - -func (refusingMedium) Write(string, string) Result { - return Fail(NewError("write refused")) -} - -func (refusingMedium) EnsureDir(string) Result { - return Fail(NewError("mkdir refused")) -} - -func configTestFS(t *T) *Fs { - t.Helper() - root := PathEvalSymlinks(t.TempDir()) - RequireTrue(t, root.OK, root.Error()) - return (&Fs{}).New(root.Value.(string)) -} - -func configTestMedium(t *T) (*Fs, string) { - t.Helper() - return configTestFS(t), testConfigYAMLPath -} - -func writeConfigFile(t *T, fs *Fs, path, content string) { - t.Helper() - r := fs.Write(path, content) - RequireTrue(t, r.OK, r.Error()) -} - -func requireConfig(t *T, r Result) *config.Config { - t.Helper() - RequireTrue(t, r.OK, r.Error()) - cfg, ok := r.Value.(*config.Config) - RequireTrue(t, ok) - return cfg -} - -func configWithDefaultPath(t *T) *config.Config { - t.Helper() - fs := configTestFS(t) - return requireConfig(t, config.New(config.WithMedium(fs), config.WithPath(""))) -} - -func startedService(t *T, opts config.ServiceOptions) *config.Service { - t.Helper() - svc := &config.Service{ServiceRuntime: NewServiceRuntime(nil, opts)} - r := svc.OnStartup(Background()) - RequireTrue(t, r.OK, r.Error()) - return svc -} - -func TestConfig_New_Good(t *T) { - fs, path := configTestMedium(t) - writeConfigFile(t, fs, path, testAppCoreYAML) - - r := config.New(config.WithMedium(fs), config.WithPath(path)) - - AssertTrue(t, r.OK, r.Error()) - cfg := r.Value.(*config.Config) - var name string - get := cfg.Get(testAppNameKey, &name) - AssertTrue(t, get.OK, get.Error()) - AssertEqual(t, "core", name) -} - -func TestConfig_New_Bad(t *T) { - fs := configTestFS(t) - writeConfigFile(t, fs, testConfigTextPath, "app.name=core") - - r := config.New(config.WithMedium(fs), config.WithPath(testConfigTextPath)) - - AssertFalse(t, r.OK) - AssertContains(t, r.Error(), testUnsupportedConfigFileType) -} - -func TestConfig_New_Ugly(t *T) { - fs := configTestFS(t) - writeConfigFile(t, fs, testDotEnvPath, "FOO=bar\n") - - r := config.New(config.WithMedium(fs), config.WithPath(testDotEnvPath)) - - AssertTrue(t, r.OK, r.Error()) - cfg := r.Value.(*config.Config) - var foo string - get := cfg.Get("foo", &foo) - AssertTrue(t, get.OK, get.Error()) - AssertEqual(t, "bar", foo) -} - -func TestConfig_WithMedium_Good(t *T) { - fs, path := configTestMedium(t) - writeConfigFile(t, fs, path, testAgentCodexYAML) - - cfg := requireConfig(t, config.New(config.WithMedium(fs), config.WithPath(path))) - - var agent string - r := cfg.Get("agent", &agent) - AssertTrue(t, r.OK, r.Error()) - AssertEqual(t, "codex", agent) -} - -func TestConfig_WithMedium_Bad(t *T) { - r := config.New(config.WithMedium(nil), config.WithPath(testMissingYAMLPath)) - - AssertTrue(t, r.OK, r.Error()) - cfg := r.Value.(*config.Config) - AssertEqual(t, testMissingYAMLPath, cfg.Path()) -} - -func TestConfig_WithMedium_Ugly(t *T) { - r := config.New(config.WithMedium(refusingMedium{}), config.WithPath(testConfigYAMLPath)) - - AssertFalse(t, r.OK) - AssertContains(t, r.Error(), "read refused") -} - -func TestConfig_WithPath_Good(t *T) { - fs := configTestFS(t) - - cfg := requireConfig(t, config.New(config.WithMedium(fs), config.WithPath("/custom/config.yaml"))) - - AssertEqual(t, "/custom/config.yaml", cfg.Path()) -} - -func TestConfig_WithPath_Bad(t *T) { - fs := configTestFS(t) - writeConfigFile(t, fs, testCustomConfigJSONPath, `{"app":{"name":"core"}}`) - - cfg := requireConfig(t, config.New(config.WithMedium(fs), config.WithPath(testCustomConfigJSONPath))) - - AssertEqual(t, testCustomConfigJSONPath, cfg.Path()) -} - -func TestConfig_WithPath_Ugly(t *T) { - fs := configTestFS(t) - cfg := requireConfig(t, config.New(config.WithMedium(fs), config.WithPath(""))) - path := cfg.Path() - - AssertContains(t, path, testDefaultConfigPathSuffix) -} - -func TestConfig_WithEnvPrefix_Good(t *T) { - t.Setenv("MYAPP_SETTING", "secret") - fs, path := configTestMedium(t) - - cfg := requireConfig(t, config.New(config.WithMedium(fs), config.WithPath(path), config.WithEnvPrefix("MYAPP"))) - - var setting string - r := cfg.Get("setting", &setting) - AssertTrue(t, r.OK, r.Error()) - AssertEqual(t, "secret", setting) -} - -func TestConfig_WithEnvPrefix_Bad(t *T) { - t.Setenv("MYAPP_SETTING", "secret") - fs, path := configTestMedium(t) - - cfg := requireConfig(t, config.New(config.WithMedium(fs), config.WithPath(path), config.WithEnvPrefix("OTHER"))) - - var setting string - r := cfg.Get("setting", &setting) - AssertFalse(t, r.OK) - AssertEqual(t, "", setting) -} - -func TestConfig_WithEnvPrefix_Ugly(t *T) { - t.Setenv("MYAPP_SETTING", "secret") - fs, path := configTestMedium(t) - - cfg := requireConfig(t, config.New(config.WithMedium(fs), config.WithPath(path), config.WithEnvPrefix("MYAPP_"))) - - var setting string - r := cfg.Get("setting", &setting) - AssertTrue(t, r.OK, r.Error()) - AssertEqual(t, "secret", setting) -} - -func TestConfig_Config_Get_Good(t *T) { - fs, path := configTestMedium(t) - cfg := requireConfig(t, config.New(config.WithMedium(fs), config.WithPath(path))) - RequireTrue(t, cfg.Set(testAppNameKey, "core").OK) - - var name string - r := cfg.Get(testAppNameKey, &name) - - AssertTrue(t, r.OK, r.Error()) - AssertEqual(t, "core", name) -} - -func TestConfig_Config_Get_Bad(t *T) { - fs, path := configTestMedium(t) - cfg := requireConfig(t, config.New(config.WithMedium(fs), config.WithPath(path))) - - var missing string - r := cfg.Get("missing.key", &missing) - - AssertFalse(t, r.OK) - AssertContains(t, r.Error(), "key not found") -} - -func TestConfig_Config_Get_Ugly(t *T) { - fs := configTestFS(t) - writeConfigFile(t, fs, testConfigYAMLPath, testAppCoreYAML+"version: 1\n") - cfg := requireConfig(t, config.New(config.WithMedium(fs), config.WithPath(testConfigYAMLPath))) - - var full struct { - App struct { - Name string `mapstructure:"name"` - } `mapstructure:"app"` - Version int `mapstructure:"version"` - } - r := cfg.Get("", &full) - - AssertTrue(t, r.OK, r.Error()) - AssertEqual(t, "core", full.App.Name) - AssertEqual(t, 1, full.Version) -} - -func TestConfig_Config_Set_Good(t *T) { - fs, path := configTestMedium(t) - cfg := requireConfig(t, config.New(config.WithMedium(fs), config.WithPath(path))) - - r := cfg.Set(testDevEditorKey, "vim") - - AssertTrue(t, r.OK, r.Error()) - var editor string - get := cfg.Get(testDevEditorKey, &editor) - AssertTrue(t, get.OK, get.Error()) - AssertEqual(t, "vim", editor) -} - -func TestConfig_Config_Set_Bad(t *T) { - var cfg *config.Config - - r := cfg.Set(testDevEditorKey, "vim") - - AssertFalse(t, r.OK) - AssertContains(t, r.Error(), "config is nil") -} - -func TestConfig_Config_Set_Ugly(t *T) { - fs, path := configTestMedium(t) - cfg := requireConfig(t, config.New(config.WithMedium(fs), config.WithPath(path))) - - r := cfg.Set("feature.enabled", true) - - AssertTrue(t, r.OK, r.Error()) - var enabled bool - get := cfg.Get("feature.enabled", &enabled) - AssertTrue(t, get.OK, get.Error()) - AssertTrue(t, enabled) -} - -func TestConfig_Config_Commit_Good(t *T) { - fs, path := configTestMedium(t) - cfg := requireConfig(t, config.New(config.WithMedium(fs), config.WithPath(path))) - RequireTrue(t, cfg.Set(testDevEditorKey, "vim").OK) - - r := cfg.Commit() - - AssertTrue(t, r.OK, r.Error()) - content := fs.Read(path) - RequireTrue(t, content.OK, content.Error()) - AssertContains(t, content.Value.(string), "editor: vim") -} - -func TestConfig_Config_Commit_Bad(t *T) { - fs := configTestFS(t) - cfg := requireConfig(t, config.New(config.WithMedium(fs), config.WithPath(testConfigJSONPath))) - RequireTrue(t, cfg.Set("key", "value").OK) - - r := cfg.Commit() - - AssertFalse(t, r.OK) - AssertContains(t, r.Error(), testUnsupportedConfigFileType) -} - -func TestConfig_Config_Commit_Ugly(t *T) { - fs, path := configTestMedium(t) - cfg := requireConfig(t, config.New(config.WithMedium(fs), config.WithPath(path))) - - r := cfg.Commit() - - AssertTrue(t, r.OK, r.Error()) - content := fs.Read(path) - RequireTrue(t, content.OK, content.Error()) - AssertContains(t, content.Value.(string), "{}") -} - -func TestConfig_Config_All_Good(t *T) { - fs, path := configTestMedium(t) - cfg := requireConfig(t, config.New(config.WithMedium(fs), config.WithPath(path))) - RequireTrue(t, cfg.Set("zulu", "last").OK) - RequireTrue(t, cfg.Set("alpha", "first").OK) - - var keys []string - for key := range cfg.All() { - keys = append(keys, key) - } - - AssertEqual(t, []string{"alpha", "zulu"}, keys) -} - -func TestConfig_Config_All_Bad(t *T) { - fs, path := configTestMedium(t) - cfg := requireConfig(t, config.New(config.WithMedium(fs), config.WithPath(path))) - - var keys []string - for key := range cfg.All() { - keys = append(keys, key) - } - - AssertEmpty(t, keys) -} - -func TestConfig_Config_All_Ugly(t *T) { - fs, path := configTestMedium(t) - cfg := requireConfig(t, config.New(config.WithMedium(fs), config.WithPath(path))) - RequireTrue(t, cfg.Set("alpha", "first").OK) - seq := cfg.All() - RequireTrue(t, cfg.Set("beta", "second").OK) - - var keys []string - for key := range seq { - keys = append(keys, key) - } - - AssertEqual(t, []string{"alpha"}, keys) -} - -func TestConfig_Config_Path_Good(t *T) { - fs := configTestFS(t) - - cfg := requireConfig(t, config.New(config.WithMedium(fs), config.WithPath("/custom/path/config.yaml"))) - - AssertEqual(t, "/custom/path/config.yaml", cfg.Path()) -} - -func TestConfig_Config_Path_Bad(t *T) { - fs := configTestFS(t) - - cfg := requireConfig(t, config.New(config.WithMedium(fs))) - - AssertContains(t, cfg.Path(), testDefaultConfigPathSuffix) -} - -func TestConfig_Config_Path_Ugly(t *T) { - cfg := configWithDefaultPath(t) - path := cfg.Path() - - AssertContains(t, path, testDefaultConfigPathSuffix) -} - -func TestConfig_Config_LoadFile_Good(t *T) { - fs, path := configTestMedium(t) - writeConfigFile(t, fs, testExtraYAMLPath, testAgentCodexYAML) - cfg := requireConfig(t, config.New(config.WithMedium(fs), config.WithPath(path))) - - r := cfg.LoadFile(fs, testExtraYAMLPath) - - AssertTrue(t, r.OK, r.Error()) - var agent string - get := cfg.Get("agent", &agent) - AssertTrue(t, get.OK, get.Error()) - AssertEqual(t, "codex", agent) -} - -func TestConfig_Config_LoadFile_Bad(t *T) { - fs, path := configTestMedium(t) - cfg := requireConfig(t, config.New(config.WithMedium(fs), config.WithPath(path))) - - r := cfg.LoadFile(fs, testMissingYAMLPath) - - AssertFalse(t, r.OK) - AssertContains(t, r.Error(), "failed to read config file") -} - -func TestConfig_Config_LoadFile_Ugly(t *T) { - fs, path := configTestMedium(t) - writeConfigFile(t, fs, testDotEnvPath, "TOKEN=abc\n") - cfg := requireConfig(t, config.New(config.WithMedium(fs), config.WithPath(path))) - - r := cfg.LoadFile(fs, testDotEnvPath) - - AssertTrue(t, r.OK, r.Error()) - var token string - get := cfg.Get("token", &token) - AssertTrue(t, get.OK, get.Error()) - AssertEqual(t, "abc", token) -} - -func TestConfig_Load_Good(t *T) { - fs := configTestFS(t) - writeConfigFile(t, fs, testConfigYAMLPath, testAppCoreYAML) - - r := config.Load(fs, testConfigYAMLPath) - - AssertTrue(t, r.OK, r.Error()) - data := r.Value.(map[string]any) - app := data["app"].(map[string]any) - AssertEqual(t, "core", app["name"]) -} - -func TestConfig_Load_Bad(t *T) { - fs := configTestFS(t) - writeConfigFile(t, fs, testConfigJSONPath, `{"app":{"name":"core"}}`) - - r := config.Load(fs, testConfigJSONPath) - - AssertFalse(t, r.OK) - AssertContains(t, r.Error(), testUnsupportedConfigFileType) -} - -func TestConfig_Load_Ugly(t *T) { - fs := configTestFS(t) - writeConfigFile(t, fs, "/config", testAppCoreYAML) - - r := config.Load(fs, "/config") - - AssertTrue(t, r.OK, r.Error()) - data := r.Value.(map[string]any) - app := data["app"].(map[string]any) - AssertEqual(t, "core", app["name"]) -} - -func TestConfig_Save_Good(t *T) { - fs := configTestFS(t) - - r := config.Save(fs, testConfigYAMLPath, map[string]any{"key": "value"}) - - AssertTrue(t, r.OK, r.Error()) - content := fs.Read(testConfigYAMLPath) - RequireTrue(t, content.OK, content.Error()) - AssertContains(t, content.Value.(string), "key: value") -} - -func TestConfig_Save_Bad(t *T) { - fs := configTestFS(t) - - r := config.Save(fs, testConfigJSONPath, map[string]any{"key": "value"}) - - AssertFalse(t, r.OK) - AssertContains(t, r.Error(), testUnsupportedConfigFileType) -} - -func TestConfig_Save_Ugly(t *T) { - r := config.Save(refusingMedium{}, testConfigYAMLPath, map[string]any{"key": "value"}) - - AssertFalse(t, r.OK) - AssertContains(t, r.Error(), "mkdir refused") -} diff --git a/docs/architecture.md b/docs/architecture.md index 839e1ea..7dcd6da 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,87 +1,186 @@ --- title: Architecture -description: Internal design of dappco.re/go/config. +description: Internal design of config -- dual-viper layering, the Medium abstraction, and the framework service wrapper. --- # Architecture -The package is built around one invariant: the configuration a service reads is -not the same thing as the configuration that should be persisted. Runtime -environment values may override file values, but they must never be written back -to disk by `Commit`. +## Design Goals -## Config +1. **Layered resolution** -- a single `Get()` call checks environment, file, and defaults without the caller needing to know which source won. +2. **Safe persistence** -- environment variables must never bleed into the saved config file. +3. **Pluggable storage** -- the file system is abstracted behind `io.Medium`, making tests deterministic and enabling future remote backends. +4. **Framework integration** -- the package satisfies the `core.Config` interface, so any Core service can consume configuration without importing this package directly. -`Config` owns two Viper instances and a small amount of Core-specific state: +## Key Types + +### Config ```go type Config struct { - mu sync.RWMutex - full *viper.Viper - file *viper.Viper - medium Medium - path string - envPrefix string - overrides map[string]any + mu sync.RWMutex + full *viper.Viper // full configuration (file + env + defaults) + file *viper.Viper // file-backed configuration only (for persistence) + medium coreio.Medium + path string } ``` -`file` stores configuration loaded from files plus explicit `Set` calls. -`full` is rebuilt from `file`, the current environment, and explicit overrides. -Reads use `full`; persistence uses `file`. +`Config` is the central type. It holds **two** Viper instances: + +- **`full`** -- contains everything: file values, environment bindings, and explicit `Set()` calls. All reads go through `full`. +- **`file`** -- contains only values that originated from the config file or were explicitly set via `Set()`. All writes (`Commit()`) go through `file`. -## Source Priority +This dual-instance design is the key architectural decision. It solves the environment leakage problem: Viper merges environment variables into its settings map, which means a naive `SaveConfig()` would serialise env vars into the YAML file. By maintaining `file` as a clean copy, `Commit()` only persists what should be persisted. -The effective read view is assembled in this order: +### Option -1. File-backed settings. -2. Environment variables for the configured prefix. -3. Explicit runtime overrides from `Set`. +```go +type Option func(*Config) +``` -The override map exists because explicit `Set` values are persisted in `file` -but must still outrank environment variables in the read view. +Functional options configure the `Config` at creation time: -## Storage +| Option | Effect | +|-------------------|----------------------------------------------------| +| `WithMedium(m)` | Sets the storage backend (defaults to `io.Local`) | +| `WithPath(path)` | Sets the config file path (defaults to `~/.core/config.yaml`) | +| `WithEnvPrefix(p)`| Changes the environment variable prefix (defaults to `CORE_CONFIG`) | -All file I/O goes through the local `Medium` interface: +### Service ```go -type Medium interface { - Exists(path string) bool - Read(path string) core.Result - Write(path, content string) core.Result - EnsureDir(path string) core.Result +type Service struct { + *core.ServiceRuntime[ServiceOptions] + config *Config } ``` -The default implementation is `(&core.Fs{}).New("/")`. Tests pass a rooted -`core.Fs` created inside `t.TempDir`, which keeps filesystem state deterministic. +`Service` wraps `Config` as a framework-managed service. It embeds `ServiceRuntime` for typed options and implements two interfaces: -## File Loading +- **`core.Config`** -- `Get(key, out)` and `Set(key, v)` +- **`core.Startable`** -- `OnStartup(ctx)` triggers config file loading during the application lifecycle -`LoadFile` detects the format from the path: +The service is registered as a factory function: -- `.yaml`, `.yml`, and extensionless files load as YAML. -- `.json` loads as JSON. -- `.toml` loads as TOML. -- `.env` loads as dotenv. +```go +core.New(core.WithService(config.NewConfigService)) +``` + +The factory receives the `*core.Core` instance, constructs the service, and returns it. The framework calls `OnStartup` at the appropriate lifecycle phase, at which point the config file is loaded. + +### Env / LoadEnv + +```go +func Env(prefix string) iter.Seq2[string, any] +func LoadEnv(prefix string) map[string]any // deprecated +``` -Parsed settings merge into `file`, then `full` is rebuilt. A later `Commit` -therefore persists loaded file data and explicit runtime writes, not environment -snapshots. +`Env` returns a Go 1.23+ iterator over environment variables matching a given prefix, yielding `(dotKey, value)` pairs. The conversion logic: -## Service Wrapper +1. Filter `os.Environ()` for entries starting with `prefix` +2. Strip the prefix +3. Lowercase and replace `_` with `.` + +`LoadEnv` is the older materialising variant and is deprecated in favour of the iterator. + +## Data Flow + +### Initialisation (`New`) + +``` +New(opts...) + | + +-- create two viper instances (full, file) + +-- set env prefix on full ("CORE_CONFIG_" default) + +-- set env key replacer ("." <-> "_") + +-- apply functional options + +-- default medium to io.Local if nil + +-- default path to ~/.core/config.yaml if empty + +-- enable full.AutomaticEnv() + +-- if config file exists: + LoadFile(medium, path) + +-- medium.Read(path) + +-- detect config type from extension + +-- file.MergeConfig(content) + +-- full.MergeConfig(content) +``` + +### Read (`Get`) + +``` +Get(key, &out) + | + +-- RLock + +-- if key == "": + | full.Unmarshal(out) // full config into a struct + +-- else: + | full.IsSet(key)? + | yes -> full.UnmarshalKey(key, out) + | no -> error "key not found" + +-- RUnlock +``` -`Service` embeds `core.ServiceRuntime[ServiceOptions]` and initialises `Config` -during `OnStartup`. Its `Get`, `Set`, `Commit`, and `LoadFile` methods return -`core.Result` and delegate to the loaded `Config`. +Because `full` has `AutomaticEnv()` enabled, `full.IsSet(key)` returns true if the key exists in the file **or** as a `CORE_CONFIG_*` environment variable. -`NewConfigService` is the Core service factory. It returns a `core.Result` -containing `*Service`, so it can be passed to `core.WithService`. +### Write (`Set` + `Commit`) + +``` +Set(key, value) + | + +-- Lock + +-- file.Set(key, value) // track for persistence + +-- full.Set(key, value) // make visible to Get() + +-- Unlock + +Commit() + | + +-- Lock + +-- Save(medium, path, file.AllSettings()) + | +-- yaml.Marshal(data) + | +-- medium.EnsureDir(dir) + | +-- medium.Write(path, content) + +-- Unlock +``` + +Note that `Commit()` serialises `file.AllSettings()`, not `full.AllSettings()`. This is intentional -- it prevents environment variable values from being written to the config file. + +### File Loading (`LoadFile`) + +`LoadFile` supports YAML, JSON, TOML, and `.env` files. The config type is inferred from the file extension: + +- `.yaml`, `.yml` -- YAML +- `.json` -- JSON +- `.toml` -- TOML +- `.env` (no extension, basename `.env`) -- dotenv format + +Content is merged (not replaced) into both `full` and `file` via `MergeConfig`, so multiple files can be layered. ## Concurrency -Public `Config` methods lock around Viper access. `Get` and `All` refresh the -read view so environment changes made before the call are visible. `All` returns -a snapshot iterator; later `Set` calls do not mutate an iterator already -returned to the caller. +All public methods on `Config` and `Service` are safe for concurrent use. A `sync.RWMutex` protects the internal state: + +- `Get`, `All` take a read lock +- `Set`, `Commit`, `LoadFile` take a write lock + +## The Medium Abstraction + +File operations go through `io.Medium` (from `dappco.re/go/core/io`), not `os` directly. This means: + +- **Tests** use `io.NewMockMedium()` -- an in-memory filesystem with a `Files` map +- **Production** uses `io.Local` -- the real local filesystem +- **Future** backends (S3, embedded assets) can implement `Medium` without changing config code + +## Compile-Time Interface Checks + +The package includes a compile-time assertion that the `Service` type satisfies `core.Startable`: + +```go +var _ core.Startable = (*Service)(nil) // service.go +``` + +`core.Config` is a concrete struct in upstream Core (not an interface), so this package provides its own `*Config` implementation alongside the Core one. Framework consumers interact with `*config.Service` directly via `core.ServiceFor[*config.Service]`. + +## Licence + +EUPL-1.2 diff --git a/docs/core-folder-spec.md b/docs/core-folder-spec.md new file mode 100644 index 0000000..a185db1 --- /dev/null +++ b/docs/core-folder-spec.md @@ -0,0 +1,319 @@ +# .core/ Folder Specification + +This document defines the `.core/` folder structure used across Host UK packages for configuration, tooling integration, and development environment setup. + +## Overview + +The `.core/` folder provides a standardised location for: +- Build and development configuration +- Claude Code plugin integration +- VM/container definitions +- Development environment settings + +## Directory Structure + +``` +package/.core/ +├── config.yaml # Build targets, test commands, deploy config +├── workspace.yaml # Workspace-level config (devops repo only) +├── plugin/ # Claude Code integration +│ ├── plugin.json # Plugin manifest +│ ├── skills/ # Context-aware skills +│ └── hooks/ # Pre/post command hooks +├── linuxkit/ # VM/container definitions (if applicable) +│ ├── kernel.yaml +│ └── image.yaml +└── run.yaml # Development environment config +``` + +## Configuration Files + +### config.yaml + +Package-level build and runtime configuration. + +```yaml +version: 1 + +# Build configuration +build: + targets: + - name: default + command: composer build + - name: production + command: composer build:prod + env: + APP_ENV: production + +# Test configuration +test: + command: composer test + coverage: true + parallel: true + +# Lint configuration +lint: + command: ./vendor/bin/pint + fix_command: ./vendor/bin/pint --dirty + +# Deploy configuration (if applicable) +deploy: + staging: + command: ./deploy.sh staging + production: + command: ./deploy.sh production + requires_approval: true +``` + +### workspace.yaml + +Workspace-level configuration (only in `core-devops`). + +```yaml +version: 1 + +# Active package for unified commands +active: core-php + +# Default package types for setup +default_only: + - foundation + - module + +# Paths +packages_dir: ./packages + +# Workspace settings +settings: + suggest_core_commands: true + show_active_in_prompt: true +``` + +### run.yaml + +Development environment configuration. + +```yaml +version: 1 + +# Services required for development +services: + - name: database + image: postgres:16 + port: 5432 + env: + POSTGRES_DB: core_dev + POSTGRES_USER: core + POSTGRES_PASSWORD: secret + + - name: redis + image: redis:7 + port: 6379 + + - name: mailpit + image: axllent/mailpit + port: 8025 + +# Development server +dev: + command: php artisan serve + port: 8000 + watch: + - app/ + - resources/ + +# Environment variables +env: + APP_ENV: local + APP_DEBUG: true + DB_CONNECTION: pgsql +``` + +## Claude Code Plugin + +### plugin.json + +The plugin manifest defines skills, hooks, and commands for Claude Code integration. + +```json +{ + "$schema": "https://claude.ai/code/plugin-schema.json", + "name": "package-name", + "version": "1.0.0", + "description": "Claude Code integration for this package", + + "skills": [ + { + "name": "skill-name", + "file": "skills/skill-name.md", + "description": "What this skill provides" + } + ], + + "hooks": { + "pre_command": [ + { + "pattern": "^command-pattern$", + "script": "hooks/script.sh", + "description": "What this hook does" + } + ] + }, + + "commands": { + "command-name": { + "description": "What this command does", + "run": "actual-command" + } + } +} +``` + +### Skills (skills/*.md) + +Markdown files providing context-aware guidance for Claude Code. Skills are loaded when relevant to the user's query. + +```markdown +# Skill Name + +Describe what this skill provides. + +## Context + +When to use this skill. + +## Commands + +Relevant commands and examples. + +## Tips + +Best practices and gotchas. +``` + +### Hooks (hooks/*.sh) + +Shell scripts executed before or after commands. Hooks should: +- Be executable (`chmod +x`) +- Exit 0 for informational hooks (don't block) +- Exit non-zero to block the command (with reason) + +```bash +#!/bin/bash +set -euo pipefail + +# Hook logic here + +exit 0 # Don't block +``` + +## LinuxKit (linuxkit/) + +For packages that deploy as VMs or containers. + +### kernel.yaml + +```yaml +kernel: + image: linuxkit/kernel:6.6 + cmdline: "console=tty0" +``` + +### image.yaml + +```yaml +image: + - linuxkit/init:v1.0.1 + - linuxkit/runc:v1.0.0 + - linuxkit/containerd:v1.0.0 +``` + +## Package-Type Specific Patterns + +### Foundation (core-php) + +``` +core-php/.core/ +├── config.yaml # Build targets for framework +├── plugin/ +│ └── skills/ +│ ├── events.md # Event system guidance +│ ├── modules.md # Module loading patterns +│ └── lifecycle.md # Lifecycle events +└── run.yaml # Test environment setup +``` + +### Module (core-tenant, core-admin, etc.) + +``` +core-tenant/.core/ +├── config.yaml # Module-specific build +├── plugin/ +│ └── skills/ +│ └── tenancy.md # Multi-tenancy patterns +└── run.yaml # Required services (database) +``` + +### Product (core-bio, core-social, etc.) + +``` +core-bio/.core/ +├── config.yaml # Build and deploy targets +├── plugin/ +│ └── skills/ +│ └── bio.md # Product-specific guidance +├── linuxkit/ # VM definitions for deployment +│ ├── kernel.yaml +│ └── image.yaml +└── run.yaml # Full dev environment +``` + +### Workspace (core-devops) + +``` +core-devops/.core/ +├── workspace.yaml # Active package, paths +├── plugin/ +│ ├── plugin.json +│ └── skills/ +│ ├── workspace.md # Multi-repo navigation +│ ├── switch-package.md # Package switching +│ └── package-status.md # Status checking +└── docs/ + └── core-folder-spec.md # This file +``` + +## Core CLI Integration + +The `core` CLI reads configuration from `.core/`: + +| File | CLI Command | Purpose | +|------|-------------|---------| +| `workspace.yaml` | `core workspace` | Active package, paths | +| `config.yaml` | `core build`, `core test` | Build/test commands | +| `run.yaml` | `core run` | Dev environment | + +## Best Practices + +1. **Always include `version: 1`** in YAML files for future compatibility +2. **Keep skills focused** - one concept per skill file +3. **Hooks should be fast** - don't slow down commands +4. **Use relative paths** - avoid hardcoded absolute paths +5. **Document non-obvious settings** with inline comments + +## Migration Guide + +To add `.core/` to an existing package: + +1. Create the directory structure: + ```bash + mkdir -p .core/plugin/skills .core/plugin/hooks + ``` + +2. Add `config.yaml` with build/test commands + +3. Add `plugin.json` with package-specific skills + +4. Add relevant skills in `skills/` + +5. Update `.gitignore` if needed (don't ignore `.core/`) diff --git a/docs/development.md b/docs/development.md index 9b97f5a..ac49ea0 100644 --- a/docs/development.md +++ b/docs/development.md @@ -1,67 +1,121 @@ --- title: Development -description: Build, test, and compliance workflow for dappco.re/go/config. +description: How to build, test, and contribute to config. --- # Development -This module is a consumer of `dappco.re/go`. Its local style is defined by the -v0.9.0 compliance audit in the Core repository. +## Prerequisites -## Required Checks +- **Go 1.26+** +- **Core CLI** (`core` binary) for running tests and quality checks +- The Go workspace at `~/Code/go.work` should include this module -Run these from the repository root: +## Running Tests ```bash -GOWORK=off go mod tidy -GOWORK=off go vet ./... -GOWORK=off go test -count=1 ./... -gofmt -l . -bash /Users/snider/Code/core/go/tests/cli/v090-upgrade/audit.sh . +cd /path/to/config + +# All tests +core go test + +# Single test +core go test --run TestConfig_Get_Good + +# With coverage +core go cov +core go cov --open # opens HTML report in browser ``` -The audit script is authoritative. A change is incomplete until the audit -prints `verdict: COMPLIANT` and every counter is `0`. +### Test Naming Convention -## Test Layout +Tests follow the `_Good` / `_Bad` / `_Ugly` suffix pattern: -Tests are file-aware. A public symbol in `config.go` belongs in -`config_test.go`, a public symbol in `env.go` belongs in `env_test.go`, and a -public symbol in `service.go` belongs in `service_test.go`. +| Suffix | Meaning | +|---------|---------------------------------| +| `_Good` | Happy path -- expected success | +| `_Bad` | Expected error conditions | +| `_Ugly` | Panics, edge cases, corruption | -Each public function or method has three variants: +### Mock Medium -- `Good` for the normal success path. -- `Bad` for expected failure. -- `Ugly` for edge cases such as nil receivers, empty inputs, or boundary - behaviour. +Tests use `io.NewMockMedium()` to avoid touching the real filesystem. Pre-populate it by writing directly to the `Files` map: + +```go +m := io.NewMockMedium() +m.Files["/tmp/test/config.yaml"] = "app:\n name: existing\n" + +cfg, err := config.New(config.WithMedium(m), config.WithPath("/tmp/test/config.yaml")) +``` -The canonical name is `Test__`. Do not add aggregate -test files, versioned test files, or AX-7 dump files. +This pattern keeps tests fast, deterministic, and parallelisable. -## Examples +## Quality Checks + +```bash +# Format, vet, lint, test in one pass +core go qa + +# Full suite (adds race detector, vulnerability scan, security audit) +core go qa full + +# Individual commands +core go fmt +core go vet +core go lint +``` -Each source file has a matching `_example_test.go` file. Examples must execute -the symbol they document and print stable output through `core.Println`. -Examples should avoid dynamic paths or map iteration unless the output is -normalised first. +## Code Style -## Core Wrapper Policy +- **UK English** in comments and documentation (colour, organisation, centre) +- **`declare(strict_types=1)`** equivalent: all functions have explicit parameter and return types +- **Error wrapping**: use `coreerr.E(caller, message, underlying)` from `go-log` +- **Formatting**: standard `gofmt` / `goimports` + +## Project Structure + +``` +config/ + .core/ + build.yaml # Build configuration (targets, flags) + release.yaml # Release configuration (changelog rules) + config.go # Config struct, New(), Get/Set/Commit, Load/Save + config_test.go # Tests + env.go # Env() iterator, LoadEnv() (deprecated) + service.go # Framework service wrapper (Startable) + go.mod + go.sum + docs/ + index.md # This documentation + architecture.md # Internal design + development.md # Build and contribution guide +``` + +## Adding a New Feature + +1. **Write the test first** -- add a `TestFeatureName_Good` (and `_Bad` if error paths exist) to `config_test.go`. +2. **Implement** -- keep the dual-viper invariant: writes go to both `v` and `f`; reads come from `v`; persistence comes from `f`. +3. **Run QA** -- `core go qa` must pass before committing. +4. **Update docs** -- if the change affects public API, update `docs/index.md` and `docs/architecture.md`. + +## Interface Compliance + +`Config` and `Service` both satisfy `core.Config`. `Service` additionally satisfies `core.Startable`. These are enforced at compile time: + +```go +var _ core.Config = (*Config)(nil) +var _ core.Config = (*Service)(nil) +var _ core.Startable = (*Service)(nil) +``` -Consumer code and tests should not import banned stdlib packages directly. -Common replacements are: +If you add a new interface method upstream in `core/go`, the compiler will tell you what to implement here. -- `core.Sprintf`, `core.Println`, and `core.Print` for formatting. -- `core.PathExt`, `core.PathBase`, `core.PathDir`, and `core.Path` for paths. -- `core.Environ`, `core.Setenv`, and `core.Unsetenv` for environment access. -- `core.NewReader`, `core.SplitN`, `core.Replace`, `core.Lower`, and - `core.TrimPrefix` for text helpers. +## Commit Guidelines -## Adding Behaviour +- Use conventional commits: `type(scope): description` +- Include `Co-Authored-By: Claude Opus 4.6 ` when pair-programming with Claude +- Push via SSH: `ssh://git@forge.lthn.ai:2223/core/config.git` -When adding a public symbol: +## Licence -1. Add its Good, Bad, and Ugly tests to the matching source test file. -2. Add its runnable example to the matching source example file. -3. Keep production return values in `core.Result` shape. -4. Run the required checks before handing the branch back. +EUPL-1.2 diff --git a/docs/index.md b/docs/index.md index 1082ad4..b27519e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,64 +1,147 @@ --- -title: go-config -description: Layered configuration management for dappco.re/go services. +title: config +description: Layered configuration management for the Core framework with file, environment, and in-memory resolution. --- -# go-config +# config -`dappco.re/go/config` is the configuration package for Core-based services. It -loads structured settings from YAML, JSON, TOML, dotenv files, environment -variables, and explicit runtime overrides while presenting a small -`core.Result`-based API. +`dappco.re/go/core/config` provides layered configuration management for applications built on the Core framework. It resolves values through a priority chain -- defaults, file, environment variables, and explicit `Set()` calls -- so that the same codebase works identically across local development, CI, and production without code changes. -## Module +## Module Path -```text -dappco.re/go/config +``` +dappco.re/go/core/config ``` -The module requires Go 1.26 or newer. +Requires **Go 1.26+**. -## Main APIs +## Quick Start -- `New(opts ...Option) core.Result` constructs a `*Config`. -- `WithMedium`, `WithPath`, and `WithEnvPrefix` customise storage, path, and - environment prefix. -- `(*Config).Get`, `Set`, `Commit`, `LoadFile`, `All`, and `Path` operate on a - configuration instance. -- `Env` iterates prefixed environment variables as lower-case dot keys. -- `NewConfigService` exposes the same configuration surface as a Core service. +### Standalone usage -## Result Shape +```go +package main + +import ( + "fmt" + config "dappco.re/go/core/config" +) + +func main() { + cfg, err := config.New() // loads ~/.core/config.yaml if it exists + if err != nil { + panic(err) + } + + // Write a value and persist it + _ = cfg.Set("dev.editor", "vim") + _ = cfg.Commit() + + // Read it back + var editor string + _ = cfg.Get("dev.editor", &editor) + fmt.Println(editor) // "vim" +} +``` -All operational APIs return `core.Result`. Callers should branch on `r.OK`: +### As a Core framework service ```go -r := config.New() -if !r.OK { - panic(r.Error()) -} -cfg := r.Value.(*config.Config) +import ( + config "dappco.re/go/core/config" + "dappco.re/go/core" +) + +app, _ := core.New( + core.WithService(config.NewConfigService), +) +// The config service loads automatically during OnStartup. +// Retrieve it later via core.ServiceFor[*config.Service](app). +``` + +## Package Layout + +| File | Purpose | +|----------------|----------------------------------------------------------------| +| `config.go` | Core `Config` struct -- layered Get/Set, file load, commit | +| `conclave.go` | Conclave-scoped config (`ForConclave`, `SetConclaveRootFunc`) | +| `discover.go` | `.core/` directory walk (`Discover`, `CoreDirs`, `FindManifest`) | +| `env.go` | Environment variable iteration and prefix-based loading | +| `feature.go` | Feature flags (`Feature`, `SetFeatureSource`, env overrides) | +| `manifest.go` | Known file constants + typed manifests (`BuildManifest`, ...) | +| `service.go` | Framework service wrapper with lifecycle (`Startable`) support | +| `watch.go` | Filesystem watcher with 100ms debounce + `OnChange` callbacks | +| `xdg.go` | Platform-aware XDG paths (`Config`, `Data`, `Cache`, `Runtime`) | +| `*_test.go` | Tests following the `_Good` / `_Bad` / `_Ugly` convention | + +## Dependencies + +| Module | Role | +|-----------------------------------|-----------------------------------------| +| `dappco.re/go/core` | Core framework (`core.Core`, `ServiceRuntime`, primitives) | +| `dappco.re/go/core/io` | Storage abstraction (`Medium` for reading/writing files) | +| `dappco.re/go/core/log` | Contextual error helper (`E()`) | +| `github.com/spf13/viper` | Underlying configuration engine | +| `gopkg.in/yaml.v3` | YAML serialisation for `Commit()` | + +## Configuration Priority + +Values are resolved in ascending priority order: + +1. **Defaults** -- hardcoded fallbacks (via `Set()` before any file load) +2. **File** -- YAML loaded from `~/.core/config.yaml` (or a custom path) +3. **Environment variables** -- prefixed with `CORE_CONFIG_` by default +4. **Explicit Set()** -- in-memory overrides applied at runtime + +Environment variables always override file values. An explicit `Set()` call overrides everything. + +## Key Access + +All keys use **dot notation** for nested values: + +```go +cfg.Set("a.b.c", "deep") + +var val string +cfg.Get("a.b.c", &val) // "deep" ``` -This is the same shape used by `dappco.re/go` for filesystem, JSON, process, -and service operations. +This maps to YAML structure: + +```yaml +a: + b: + c: deep +``` + +## Environment Variable Mapping + +Environment variables are mapped to dot-notation keys by: -## Environment Mapping +1. Stripping the prefix (default `CORE_CONFIG_`) +2. Lowercasing +3. Replacing `_` with `.` -Environment variables are mapped by stripping the configured prefix, lowering -the remaining name, and replacing `_` with `.`. With the default prefix, -`CORE_CONFIG_DEV_EDITOR=vim` becomes key `dev.editor`. +For example, `CORE_CONFIG_DEV_EDITOR=nano` resolves to key `dev.editor` with value `"nano"`. -Use `WithEnvPrefix("MYAPP")` when an application has its own prefix. The -trailing underscore is optional. +You can change the prefix with `WithEnvPrefix`: -## Persistence +```go +cfg, _ := config.New(config.WithEnvPrefix("MYAPP")) +// MYAPP_SETTING=secret -> key "setting" +``` + +## Persisting Changes + +`Set()` only writes to memory. Call `Commit()` to flush changes to disk: + +```go +cfg.Set("dev.editor", "vim") +cfg.Commit() // writes to ~/.core/config.yaml +``` -`Set` updates both the persisted view and the read view. `Commit` writes only -the persisted view to disk. Environment variables participate in reads but are -not written back, which avoids leaking deployment secrets into config files. +`Commit()` only persists values that were loaded from the file or explicitly set via `Set()`. Environment variable values are never leaked into the config file. -## Related Pages +## Licence -- [Architecture](architecture.md) -- [Development](development.md) +EUPL-1.2 diff --git a/env_example_test.go b/env_example_test.go deleted file mode 100644 index 3d38184..0000000 --- a/env_example_test.go +++ /dev/null @@ -1,30 +0,0 @@ -package config_test - -import ( - . "dappco.re/go" - config "dappco.re/go/config" -) - -func ExampleEnv() { - _ = Setenv("GO_CONFIG_EXAMPLE_HOST", "localhost") - defer func() { - _ = Unsetenv("GO_CONFIG_EXAMPLE_HOST") - }() - - for key, value := range config.Env("GO_CONFIG_EXAMPLE") { - Println(key, value) - } - // Output: host localhost -} - -func ExampleLoadEnv() { - _ = Setenv("GO_CONFIG_EXAMPLE_HOST", "localhost") - defer func() { - _ = Unsetenv("GO_CONFIG_EXAMPLE_HOST") - }() - - data := config.LoadEnv("GO_CONFIG_EXAMPLE") - - Println(data["host"]) - // Output: localhost -} diff --git a/env_test.go b/env_test.go deleted file mode 100644 index 88191fe..0000000 --- a/env_test.go +++ /dev/null @@ -1,69 +0,0 @@ -package config_test - -import ( - . "dappco.re/go" - config "dappco.re/go/config" -) - -func TestEnv_Env_Good(t *T) { - t.Setenv(testFooBarEnv, testFooBarValue) - t.Setenv("AX_CONFIG_ALPHA", "first") - - var keys []string - var values []any - for key, value := range config.Env(testAXConfigPrefixWithSeparator) { - keys = append(keys, key) - values = append(values, value) - } - - AssertEqual(t, []string{"alpha", testFooBarKey}, keys) - AssertEqual(t, []any{"first", testFooBarValue}, values) -} - -func TestEnv_Env_Bad(t *T) { - t.Setenv("AX_CONFIG_FOO", "bar") - - var keys []string - for key := range config.Env(testOtherConfigPrefix) { - keys = append(keys, key) - } - - AssertEmpty(t, keys) -} - -func TestEnv_Env_Ugly(t *T) { - t.Setenv(testFooBarEnv, testFooBarValue) - - var keys []string - for key := range config.Env(testAXConfigPrefix) { - keys = append(keys, key) - } - - AssertEqual(t, []string{testFooBarKey}, keys) -} - -func TestEnv_LoadEnv_Good(t *T) { - t.Setenv(testFooBarEnv, testFooBarValue) - - data := config.LoadEnv(testAXConfigPrefixWithSeparator) - - AssertLen(t, data, 1) - AssertEqual(t, testFooBarValue, data[testFooBarKey]) -} - -func TestEnv_LoadEnv_Bad(t *T) { - t.Setenv(testFooBarEnv, testFooBarValue) - - data := config.LoadEnv(testOtherConfigPrefix) - - AssertEmpty(t, data) -} - -func TestEnv_LoadEnv_Ugly(t *T) { - t.Setenv(testFooBarEnv, testFooBarValue) - - data := config.LoadEnv(testAXConfigPrefix) - - AssertLen(t, data, 1) - AssertEqual(t, testFooBarValue, data[testFooBarKey]) -} diff --git a/external/go b/external/go new file mode 160000 index 0000000..d661b70 --- /dev/null +++ b/external/go @@ -0,0 +1 @@ +Subproject commit d661b703e16183b3cbab101de189f688888a1174 diff --git a/external/io b/external/io new file mode 160000 index 0000000..789653d --- /dev/null +++ b/external/io @@ -0,0 +1 @@ +Subproject commit 789653dfc376383a3873993cdb875c8c717e4b05 diff --git a/external/log b/external/log new file mode 160000 index 0000000..df05298 --- /dev/null +++ b/external/log @@ -0,0 +1 @@ +Subproject commit df0529839b2ab786a6a3da374fa664867d5f9f09 diff --git a/go.mod b/go.mod deleted file mode 100644 index a8892a9..0000000 --- a/go.mod +++ /dev/null @@ -1,24 +0,0 @@ -module dappco.re/go/config - -go 1.26.0 - -require ( - dappco.re/go v0.9.0 - github.com/spf13/viper v1.21.0 // Note: multi-source config loader (YAML/JSON/env/flags); no core equivalent - gopkg.in/yaml.v3 v3.0.1 // Note: YAML decoder; no core equivalent -) - -require ( - github.com/fsnotify/fsnotify v1.9.0 // indirect - github.com/go-viper/mapstructure/v2 v2.5.0 // indirect - github.com/google/go-cmp v0.7.0 // indirect - github.com/pelletier/go-toml/v2 v2.2.4 // indirect - github.com/sagikazarmark/locafero v0.12.0 // indirect - github.com/spf13/afero v1.15.0 // indirect - github.com/spf13/cast v1.10.0 // indirect - github.com/spf13/pflag v1.0.10 // indirect - github.com/subosito/gotenv v1.6.0 // indirect - go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/sys v0.42.0 // indirect - golang.org/x/text v0.35.0 // indirect -) diff --git a/go.sum b/go.sum deleted file mode 100644 index bc169c1..0000000 --- a/go.sum +++ /dev/null @@ -1,46 +0,0 @@ -dappco.re/go v0.9.0 h1:4ruZRNqKDDva8o6g65tYggjGVe42E6/lMZfVKXtr3p0= -dappco.re/go v0.9.0/go.mod h1:xapr7fLK4/9Pu2iSCr4qZuIuatmtx1j56zS/oPDbGyQ= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= -github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= -github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= -github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= -github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= -github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= -github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= -github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= -github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= -github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4= -github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= -github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= -github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= -github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= -github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= -github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= -github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= -github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= -github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= -go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= -go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= -golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/go.work b/go.work new file mode 100644 index 0000000..ddc30ee --- /dev/null +++ b/go.work @@ -0,0 +1,11 @@ +go 1.26.2 + +// Workspace mode for development: pulls fresh code from external/ submodules. +// CI uses GOWORK=off to fall back to go/go.mod tags (reproducible). + +use ( + ./go + ./external/go + ./external/io + ./external/log +) diff --git a/go.work.sum b/go.work.sum new file mode 100644 index 0000000..fc7fefd --- /dev/null +++ b/go.work.sum @@ -0,0 +1,83 @@ +cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc= +cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= +cyphar.com/go-pathrs v0.2.1 h1:9nx1vOgwVvX1mNBWDu93+vaceedpbsDqo+XuBGL40b8= +cyphar.com/go-pathrs v0.2.1/go.mod h1:y8f1EMG7r+hCuFf/rXsKqMJrJAUoADZGNh5/vZPKcGc= +github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= +github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= +github.com/bwesterb/go-ristretto v1.2.3 h1:1w53tCkGhCQ5djbat3+MH0BAQ5Kfgbt56UZQ/JMzngw= +github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-github/v39 v39.2.0 h1:rNNM311XtPOz5rDdsJXAp2o8F67X9FnROXTvto3aSnQ= +github.com/google/go-github/v39 v39.2.0/go.mod h1:C1s8C5aCC9L+JXIYpJM5GYytdX52vC1bLvHEF1IhBrE= +github.com/google/gofuzz v1.0.0 h1:A8PeW59pxE9IoFRqBp37U+mSNaQoZ46F1f0f863XSXw= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ= +github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs= +github.com/jordanlewis/gcassert v0.0.0-20250430164644-389ef753e22e h1:a+PGEeXb+exwBS3NboqXHyxarD9kaboBbrSp+7GuBuc= +github.com/jordanlewis/gcassert v0.0.0-20250430164644-389ef753e22e/go.mod h1:ZybsQk6DWyN5t7An1MuPm1gtSZ1xDaTXS9ZjIOxvQrk= +github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213 h1:qGQQKEcAR99REcMpsXCp3lJ03zYT1PkRd3kQGPn9GVg= +github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213/go.mod h1:vNUNkEQ1e29fT/6vq2aBdFsgNPmy8qMdSay1npru+Sw= +github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= +github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/kr/pty v1.1.1 h1:VkoXIwSboBpnk99O/KFauAEILuNHv5DVFKZMBN/gUgw= +github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY= +github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g= +github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= +github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= +github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A= +github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU= +github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI= +github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw= +github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js= +github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8= +github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M= +github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw= +github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ= +github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4= +github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY= +github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= +github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +github.com/wailsapp/go-webview2 v1.0.23 h1:jmv8qhz1lHibCc79bMM/a/FqOnnzOGEisLav+a0b9P0= +github.com/wailsapp/go-webview2 v1.0.23/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc= +github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs= +github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o= +github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ= +github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k= +github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs= +github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8= +github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= +github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +rsc.io/pdf v0.1.1 h1:k1MczvYDUvJBe93bYd7wrZLLUEcLZAuF824/I4e5Xr4= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/go/AGENTS.md b/go/AGENTS.md new file mode 120000 index 0000000..be77ac8 --- /dev/null +++ b/go/AGENTS.md @@ -0,0 +1 @@ +../AGENTS.md \ No newline at end of file diff --git a/go/CLAUDE.md b/go/CLAUDE.md new file mode 120000 index 0000000..949a29f --- /dev/null +++ b/go/CLAUDE.md @@ -0,0 +1 @@ +../CLAUDE.md \ No newline at end of file diff --git a/go/README.md b/go/README.md new file mode 120000 index 0000000..32d46ee --- /dev/null +++ b/go/README.md @@ -0,0 +1 @@ +../README.md \ No newline at end of file diff --git a/go/conclave.go b/go/conclave.go new file mode 100644 index 0000000..2097fea --- /dev/null +++ b/go/conclave.go @@ -0,0 +1,100 @@ +package config + +import ( + "sync" + + core "dappco.re/go" + coreio "dappco.re/go/io" + coreerr "dappco.re/go/log" +) + +// conclaveRootFn is swapped in by go-session or a similar session-scoped +// storage provider. Until set, ForConclave falls back to ~/.core/conclaves/{name}. +var ( + conclaveMu sync.RWMutex + conclaveRoot = defaultConclaveRoot +) + +const callerForConclave = "config.ForConclave" + +// ConclaveRootFunc resolves the on-disk root directory for a Conclave by name. +// +// config.SetConclaveRootFunc(func(name string) core.Result { +// return core.Ok(session.ConclaveRoot(name)) +// }) +type ConclaveRootFunc func(name string) core.Result + +// SetConclaveRootFunc installs a resolver that maps a Conclave name to its +// on-disk root directory. Passing nil restores the default resolver. +// +// config.SetConclaveRootFunc(resolver) +func SetConclaveRootFunc(fn ConclaveRootFunc) { + conclaveMu.Lock() + defer conclaveMu.Unlock() + if fn == nil { + conclaveRoot = defaultConclaveRoot + return + } + conclaveRoot = fn +} + +// ForConclave returns a Config scoped to the named Conclave. The returned +// config inherits from the parent (project walking up from cwd, then the +// user-global ~/.core/) and overrides with values found in the Conclave's own +// `.core/` directory. Resolution precedence from highest to lowest: +// +// 1. Conclave `{root}/.core/config.yaml` +// +// 2. Project `.core/config.yaml` (and ancestors up to repo boundary) +// +// 3. User-global `~/.core/config.yaml` +// +// alpha, _ := config.ForConclave("workspace-alpha") +// alpha.Get("theme", &theme) +func ForConclave(name string, opts ...Option) core.Result { + conclaveMu.RLock() + resolver := conclaveRoot + conclaveMu.RUnlock() + + rootResult := resolver(name) + if !rootResult.OK { + return core.Fail(coreerr.E(callerForConclave, "failed to resolve conclave root: "+name, resultCause(rootResult).(error))) + } + root := rootResult.Value.(string) + if root == "" { + return core.Fail(coreerr.E(callerForConclave, "failed to resolve conclave root: "+name, nil)) + } + if isSymlinkedCoreDir(coreio.Local, core.Path(root, ".core")) { + return core.Fail(coreerr.E(callerForConclave, "symlinked conclave .core directory rejected: "+root, nil)) + } + + conclaveOpts := append([]Option{}, opts...) + conclaveOpts = append(conclaveOpts, WithPath(core.Path(root, ".core", "config.yaml"))) + + // Project + global inheritance is discovered from the current working dir, + // not the conclave root — the conclave usually sits outside the project + // tree (e.g. under XDG config/conclaves/). Discover() handles ~/.core/ as + // the final fallback layer. + baseResult := Discover(opts...) + if !baseResult.OK { + return core.Fail(coreerr.E(callerForConclave, "failed to discover base config: "+name, resultCause(baseResult).(error))) + } + base := baseResult.Value.(*Config) + + conclaveResult := New(conclaveOpts...) + if !conclaveResult.OK { + return core.Fail(coreerr.E(callerForConclave, "failed to load conclave config: "+name, resultCause(conclaveResult).(error))) + } + conclaveCfg := conclaveResult.Value.(*Config) + + // Conclave wins over base — MergeFrom only fills gaps. + conclaveCfg.MergeFrom(base) + return core.Ok(conclaveCfg) +} + +func defaultConclaveRoot(name string) core.Result { + if !isSafePathElement(name) { + return core.Fail(coreerr.E("config.defaultConclaveRoot", "invalid conclave name: "+name, nil)) + } + return core.Ok(core.Path(XDG().Config(), "conclaves", name)) +} diff --git a/go/conclave_example_test.go b/go/conclave_example_test.go new file mode 100644 index 0000000..404e1b5 --- /dev/null +++ b/go/conclave_example_test.go @@ -0,0 +1,35 @@ +package config + +import ( + core "dappco.re/go" + coreio "dappco.re/go/io" +) + +func ExampleSetConclaveRootFunc() { + SetConclaveRootFunc(func(name string) core.Result { + return core.Ok(core.PathJoin("/", "conclaves", name)) + }) + defer SetConclaveRootFunc(nil) + result := conclaveRoot("alpha") + root, _ := core.Cast[string](result) + core.Println(result.OK, root) + // Output: true /conclaves/alpha +} + +func ExampleForConclave() { + m := coreio.NewMockMedium() + root := core.PathJoin("/", "conclaves", "alpha") + _ = m.EnsureDir(core.PathJoin(root, ".core")) + _ = m.Write(core.PathJoin(root, ".core", FileConfig), "app:\n name: alpha\n") + SetConclaveRootFunc(func(string) core.Result { + return core.Ok(root) + }) + defer SetConclaveRootFunc(nil) + + result := ForConclave("alpha", WithMedium(m)) + cfg, _ := core.Cast[*Config](result) + var name string + _ = cfg.Get("app.name", &name) + core.Println(result.OK, name) + // Output: true alpha +} diff --git a/go/conclave_test.go b/go/conclave_test.go new file mode 100644 index 0000000..38602e0 --- /dev/null +++ b/go/conclave_test.go @@ -0,0 +1,200 @@ +package config + +import ( + core "dappco.re/go" + "runtime" + + coreio "dappco.re/go/io" +) + +func TestConclave_ForConclave_Good(t *core.T) { + tmp := t.TempDir() + SetConclaveRootFunc(func(name string) core.Result { + return core.Ok(core.PathJoin(tmp, name)) + }) + t.Cleanup(func() { SetConclaveRootFunc(nil) }) + + root := core.PathJoin(tmp, "alpha", ".core") + core.AssertNoError(t, coreio.Local.EnsureDir(root)) + core.AssertNoError(t, coreio.Local.Write(core.PathJoin(root, FileConfig), "theme: dark\n")) + + cfg := requireResultValue[*Config](t, ForConclave("alpha", WithMedium(coreio.Local))) + + var theme string + core.AssertNoError(t, resultError(cfg.Get("theme", &theme))) + core.AssertEqual(t, "dark", theme) +} + +func TestConclave_ForConclave_Bad(t *core.T) { + SetConclaveRootFunc(func(_ string) core.Result { + return core.Fail(assertResolverError()) + }) + t.Cleanup(func() { SetConclaveRootFunc(nil) }) + + err := resultError(ForConclave("missing")) + core.AssertError(t, err) +} + +func TestConclave_ForConclave_Ugly(t *core.T) { + // Nil resolver should fall back to the default — no panic. + SetConclaveRootFunc(nil) + cfg := requireResultValue[*Config](t, ForConclave("test-conclave")) + core.AssertNotNil(t, cfg) +} + +func TestConclave_ForConclave_InvalidName_Bad(t *core.T) { + SetConclaveRootFunc(nil) + err := resultError(ForConclave("../escape")) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "invalid conclave name") +} + +func TestConclave_ForConclave_SymlinkedCore_Bad(t *core.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink test is not portable on Windows in this environment") + } + + tmp := t.TempDir() + conclaveDir := core.PathJoin(tmp, "conclave") + realCore := core.PathJoin(tmp, "real-core") + + core.AssertNoError(t, coreio.Local.EnsureDir(conclaveDir)) + core.AssertNoError(t, coreio.Local.EnsureDir(realCore)) + core.AssertNoError(t, coreio.Local.Write(core.PathJoin(realCore, FileConfig), "theme: dark\n")) + testSymlink(t, realCore, core.PathJoin(conclaveDir, ".core")) + + SetConclaveRootFunc(func(_ string) core.Result { + return core.Ok(conclaveDir) + }) + t.Cleanup(func() { SetConclaveRootFunc(nil) }) + + err := resultError(ForConclave("alpha")) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "symlinked conclave .core directory rejected") +} + +func TestConclave_ForConclave_InheritsProject_Good(t *core.T) { + // A Conclave inherits gaps from the project .core/ directory walked up + // from the current working directory. The Conclave's own .core/ still + // wins for keys it declares. + projectDir := t.TempDir() + conclaveDir := t.TempDir() + + core.AssertNoError(t, coreio.Local.EnsureDir(core.PathJoin(projectDir, ".core"))) + core.AssertNoError(t, coreio.Local.EnsureDir(core.PathJoin(projectDir, ".git"))) + core.AssertNoError(t, coreio.Local.Write( + core.PathJoin(projectDir, ".core", FileConfig), + "dev:\n editor: vim\napp:\n name: project\n", + )) + + core.AssertNoError(t, coreio.Local.EnsureDir(core.PathJoin(conclaveDir, ".core"))) + core.AssertNoError(t, coreio.Local.Write( + core.PathJoin(conclaveDir, ".core", FileConfig), + "app:\n name: conclave\n", + )) + + SetConclaveRootFunc(func(_ string) core.Result { + return core.Ok(conclaveDir) + }) + t.Cleanup(func() { SetConclaveRootFunc(nil) }) + + // Switch cwd so Discover picks up the project layer. + prev := testGetwd(t) + testChdir(t, projectDir) + t.Cleanup(func() { testChdir(t, prev) }) + + cfg := requireResultValue[*Config](t, ForConclave("alpha", WithMedium(coreio.Local))) + + // Conclave wins on app.name. + var name string + core.AssertNoError(t, resultError(cfg.Get("app.name", &name))) + core.AssertEqual(t, "conclave", name) + + // Project fills the gap on dev.editor. + var editor string + core.AssertNoError(t, resultError(cfg.Get("dev.editor", &editor))) + core.AssertEqual(t, "vim", editor) +} + +func TestConclave_SetConclaveRootFunc_Good(t *core.T) { + SetConclaveRootFunc(func(name string) core.Result { + return core.Ok("/custom/" + name) + }) + t.Cleanup(func() { SetConclaveRootFunc(nil) }) + + conclaveMu.RLock() + resolver := conclaveRoot + conclaveMu.RUnlock() + + root := requireResultValue[string](t, resolver("a")) + core.AssertEqual(t, "/custom/a", root) +} + +func TestConclave_ForConclave_Isolation_Good(t *core.T) { + // RFC §12.3: "Writes are isolated to the Conclave's .core/ directory. + // alpha.Set("theme", "dark"), beta.Get("theme", &t) // unchanged" + // + // Two conclaves under different roots must not share state: a Set in + // alpha is invisible to beta, and each Commit writes only to its own + // .core/config.yaml. + tmp := t.TempDir() + SetConclaveRootFunc(func(name string) core.Result { + return core.Ok(core.PathJoin(tmp, name)) + }) + t.Cleanup(func() { SetConclaveRootFunc(nil) }) + + alpha := requireResultValue[*Config](t, ForConclave("workspace-alpha", WithMedium(coreio.Local))) + beta := requireResultValue[*Config](t, ForConclave("workspace-beta", WithMedium(coreio.Local))) + + core.AssertNoError(t, resultError(alpha.Set("theme", "dark"))) + core.AssertNoError(t, resultError(alpha.Commit())) + + // beta was created before alpha's Set — its in-memory view is untouched. + var betaTheme string + err := resultError(beta.Get("theme", &betaTheme)) + core.AssertError(t, err) + + // Alpha's on-disk config contains theme; beta's root has no config file yet. + alphaFile := core.PathJoin(tmp, "workspace-alpha", ".core", "config.yaml") + betaFile := core.PathJoin(tmp, "workspace-beta", ".core", "config.yaml") + + body, err := coreio.Local.Read(alphaFile) + core.AssertNoError(t, err) + core.AssertContains(t, body, "theme") + core.AssertContains(t, body, "dark") + + core.AssertFalse(t, coreio.Local.Exists(betaFile), "beta conclave must not have received alpha's write") +} + +func assertResolverError() error { + return &assertErr{msg: "resolver failed"} +} + +type assertErr struct{ msg string } + +func (e *assertErr) Error() string { return e.msg } + +func TestConclave_SetConclaveRootFunc_Bad(t *core.T) { + SetConclaveRootFunc(nil) + t.Cleanup(func() { SetConclaveRootFunc(nil) }) + conclaveMu.RLock() + resolver := conclaveRoot + conclaveMu.RUnlock() + r := resolver("alpha") + core.AssertNoError(t, resultError(r)) + got := resultValue[string](r) + core.AssertContains(t, got, core.PathJoin("conclaves", "alpha")) +} + +func TestConclave_SetConclaveRootFunc_Ugly(t *core.T) { + want := core.NewError("resolver refused") + SetConclaveRootFunc(func(string) core.Result { return core.Fail(want) }) + t.Cleanup(func() { SetConclaveRootFunc(nil) }) + conclaveMu.RLock() + resolver := conclaveRoot + conclaveMu.RUnlock() + r := resolver("alpha") + got := resultValue[string](r) + core.AssertEqual(t, "", got) + core.AssertErrorIs(t, resultError(r), want) +} diff --git a/go/config.go b/go/config.go new file mode 100644 index 0000000..cbf1ebc --- /dev/null +++ b/go/config.go @@ -0,0 +1,741 @@ +// Package config provides layered configuration management for the Core framework. +// +// Configuration values are resolved in priority order: defaults -> file -> env -> Set(). +// Values are stored in a YAML file at ~/.core/config.yaml by default. +// +// Keys use dot notation for nested access: +// +// cfg.Set("dev.editor", "vim") +// var editor string +// cfg.Get("dev.editor", &editor) +package config + +import ( + "iter" + "slices" + "sync" + + core "dappco.re/go" + coreio "dappco.re/go/io" + coreerr "dappco.re/go/log" + "github.com/spf13/viper" + "gopkg.in/yaml.v3" +) + +type envkeyreplacer struct{} + +func (envkeyreplacer) Replace(s string) string { + return core.Replace(s, ".", "_") +} + +const ( + callerConfigNew = "config.New" + callerConfigLoad = "config.Load" + callerConfigLoadFile = "config.LoadFile" + callerConfigGet = "config.Get" + callerConfigSave = "config.Save" + unsupportedConfigFileType = "unsupported config file type" + configChangeSourceFile = "file" + configChangeSourceSet = "set" + configChangeSourceCommit = "commit" +) + +// ConfigChanged is broadcast on every Set() and Commit() call so other services +// can react to runtime config updates without polling. +// +// c.RegisterAction(func(c *core.Core, msg core.Message) core.Result { +// if cc, ok := msg.(config.ConfigChanged); ok { +// // react to cc.Key / cc.Value +// } +// return core.Ok(nil) +// }) +type ConfigChanged struct { + Key string + Value any + Previous any + Source string // "set", "env", "file", "commit" +} + +// Config implements layered configuration with a dual-Viper pattern. +// The full viper holds file + env + defaults (for reads); the file viper +// holds file + explicit Set() calls only (for persistence). +// +// cfg, _ := config.New(config.WithPath("~/.core/config.yaml")) +// cfg.Set("dev.editor", "vim") +// cfg.Commit() +type Config struct { + mu sync.RWMutex + full *viper.Viper // Full configuration (file + env + defaults) + file *viper.Viper // File-backed configuration only (for persistence) + medium coreio.Medium + path string + core *core.Core // optional — set when attached to a Core service + store ConfigStoreWriter + callbacks []func(key string, value any) + watcher *fileWatcher +} + +// Option is a functional option for configuring a Config instance. +type Option func(*Config) + +// ConfigStoreWriter is the minimal store contract config needs for mirroring +// Set() calls into go-store when available. +// +// store.Set("config", "dev.editor", "\"vim\"") +type ConfigStoreWriter interface { + Set(string, string, string) error +} + +// ConfigStoreReader is the optional store contract for hydrating config state +// back into memory on startup so pre-Commit Set() calls survive restarts. +// +// values, _ := store.GetAll("config") +type ConfigStoreReader interface { + GetAll(string) (map[string]string, error) +} + +// WithMedium sets the storage medium for configuration file operations. +// +// config.New(config.WithMedium(io.Local)) +func WithMedium(m coreio.Medium) Option { + return func(c *Config) { + c.medium = m + } +} + +// WithPath sets the path to the configuration file. +// +// config.New(config.WithPath("~/.core/config.yaml")) +func WithPath(path string) Option { + return func(c *Config) { + c.path = path + } +} + +// WithEnvPrefix sets the prefix for environment variables. +// +// config.New(config.WithEnvPrefix("CORE_CONFIG")) // CORE_CONFIG_DEV_EDITOR → dev.editor +func WithEnvPrefix(prefix string) Option { + return func(c *Config) { + c.full.SetEnvPrefix(core.TrimSuffix(prefix, "_")) + } +} + +// WithCore attaches the Config to a Core instance so Set()/Commit() calls +// broadcast ConfigChanged events. +// +// config.New(config.WithCore(c)) +func WithCore(c *core.Core) Option { + return func(cfg *Config) { + cfg.core = c + } +} + +// WithStore attaches an optional go-store-compatible writer. When present, +// every Set() call mirrors the new value into the "config" bucket. +// +// config.New(config.WithStore(store)) +func WithStore(store ConfigStoreWriter) Option { + return func(c *Config) { + c.store = store + } +} + +// WithDefaults seeds default values into the defaults layer, the lowest rung +// of the resolution priority (defaults → file → env → Set()). Existing file +// or env values are NOT shadowed — defaults only fill in gaps the caller has +// not supplied another way. +// +// cfg, _ := config.New(config.WithDefaults(map[string]any{ +// "dev.editor": "vim", +// "app.version": "0.1.0", +// })) +// cfg.Get("dev.editor", &editor) // "vim" unless file/env/Set overrides +func WithDefaults(defaults map[string]any) Option { + return func(c *Config) { + for key, value := range defaults { + c.full.SetDefault(key, value) + } + } +} + +// AttachCore wires the Config to a Core instance after construction. Use this +// when New() ran before Core was available (e.g. from a service lifecycle). +// Thread-safe; safe to call concurrently with Set()/Commit(). +// +// cfg, _ := config.New(...) +// cfg.AttachCore(c) // ConfigChanged broadcasts from here on. +func (c *Config) AttachCore(core *core.Core) { + c.mu.Lock() + defer c.mu.Unlock() + c.core = core +} + +// New creates a new Config instance with the given options. +// If no medium is provided, it defaults to io.Local. +// If no path is provided, it defaults to ~/.core/config.yaml. +// +// cfg, err := config.New( +// config.WithPath("~/.core/config.yaml"), +// config.WithMedium(io.Local), +// config.WithEnvPrefix("CORE_CONFIG"), +// ) +func New(opts ...Option) core.Result { + return newConfig(true, opts...) +} + +// newConfig centralises Config construction so discovery can create a config +// shell without eagerly loading the path that will later receive merged layers. +func newConfig(loadFromPath bool, opts ...Option) core.Result { + c := &Config{ + full: viper.NewWithOptions(viper.EnvKeyReplacer(envkeyreplacer{})), + file: viper.New(), + } + + // Configure viper defaults + c.full.SetEnvPrefix("CORE_CONFIG") + + for _, opt := range opts { + opt(c) + } + + if c.medium == nil { + c.medium = coreio.Local + } + + if c.path == "" { + home := core.Env("DIR_HOME") + if home == "" { + return core.Fail(coreerr.E(callerConfigNew, "failed to determine home directory", nil)) + } + c.path = core.Path(home, ".core", "config.yaml") + } + + c.full.AutomaticEnv() + + // Load existing config file if it exists. + if loadFromPath && c.medium.Exists(c.path) { + if r := c.loadFile(c.medium, c.path, false); !r.OK { + return core.Fail(coreerr.E(callerConfigNew, "failed to load config file", resultCause(r).(error))) + } + } + if loadFromPath { + if r := c.loadStoreState(); !r.OK { + return core.Fail(coreerr.E(callerConfigNew, "failed to load config store state", resultCause(r).(error))) + } + } + + return core.Ok(c) +} + +func configTypeForPath(path string) core.Result { + ext := core.Lower(core.PathExt(path)) + if ext == "" && core.PathBase(path) == ".env" { + return core.Ok("env") + } + if ext == "" { + return core.Ok("yaml") + } + + switch ext { + case ".yaml", ".yml": + return core.Ok("yaml") + case ".json": + return core.Ok("json") + case ".toml": + return core.Ok("toml") + case ".env": + return core.Ok("env") + default: + return core.Fail(coreerr.E("config.configTypeForPath", "unsupported config file type: "+path, nil)) + } +} + +// LoadFile reads a configuration file from the given medium and path and merges it +// into the current config. It supports YAML, JSON, TOML, and dotenv files (.env). +// +// cfg.LoadFile(io.Local, ".core/build.yaml") +func (c *Config) LoadFile(m coreio.Medium, path string) core.Result { + return c.loadFile(m, path, true) +} + +// loadFile merges a configuration file into the current Config. When notify is +// true it also broadcasts ConfigChanged events for each changed key. +func (c *Config) loadFile(m coreio.Medium, path string, notify bool) core.Result { + c.mu.Lock() + before := c.snapshotAllLocked() + + settingsResult := readConfigSettings(m, path) + if !settingsResult.OK { + c.mu.Unlock() + return settingsResult + } + settings := settingsResult.Value.(map[string]any) + if r := c.mergeConfigSettingsLocked(settings); !r.OK { + c.mu.Unlock() + return r + } + + callbacks, attached, after := c.loadNotificationStateLocked(notify) + c.mu.Unlock() + + if notify && (len(callbacks) > 0 || attached != nil) { + emitConfigChanges(callbacks, attached, diffSnapshots(before, after), configChangeSourceFile) + } + + return core.Ok(nil) +} + +func readConfigSettings(m coreio.Medium, path string) core.Result { + configTypeResult := configTypeForPath(path) + if !configTypeResult.OK { + return core.Fail(coreerr.E(callerConfigLoadFile, "failed to determine config file type: "+path, resultCause(configTypeResult).(error))) + } + configType := configTypeResult.Value.(string) + + content, err := m.Read(path) + if err != nil { + return core.Fail(coreerr.E(callerConfigLoadFile, "failed to read config file: "+path, err)) + } + + parsed := viper.New() + parsed.SetConfigType(configType) + if err := parsed.MergeConfig(core.NewReader(content)); err != nil { + return core.Fail(coreerr.E(callerConfigLoadFile, core.Sprintf("failed to parse config file: %s", path), err)) + } + + settings := parsed.AllSettings() + if r := validateSchema(path, settings); !r.OK { + return r + } + return core.Ok(settings) +} + +func (c *Config) mergeConfigSettingsLocked(settings map[string]any) core.Result { + if err := c.file.MergeConfigMap(settings); err != nil { + return core.Fail(coreerr.E(callerConfigLoadFile, "failed to merge config into file settings", err)) + } + if err := c.full.MergeConfigMap(settings); err != nil { + return core.Fail(coreerr.E(callerConfigLoadFile, "failed to merge config into full settings", err)) + } + return core.Ok(nil) +} + +func (c *Config) loadNotificationStateLocked(notify bool) ([]func(string, any), *core.Core, map[string]any) { + callbacks := append([]func(string, any){}, c.callbacks...) + attached := c.core + if !notify || (len(callbacks) == 0 && attached == nil) { + return callbacks, attached, nil + } + return callbacks, attached, c.snapshotAllLocked() +} + +func emitConfigChanges(callbacks []func(string, any), attached *core.Core, changes []configChange, source string) { + for _, change := range changes { + for _, fn := range callbacks { + fn(change.Key, change.Value) + } + if attached != nil { + _ = attached.ACTION(ConfigChanged{ + Key: change.Key, + Value: change.Value, + Previous: change.Previous, + Source: source, + }) + } + } +} + +// Get retrieves a configuration value by dot-notation key and stores it in out. +// If key is empty, it unmarshals the entire configuration into out. +// The out parameter must be a pointer to the target type. +// +// var editor string +// cfg.Get("dev.editor", &editor) +func (c *Config) Get(key string, out any) core.Result { + c.mu.RLock() + defer c.mu.RUnlock() + + if key == "" { + if err := c.full.Unmarshal(out); err != nil { + return core.Fail(coreerr.E(callerConfigGet, "failed to unmarshal full config", err)) + } + return core.Ok(nil) + } + + if !c.full.IsSet(key) { + return core.Fail(coreerr.E(callerConfigGet, core.Sprintf("key not found: %s", key), nil)) + } + + if err := c.full.UnmarshalKey(key, out); err != nil { + return core.Fail(coreerr.E(callerConfigGet, core.Sprintf("failed to unmarshal key: %s", key), err)) + } + return core.Ok(nil) +} + +// SetDefault stores a value in the lowest-precedence defaults layer. File, +// env and explicit Set() values all shadow defaults. Unlike Set(), defaults +// are NOT persisted by Commit() and do NOT broadcast ConfigChanged — they +// exist so callers can declare a baseline the config resolves to when no +// other source has spoken. +// +// cfg.SetDefault("dev.editor", "vim") +// cfg.Get("dev.editor", &editor) // "vim" until someone Sets/loads another value +func (c *Config) SetDefault(key string, v any) { + c.mu.Lock() + defer c.mu.Unlock() + c.full.SetDefault(key, v) +} + +// Set stores a configuration value in memory and broadcasts ConfigChanged. +// Call Commit() to persist changes to disk. +// +// cfg.Set("dev.editor", "vim") +// cfg.Commit() +func (c *Config) Set(key string, v any) core.Result { + c.mu.Lock() + previous := c.full.Get(key) + c.file.Set(key, v) + c.full.Set(key, v) + store := c.store + callbacks := append([]func(string, any){}, c.callbacks...) + attached := c.core + c.mu.Unlock() + + for _, fn := range callbacks { + fn(key, v) + } + if attached != nil { + _ = attached.ACTION(ConfigChanged{Key: key, Value: v, Previous: previous, Source: configChangeSourceSet}) + } + persistToStore(store, key, v) + return core.Ok(nil) +} + +// Commit persists any changes made via Set() to the configuration file on disk. +// This will only save the configuration that was loaded from the file or explicitly Set(), +// preventing environment variable leakage. +// +// cfg.Commit() +func (c *Config) Commit() core.Result { + c.mu.Lock() + medium := c.medium + path := c.path + settings := c.file.AllSettings() + attached := c.core + c.mu.Unlock() + + if r := Save(medium, path, settings); !r.OK { + return core.Fail(coreerr.E("config.Commit", "failed to save config", resultCause(r).(error))) + } + if attached != nil { + _ = attached.ACTION(ConfigChanged{Key: "", Value: nil, Source: configChangeSourceCommit}) + } + return core.Ok(nil) +} + +// All returns an iterator over a snapshot of every configuration value in +// lexical key order. Keys are flat dot-notation (e.g. "dev.editor", +// "app.name"). The iterator includes values sourced from file, Set() calls, +// and environment-variable overrides mapped via the configured env prefix +// (so CORE_CONFIG_DEV_EDITOR shows up as "dev.editor"). +// +// for key, value := range cfg.All() { +// fmt.Println(key, value) // "dev.editor" "vim" +// } +func (c *Config) All() iter.Seq2[string, any] { + c.mu.RLock() + + // AllKeys() gives flat dot-notation for every key viper knows about, + // covering file values, explicit Set() calls, and registered env overrides. + keys := append([]string(nil), c.full.AllKeys()...) + + // Surface env-prefixed variables that were never declared in the file so + // the iterator truly reflects the merged reality rather than only the + // persisted surface. + prefix := envPrefixOf(c.full) + if prefix != "" { + for envKey := range Env(prefix) { + if !contains(keys, envKey) { + keys = append(keys, envKey) + } + } + } + + values := make(map[string]any, len(keys)) + for _, key := range keys { + values[key] = c.full.Get(key) + } + c.mu.RUnlock() + + slices.Sort(keys) + + return func(yield func(string, any) bool) { + for _, key := range keys { + if !yield(key, values[key]) { + return + } + } + } +} + +// snapshotAllLocked copies the current config state while c.mu is already held. +func (c *Config) snapshotAllLocked() map[string]any { + out := make(map[string]any, len(c.full.AllKeys())) + for _, key := range c.full.AllKeys() { + out[key] = c.full.Get(key) + } + return out +} + +// envPrefixOf returns the environment-variable prefix registered with viper +// in the form required by Env() (trailing underscore, uppercase). Empty +// when no prefix is active. +func envPrefixOf(v *viper.Viper) string { + // Viper stores the prefix verbatim (without trailing underscore) via + // SetEnvPrefix. We probe via its canonical environment-key transform. + canonical := v.GetEnvPrefix() + if canonical == "" { + return "" + } + if !core.HasSuffix(canonical, "_") { + canonical += "_" + } + return canonical +} + +// Path returns the path to the configuration file. +// +// cfg.Path() // "~/.core/config.yaml" +func (c *Config) Path() string { + return c.path +} + +// Medium returns the I/O medium backing the configuration. +// +// medium := cfg.Medium() +func (c *Config) Medium() coreio.Medium { + return c.medium +} + +// MergeFrom overlays source values onto the receiver at the leaf level. +// Existing keys in the receiver are NOT overwritten — including keys sourced +// from environment variables via AutomaticEnv. Used by Discover() to merge +// closer (project) configs over further (global) ones without shadowing the +// "env overrides everything" rule from §5.3 of the .core/ convention spec. +// +// Inherited values land in the defaults layer only — Commit() never persists +// them into this Config's own file, so a project Commit cannot leak secrets +// discovered from ~/.core/config.yaml into the project config. +// +// base := config.New() +// base.MergeFrom(projectConfig) // closest wins +// base.MergeFrom(globalConfig) // fills gaps only +func (c *Config) MergeFrom(source *Config) { + if source == nil { + return + } + source.mu.RLock() + // file keys are the source's own persistable values. full keys also + // include values previously inherited from other MergeFrom calls — we + // need both so inheritance chains (discover → conclave) keep working. + leaf := flattenSettings(nil, source.file.AllSettings()) + for key, value := range flattenSettings(nil, source.full.AllSettings()) { + if _, ok := leaf[key]; ok { + continue + } + leaf[key] = value + } + source.mu.RUnlock() + + c.mu.Lock() + defer c.mu.Unlock() + for key, value := range leaf { + if c.full.IsSet(key) { + continue + } + // full viper uses SetDefault so AutomaticEnv still wins on Get, but + // file viper is left untouched — inherited values must not be written + // back when Commit() flushes this Config to disk. + c.full.SetDefault(key, value) + } +} + +func flattenSettings(dst, settings map[string]any) map[string]any { + if dst == nil { + dst = map[string]any{} + } + flattenInto(dst, "", settings) + return dst +} + +func flattenInto(dst map[string]any, prefix string, value any) { + switch typed := value.(type) { + case map[string]any: + for key, next := range typed { + flattenInto(dst, joinConfigPath(prefix, key), next) + } + case map[any]any: + for key, next := range typed { + flattenInto(dst, joinConfigPath(prefix, core.Sprintf("%v", key)), next) + } + case []any: + if prefix != "" { + dst[prefix] = typed + } + case []string: + if prefix != "" { + dst[prefix] = typed + } + default: + if prefix != "" { + dst[prefix] = value + } + } +} + +func joinConfigPath(prefix, key string) string { + if prefix == "" { + return key + } + return prefix + "." + key +} + +// OnChange registers a callback invoked when a config key changes. +// The callback receives the key path and new value. +// Multiple callbacks can be registered; all are called in order. +// +// cfg.OnChange(func(key string, value any) { +// if key == "dev.editor" { +// fmt.Println("editor changed to", value) +// } +// }) +func (c *Config) OnChange(fn func(key string, value any)) { + if fn == nil { + return + } + c.mu.Lock() + defer c.mu.Unlock() + c.callbacks = append(c.callbacks, fn) +} + +// Load reads a YAML configuration file from the given medium and path. +// Returns the parsed data as a map, or an error if the file cannot be read or parsed. +// +// data, err := config.Load(io.Local, "~/.core/config.yaml") +// +// Deprecated: Use Config.LoadFile instead. +func Load(m coreio.Medium, path string) core.Result { + ext := core.Lower(core.PathExt(path)) + switch ext { + case "", ".yaml", ".yml": + // These paths are safe to treat as YAML sources. + case ".env": + // dotenv sources are also supported by the RFC contract. + default: + if core.PathBase(path) != ".env" { + return core.Fail(coreerr.E(callerConfigLoad, unsupportedConfigFileType+": "+path, nil)) + } + } + + content, err := m.Read(path) + if err != nil { + return core.Fail(coreerr.E(callerConfigLoad, "failed to read config file: "+path, err)) + } + + v := viper.New() + switch { + case ext == ".env" || core.PathBase(path) == ".env": + v.SetConfigType("env") + default: + v.SetConfigType("yaml") + } + if err := v.ReadConfig(core.NewReader(content)); err != nil { + return core.Fail(coreerr.E(callerConfigLoad, "failed to parse config file: "+path, err)) + } + + return core.Ok(v.AllSettings()) +} + +// Save writes configuration data to a YAML file at the given path. +// It ensures the parent directory exists before writing and uses 0600 +// permissions for the file so user config does not become world-readable. +// +// config.Save(io.Local, "~/.core/config.yaml", map[string]any{"dev": map[string]any{"editor": "vim"}}) +func Save(m coreio.Medium, path string, data map[string]any) core.Result { + switch ext := core.Lower(core.PathExt(path)); ext { + case "", ".yaml", ".yml": + // These paths are safe to treat as YAML destinations. + default: + return core.Fail(coreerr.E(callerConfigSave, unsupportedConfigFileType+": "+path, nil)) + } + + payload := make(map[string]any, len(data)+1) + for key, value := range data { + payload[key] = value + } + // Every .core YAML payload is versioned for forward compatibility. + payload["version"] = 1 + + out, err := yaml.Marshal(payload) + if err != nil { + return core.Fail(coreerr.E(callerConfigSave, "failed to marshal config", err)) + } + + dir := core.PathDir(path) + if err := m.EnsureDir(dir); err != nil { + return core.Fail(coreerr.E(callerConfigSave, "failed to create config directory: "+dir, err)) + } + + if err := m.WriteMode(path, string(out), 0600); err != nil { + return core.Fail(coreerr.E(callerConfigSave, "failed to write config file: "+path, err)) + } + + return core.Ok(nil) +} + +func persistToStore(store ConfigStoreWriter, key string, value any) { + if store == nil || key == "" { + return + } + if err := store.Set("config", key, core.JSONMarshalString(value)); err != nil { + return + } +} + +func (c *Config) loadStoreState() core.Result { + reader, ok := c.store.(ConfigStoreReader) + if !ok || reader == nil { + return core.Ok(nil) + } + + entries, err := reader.GetAll("config") + if err != nil { + return core.Fail(coreerr.E("config.loadStoreState", "failed to read config entries from store", err)) + } + + for key, raw := range entries { + if key == "" { + continue + } + decoded := decodeStoredConfigValue(raw) + c.file.Set(key, decoded) + c.full.Set(key, decoded) + } + + return core.Ok(nil) +} + +func decodeStoredConfigValue(raw string) any { + if raw == "" { + return "" + } + + var decoded any + if r := core.JSONUnmarshalString(raw, &decoded); r.OK { + return decoded + } + + // Older or non-core writers may persist plain strings instead of JSON. + return raw +} diff --git a/go/config_example_test.go b/go/config_example_test.go new file mode 100644 index 0000000..04d6def --- /dev/null +++ b/go/config_example_test.go @@ -0,0 +1,192 @@ +package config + +import ( + core "dappco.re/go" + coreio "dappco.re/go/io" +) + +type exampleConfigStore struct { + values map[string]string +} + +func (s *exampleConfigStore) Set(bucket, key, value string) error { + if s.values == nil { + s.values = map[string]string{} + } + s.values[bucket+"."+key] = value + return nil +} + +func ExampleWithMedium() { + m := coreio.NewMockMedium() + cfg := core.MustCast[*Config](New(WithMedium(m), WithPath("/example/config.yaml"))) + core.Println(cfg.Medium() == m) + // Output: true +} + +func ExampleWithPath() { + cfg := core.MustCast[*Config](New(WithMedium(coreio.NewMockMedium()), WithPath("/example/app.yaml"))) + core.Println(cfg.Path()) + // Output: /example/app.yaml +} + +func ExampleWithEnvPrefix() { + result := New(WithMedium(coreio.NewMockMedium()), WithPath("/example/config.yaml"), WithEnvPrefix("APP")) + cfg, _ := core.Cast[*Config](result) + core.Println(result.OK && cfg != nil) + // Output: true +} + +func ExampleWithCore() { + c := core.New() + cfg := core.MustCast[*Config](New(WithMedium(coreio.NewMockMedium()), WithPath("/example/config.yaml"), WithCore(c))) + core.Println(cfg.core == c) + // Output: true +} + +func ExampleWithStore() { + store := &exampleConfigStore{} + cfg := core.MustCast[*Config](New(WithMedium(coreio.NewMockMedium()), WithPath("/example/config.yaml"), WithStore(store))) + _ = cfg.Set("agent.name", "codex") + core.Println(store.values["config.agent.name"]) + // Output: "codex" +} + +func ExampleWithDefaults() { + cfg := core.MustCast[*Config](New( + WithMedium(coreio.NewMockMedium()), + WithPath("/example/config.yaml"), + WithDefaults(map[string]any{"app.name": "core"}), + )) + var name string + _ = cfg.Get("app.name", &name) + core.Println(name) + // Output: core +} + +func ExampleConfig_AttachCore() { + cfg := core.MustCast[*Config](New(WithMedium(coreio.NewMockMedium()), WithPath("/example/config.yaml"))) + c := core.New() + cfg.AttachCore(c) + core.Println(cfg.core == c) + // Output: true +} + +func ExampleNew() { + result := New(WithMedium(coreio.NewMockMedium()), WithPath("/example/config.yaml")) + cfg, _ := core.Cast[*Config](result) + core.Println(result.OK && cfg != nil) + // Output: true +} + +func ExampleConfig_LoadFile() { + m := coreio.NewMockMedium() + _ = m.Write("/example/extra.yaml", "app:\n name: loaded\n") + cfg := core.MustCast[*Config](New(WithMedium(m), WithPath("/example/config.yaml"))) + _ = cfg.LoadFile(m, "/example/extra.yaml") + var name string + _ = cfg.Get("app.name", &name) + core.Println(name) + // Output: loaded +} + +func ExampleConfig_Get() { + cfg := core.MustCast[*Config](New(WithMedium(coreio.NewMockMedium()), WithPath("/example/config.yaml"))) + _ = cfg.Set("dev.editor", "vim") + var editor string + _ = cfg.Get("dev.editor", &editor) + core.Println(editor) + // Output: vim +} + +func ExampleConfig_SetDefault() { + cfg := core.MustCast[*Config](New(WithMedium(coreio.NewMockMedium()), WithPath("/example/config.yaml"))) + cfg.SetDefault("feature.beta", true) + var beta bool + _ = cfg.Get("feature.beta", &beta) + core.Println(beta) + // Output: true +} + +func ExampleConfig_Set() { + cfg := core.MustCast[*Config](New(WithMedium(coreio.NewMockMedium()), WithPath("/example/config.yaml"))) + _ = cfg.Set("dev.shell", "zsh") + var shell string + _ = cfg.Get("dev.shell", &shell) + core.Println(shell) + // Output: zsh +} + +func ExampleConfig_Commit() { + m := coreio.NewMockMedium() + cfg := core.MustCast[*Config](New(WithMedium(m), WithPath("/example/config.yaml"))) + _ = cfg.Set("app.name", "core") + _ = cfg.Commit() + core.Println(m.Exists("/example/config.yaml")) + // Output: true +} + +func ExampleConfig_All() { + cfg := core.MustCast[*Config](New(WithMedium(coreio.NewMockMedium()), WithPath("/example/config.yaml"))) + _ = cfg.Set("app.name", "core") + found := false + for key := range cfg.All() { + if key == "app.name" { + found = true + } + } + core.Println(found) + // Output: true +} + +func ExampleConfig_Path() { + cfg := core.MustCast[*Config](New(WithMedium(coreio.NewMockMedium()), WithPath("/example/config.yaml"))) + core.Println(cfg.Path()) + // Output: /example/config.yaml +} + +func ExampleConfig_Medium() { + m := coreio.NewMockMedium() + cfg := core.MustCast[*Config](New(WithMedium(m), WithPath("/example/config.yaml"))) + core.Println(cfg.Medium() == m) + // Output: true +} + +func ExampleConfig_MergeFrom() { + base := core.MustCast[*Config](New(WithMedium(coreio.NewMockMedium()), WithPath("/example/base.yaml"))) + _ = base.Set("app.name", "base") + layer := core.MustCast[*Config](New(WithMedium(coreio.NewMockMedium()), WithPath("/example/layer.yaml"))) + _ = layer.Set("dev.editor", "vim") + base.MergeFrom(layer) + var editor string + _ = base.Get("dev.editor", &editor) + core.Println(editor) + // Output: vim +} + +func ExampleConfig_OnChange() { + cfg := core.MustCast[*Config](New(WithMedium(coreio.NewMockMedium()), WithPath("/example/config.yaml"))) + seen := "" + cfg.OnChange(func(key string, value any) { + seen = key + "=" + value.(string) + }) + _ = cfg.Set("dev.editor", "vim") + core.Println(seen) + // Output: dev.editor=vim +} + +func ExampleLoad() { + m := coreio.NewMockMedium() + _ = m.Write("/example/config.yaml", "app:\n name: core\n") + result := Load(m, "/example/config.yaml") + data, _ := core.Cast[map[string]any](result) + core.Println(result.OK, data["app"].(map[string]any)["name"]) + // Output: true core +} + +func ExampleSave() { + m := coreio.NewMockMedium() + result := Save(m, "/example/config.yaml", map[string]any{"app": map[string]any{"name": "core"}}) + core.Println(result.OK && m.Exists("/example/config.yaml")) + // Output: true +} diff --git a/go/config_extra_test.go b/go/config_extra_test.go new file mode 100644 index 0000000..f333ffd --- /dev/null +++ b/go/config_extra_test.go @@ -0,0 +1,271 @@ +package config + +import ( + "sync" + + core "dappco.re/go" + coreio "dappco.re/go/io" +) + +const ( + configExtraAppNameKey = "app.name" + configExtraDevEditorKey = "dev.editor" + configExtraDefaultsPath = "/defaults.yaml" + configExtraFeatureBetaKey = "feature.beta" + configExtraStorePath = "/store.yaml" +) + +type mockConfigStore struct { + bucket string + key string + value string + calls int + failWith error +} + +func (s *mockConfigStore) Set(bucket, key, value string) error { + s.calls++ + s.bucket = bucket + s.key = key + s.value = value + if s.failWith != nil { + return s.failWith + } + return nil +} + +func TestConfig_MergeFrom_Good(t *core.T) { + m := coreio.NewMockMedium() + base, err := configResult(New(WithMedium(m), WithPath("/base.yaml"))) + core.AssertNoError(t, err) + core.AssertNoError(t, resultError(base.Set(configExtraAppNameKey, "base"))) + + src, err := configResult(New(WithMedium(m), WithPath("/src.yaml"))) + core.AssertNoError(t, err) + core.AssertNoError(t, resultError(src.Set(configExtraAppNameKey, "src"))) + core.AssertNoError(t, resultError(src.Set(configExtraDevEditorKey, "vim"))) + + base.MergeFrom(src) + + var name, editor string + core.AssertNoError(t, resultError(base.Get(configExtraAppNameKey, &name))) + core.AssertEqual(t, "base", name) // closest wins — base not overridden + core.AssertNoError(t, resultError(base.Get(configExtraDevEditorKey, &editor))) + core.AssertEqual(t, "vim", editor) // gap filled from src +} + +func TestConfig_MergeFrom_Bad(t *core.T) { + m := coreio.NewMockMedium() + base, err := configResult(New(WithMedium(m), WithPath("/base.yaml"))) + core.AssertNoError(t, err) + + // Nil source is a no-op, not a panic. + base.MergeFrom(nil) +} + +func TestConfig_OnChange_Good(t *core.T) { + m := coreio.NewMockMedium() + cfg, err := configResult(New(WithMedium(m), WithPath("/cb.yaml"))) + core.AssertNoError(t, err) + + var mu sync.Mutex + seen := map[string]any{} + cfg.OnChange(func(key string, value any) { + mu.Lock() + defer mu.Unlock() + seen[key] = value + }) + + core.AssertNoError(t, resultError(cfg.Set(configExtraDevEditorKey, "vim"))) + + mu.Lock() + defer mu.Unlock() + core.AssertEqual(t, "vim", seen[configExtraDevEditorKey]) +} + +func TestConfig_OnChange_Ugly(t *core.T) { + m := coreio.NewMockMedium() + cfg, err := configResult(New(WithMedium(m), WithPath("/cb.yaml"))) + core.AssertNoError(t, err) + + // Nil callback is silently ignored, not stored. + cfg.OnChange(nil) + core.AssertNoError(t, resultError(cfg.Set(configExtraDevEditorKey, "vim"))) +} + +func TestConfig_Set_BroadcastsConfigChanged_Good(t *core.T) { + m := coreio.NewMockMedium() + c := core.New() + + var mu sync.Mutex + var events []ConfigChanged + c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result { + if cc, ok := msg.(ConfigChanged); ok { + mu.Lock() + events = append(events, cc) + mu.Unlock() + } + return core.Ok(nil) + }) + + cfg, err := configResult(New(WithMedium(m), WithPath("/b.yaml"), WithCore(c))) + core.AssertNoError(t, err) + + core.AssertNoError(t, resultError(cfg.Set(configExtraDevEditorKey, "vim"))) + + mu.Lock() + defer mu.Unlock() + core.AssertGreaterOrEqual(t, len(events), 1) + core.AssertEqual(t, configExtraDevEditorKey, events[0].Key) + core.AssertEqual(t, "vim", events[0].Value) + core.AssertEqual(t, "set", events[0].Source) +} + +func TestConfig_Medium_Good(t *core.T) { + m := coreio.NewMockMedium() + cfg, err := configResult(New(WithMedium(m), WithPath("/medium.yaml"))) + core.AssertNoError(t, err) + core.AssertSame(t, m, cfg.Medium()) +} + +func TestConfig_SetDefault_Good(t *core.T) { + // SetDefault installs a runtime default — visible only while no other + // source has set the key. + m := coreio.NewMockMedium() + cfg, err := configResult(New(WithMedium(m), WithPath("/d.yaml"))) + core.AssertNoError(t, err) + + cfg.SetDefault(configExtraFeatureBetaKey, true) + + var beta bool + core.AssertNoError(t, resultError(cfg.Get(configExtraFeatureBetaKey, &beta))) + core.AssertTrue(t, beta) +} + +func TestConfig_SetDefault_Ugly(t *core.T) { + // Defaults never broadcast ConfigChanged — they are a silent baseline. + m := coreio.NewMockMedium() + c := core.New() + + var mu sync.Mutex + var events []ConfigChanged + c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result { + if cc, ok := msg.(ConfigChanged); ok { + mu.Lock() + events = append(events, cc) + mu.Unlock() + } + return core.Ok(nil) + }) + + cfg, err := configResult(New(WithMedium(m), WithPath("/d.yaml"), WithCore(c))) + core.AssertNoError(t, err) + + cfg.SetDefault(configExtraFeatureBetaKey, true) + + mu.Lock() + defer mu.Unlock() + core.AssertEmpty(t, events) +} + +func TestConfig_WithDefaults_FileWins_Good(t *core.T) { + // File values shadow defaults even when both are present. + m := coreio.NewMockMedium() + m.Files[configExtraDefaultsPath] = "dev:\n editor: nano\n" + + cfg, err := configResult(New( + WithMedium(m), + WithPath(configExtraDefaultsPath), + WithDefaults(map[string]any{configExtraDevEditorKey: "vim"}), + )) + core.AssertNoError(t, err) + + var editor string + core.AssertNoError(t, resultError(cfg.Get(configExtraDevEditorKey, &editor))) + core.AssertEqual(t, "nano", editor) +} + +func TestConfig_AttachCore_Good(t *core.T) { + // AttachCore wires a Core instance in after construction. Subsequent Set() + // calls must broadcast ConfigChanged, even though the Config was created + // without WithCore. + m := coreio.NewMockMedium() + cfg, err := configResult(New(WithMedium(m), WithPath("/attach.yaml"))) + core.AssertNoError(t, err) + + c := core.New() + var mu sync.Mutex + var events []ConfigChanged + c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result { + if cc, ok := msg.(ConfigChanged); ok { + mu.Lock() + events = append(events, cc) + mu.Unlock() + } + return core.Ok(nil) + }) + + // Before AttachCore, Set() does not broadcast. + core.AssertNoError(t, resultError(cfg.Set("before.attach", "silent"))) + mu.Lock() + core.AssertEmpty(t, events) + mu.Unlock() + + cfg.AttachCore(c) + + // After AttachCore, Set() broadcasts. + core.AssertNoError(t, resultError(cfg.Set("after.attach", "noisy"))) + mu.Lock() + defer mu.Unlock() + core.AssertGreaterOrEqual(t, len(events), 1) + core.AssertEqual(t, "after.attach", events[0].Key) + core.AssertEqual(t, "noisy", events[0].Value) +} + +func TestConfig_AttachCore_Ugly(t *core.T) { + // AttachCore is safe to call with nil — it simply leaves the Config in + // pre-attach state with no panics on subsequent Set() calls. + m := coreio.NewMockMedium() + cfg, err := configResult(New(WithMedium(m), WithPath("/attach.yaml"))) + core.AssertNoError(t, err) + + cfg.AttachCore(nil) + core.AssertNoError(t, resultError(cfg.Set("quiet", "ok"))) +} + +func TestConfig_Config_Set_PersistToStore_Good(t *core.T) { + store := &mockConfigStore{} + m := coreio.NewMockMedium() + cfg, err := configResult(New(WithStore(store), WithMedium(m), WithPath(configExtraStorePath))) + core.AssertNoError(t, err) + + core.AssertNoError(t, resultError(cfg.Set(configExtraAppNameKey, "core"))) + + core.AssertEqual(t, 1, store.calls) + core.AssertEqual(t, "config", store.bucket) + core.AssertEqual(t, configExtraAppNameKey, store.key) + core.AssertEqual(t, "\"core\"", store.value) +} + +func TestConfig_Config_Set_PersistToStore_Bad(t *core.T) { + store := &mockConfigStore{failWith: core.NewError("store write failed")} + m := coreio.NewMockMedium() + cfg, err := configResult(New(WithStore(store), WithMedium(m), WithPath(configExtraStorePath))) + core.AssertNoError(t, err) + + core.AssertNoError(t, resultError(cfg.Set(configExtraAppNameKey, "core"))) + core.AssertEqual(t, 1, store.calls) +} + +func TestConfig_persistToStore_Ugly(t *core.T) { + store := &mockConfigStore{} + m := coreio.NewMockMedium() + _, err := configResult(New(WithStore(store), WithMedium(m), WithPath(configExtraStorePath))) + core.AssertNoError(t, err) + + core.AssertNotPanics(t, func() { + persistToStore(nil, configExtraAppNameKey, "core") + persistToStore(store, "", "core") + }) + core.AssertEqual(t, 0, store.calls) +} diff --git a/go/config_test.go b/go/config_test.go new file mode 100644 index 0000000..55ba9cf --- /dev/null +++ b/go/config_test.go @@ -0,0 +1,1197 @@ +package config + +import ( + "context" + "crypto/ed25519" + "io/fs" + "iter" + "maps" + "syscall" + + core "dappco.re/go" + coreio "dappco.re/go/io" +) + +const ( + configTestYAMLPath = "/tmp/test/" + FileConfig + configTestJSONPath = "/tmp/test/config.json" + configTestBasePath = "/tmp/test/config" + configTestTextPath = "/tmp/test/config.txt" + configTestRootPath = "/" + FileConfig + configTestExampleYAMLPath = "/tmp/example/" + FileConfig + configTestAx7MediumPath = "/ax7/medium.yaml" + configTestAx7CorePath = "/ax7/core.yaml" + configTestAx7StorePath = "/ax7/store.yaml" + configTestAx7NewPath = "/ax7/new.yaml" + configTestAx7LoadPath = "/ax7/load.yaml" + configTestAppNameKey = "app.name" + configTestDevEditorKey = "dev.editor" + configTestAgentNameKey = "agent.name" + configTestCoreYAML = "app:\n name: core\n" +) + +func requireResultOK(t *core.T, r core.Result) { + t.Helper() + if !r.OK { + core.RequireNoError(t, resultError(r)) + } +} + +func resultError(r core.Result) error { + if r.OK { + return nil + } + if err, ok := r.Value.(error); ok { + return err + } + return core.NewError(r.Error()) +} + +func requireResultValue[T any](t *core.T, r core.Result) T { + t.Helper() + requireResultOK(t, r) + value, ok := core.Cast[T](r) + core.RequireTrue(t, ok) + return value +} + +func resultValue[T any](r core.Result) T { + value, _ := core.Cast[T](r) + return value +} + +func configResult(r core.Result) (*Config, error) { + return resultValue[*Config](r), resultError(r) +} + +func settingsResult(r core.Result) (map[string]any, error) { + return resultValue[map[string]any](r), resultError(r) +} + +func imagesManifestResult(r core.Result) (*ImagesManifest, error) { + return resultValue[*ImagesManifest](r), resultError(r) +} + +func buildManifestResult(r core.Result) (*BuildManifest, error) { + return resultValue[*BuildManifest](r), resultError(r) +} + +func releaseManifestResult(r core.Result) (*ReleaseManifest, error) { + return resultValue[*ReleaseManifest](r), resultError(r) +} + +func testManifestResult(r core.Result) (*TestManifest, error) { + return resultValue[*TestManifest](r), resultError(r) +} + +func runManifestResult(r core.Result) (*RunManifest, error) { + return resultValue[*RunManifest](r), resultError(r) +} + +func viewManifestResult(r core.Result) (*ViewManifest, error) { + return resultValue[*ViewManifest](r), resultError(r) +} + +func packageManifestResult(r core.Result) (*PackageManifest, error) { + return resultValue[*PackageManifest](r), resultError(r) +} + +func agentManifestResult(r core.Result) (*AgentManifest, error) { + return resultValue[*AgentManifest](r), resultError(r) +} + +func zoneManifestResult(r core.Result) (*ZoneManifest, error) { + return resultValue[*ZoneManifest](r), resultError(r) +} + +func workspaceManifestResult(r core.Result) (*WorkspaceManifest, error) { + return resultValue[*WorkspaceManifest](r), resultError(r) +} + +func ideManifestResult(r core.Result) (*IDEManifest, error) { + return resultValue[*IDEManifest](r), resultError(r) +} + +func linuxKitManifestResult(r core.Result) (map[string]any, error) { + return resultValue[map[string]any](r), resultError(r) +} + +func reposManifestResult(r core.Result) (*ReposManifest, error) { + return resultValue[*ReposManifest](r), resultError(r) +} + +func phpManifestResult(r core.Result) (*PHPManifest, error) { + return resultValue[*PHPManifest](r), resultError(r) +} + +func bytesResult(r core.Result) ([]byte, error) { + return resultValue[[]byte](r), resultError(r) +} + +func trustedKeysResult(r core.Result) ([]ed25519.PublicKey, error) { + return resultValue[[]ed25519.PublicKey](r), resultError(r) +} + +func publicKeyResult(r core.Result) (ed25519.PublicKey, error) { + return resultValue[ed25519.PublicKey](r), resultError(r) +} + +func stringResult(r core.Result) (string, error) { + return resultValue[string](r), resultError(r) +} + +func serviceLoadPathResult(r core.Result) (string, string, error) { + resolution := resultValue[serviceLoadPathResolution](r) + return resolution.Candidate, resolution.Core, resultError(r) +} + +func watchBackendResult(r core.Result) (watchBackend, error) { + return resultValue[watchBackend](r), resultError(r) +} + +func testMkdirAll(t *core.T, path string, mode core.FileMode) { + t.Helper() + requireResultOK(t, core.MkdirAll(path, mode)) +} + +func testWriteFile(t *core.T, path string, data []byte, mode core.FileMode) { + t.Helper() + requireResultOK(t, core.WriteFile(path, data, mode)) +} + +func testSymlink(t *core.T, target, link string) { + t.Helper() + core.RequireNoError(t, syscall.Symlink(target, link)) +} + +func testRemove(path string) { + _ = core.Remove(path) +} + +func testGetwd(t *core.T) string { + t.Helper() + r := core.Getwd() + requireResultOK(t, r) + return r.Value.(string) +} + +func testChdir(t *core.T, dir string) { + t.Helper() + requireResultOK(t, core.Chdir(dir)) +} + +func testPathAbs(t *core.T, path string) string { + t.Helper() + r := core.PathAbs(path) + requireResultOK(t, r) + return r.Value.(string) +} + +func testPathEvalSymlinks(t *core.T, path string) string { + t.Helper() + r := core.PathEvalSymlinks(path) + requireResultOK(t, r) + return r.Value.(string) +} + +func TestConfig_Get_Good(t *core.T) { + m := coreio.NewMockMedium() + + cfg, err := configResult(New(WithMedium(m), WithPath(configTestYAMLPath))) + core.AssertNoError(t, err) + + err = resultError(cfg.Set(configTestAppNameKey, "core")) + core.AssertNoError(t, err) + + var name string + err = resultError(cfg.Get(configTestAppNameKey, &name)) + core.AssertNoError(t, err) + core.AssertEqual(t, "core", name) +} + +func TestConfig_Get_Bad(t *core.T) { + m := coreio.NewMockMedium() + + cfg, err := configResult(New(WithMedium(m), WithPath(configTestYAMLPath))) + core.AssertNoError(t, err) + + var value string + err = resultError(cfg.Get("nonexistent.key", &value)) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "key not found") +} + +func TestConfig_Set_Good(t *core.T) { + m := coreio.NewMockMedium() + + cfg, err := configResult(New(WithMedium(m), WithPath(configTestYAMLPath))) + core.AssertNoError(t, err) + + err = resultError(cfg.Set(configTestDevEditorKey, "vim")) + core.AssertNoError(t, err) + + err = resultError(cfg.Commit()) + core.AssertNoError(t, err) + + // Verify the value was saved to the medium + content, readErr := m.Read(configTestYAMLPath) + core.AssertNoError(t, readErr) + core.AssertContains(t, content, "editor: vim") + + // Verify we can read it back + var editor string + err = resultError(cfg.Get(configTestDevEditorKey, &editor)) + core.AssertNoError(t, err) + core.AssertEqual(t, "vim", editor) +} + +func TestConfig_Set_Nested_Good(t *core.T) { + m := coreio.NewMockMedium() + + cfg, err := configResult(New(WithMedium(m), WithPath(configTestYAMLPath))) + core.AssertNoError(t, err) + + err = resultError(cfg.Set("a.b.c", "deep")) + core.AssertNoError(t, err) + + var val string + err = resultError(cfg.Get("a.b.c", &val)) + core.AssertNoError(t, err) + core.AssertEqual(t, "deep", val) +} + +func TestConfig_All_Good(t *core.T) { + m := coreio.NewMockMedium() + + cfg, err := configResult(New(WithMedium(m), WithPath(configTestYAMLPath))) + core.AssertNoError(t, err) + + _ = cfg.Set("key1", "val1") + _ = cfg.Set("key2", "val2") + + all := maps.Collect(cfg.All()) + core.AssertEqual(t, "val1", all["key1"]) + core.AssertEqual(t, "val2", all["key2"]) +} + +func TestConfig_All_Order_Good(t *core.T) { + m := coreio.NewMockMedium() + + cfg, err := configResult(New(WithMedium(m), WithPath(configTestYAMLPath))) + core.AssertNoError(t, err) + + _ = cfg.Set("zulu", "last") + _ = cfg.Set("alpha", "first") + + var keys []string + for key := range cfg.All() { + keys = append(keys, key) + } + + core.AssertEqual(t, []string{"alpha", "zulu"}, keys) +} + +func TestConfig_All_Snapshot_Good(t *core.T) { + m := coreio.NewMockMedium() + + cfg, err := configResult(New(WithMedium(m), WithPath(configTestYAMLPath))) + core.AssertNoError(t, err) + + _ = cfg.Set("alpha", "one") + snapshot := cfg.All() + _ = cfg.Set("beta", "two") + + all := maps.Collect(snapshot) + core.AssertEqual(t, "one", all["alpha"]) + core.AssertNotContains(t, all, "beta") +} + +func TestConfig_All_Nested_Good(t *core.T) { + // Nested keys surface via flat dot-notation — callers iterate a single + // map instead of recursing through map[string]any trees. + m := coreio.NewMockMedium() + m.Files[configTestYAMLPath] = "app:\n name: core\n version: \"1.0\"\ndev:\n editor: vim\n" + + cfg, err := configResult(New(WithMedium(m), WithPath(configTestYAMLPath))) + core.AssertNoError(t, err) + + all := maps.Collect(cfg.All()) + core.AssertEqual(t, "core", all[configTestAppNameKey]) + core.AssertEqual(t, "1.0", all["app.version"]) + core.AssertEqual(t, "vim", all[configTestDevEditorKey]) +} + +func TestConfig_All_IncludesEnv_Good(t *core.T) { + // Env-prefixed vars that never appear in the file still surface via All() + // so consumers iterate the merged reality, not just the persisted surface. + t.Setenv("CORE_CONFIG_RUNTIME_TOKEN", "secret") + + m := coreio.NewMockMedium() + m.Files[configTestYAMLPath] = configTestCoreYAML + + cfg, err := configResult(New(WithMedium(m), WithPath(configTestYAMLPath))) + core.AssertNoError(t, err) + + all := maps.Collect(cfg.All()) + core.AssertEqual(t, "core", all[configTestAppNameKey]) + core.AssertEqual(t, "secret", all["runtime.token"]) +} + +func TestConfig_All_EnvOverridesFile_Good(t *core.T) { + // When file and env both define a key, All() reflects the env override + // (same precedence as Get()). + t.Setenv("CORE_CONFIG_DEV_EDITOR", "nano") + + m := coreio.NewMockMedium() + m.Files[configTestYAMLPath] = "dev:\n editor: vim\n" + + cfg, err := configResult(New(WithMedium(m), WithPath(configTestYAMLPath))) + core.AssertNoError(t, err) + + all := maps.Collect(cfg.All()) + core.AssertEqual(t, "nano", all[configTestDevEditorKey]) +} + +func TestConfig_All_CustomPrefix_Good(t *core.T) { + // A custom env prefix (via WithEnvPrefix) still populates All(). + t.Setenv("MYAPP_FEATURE_BETA", "true") + + m := coreio.NewMockMedium() + cfg, err := configResult(New(WithMedium(m), WithPath(configTestYAMLPath), WithEnvPrefix("MYAPP"))) + core.AssertNoError(t, err) + + all := maps.Collect(cfg.All()) + core.AssertEqual(t, "true", all["feature.beta"]) +} + +func TestConfig_Path_Good(t *core.T) { + m := coreio.NewMockMedium() + + cfg, err := configResult(New(WithMedium(m), WithPath("/custom/path/config.yaml"))) + core.AssertNoError(t, err) + + core.AssertEqual(t, "/custom/path/config.yaml", cfg.Path()) +} + +func TestConfig_New_LoadExisting_Good(t *core.T) { + m := coreio.NewMockMedium() + m.Files[configTestYAMLPath] = "app:\n name: existing\n" + + cfg, err := configResult(New(WithMedium(m), WithPath(configTestYAMLPath))) + core.AssertNoError(t, err) + + var name string + err = resultError(cfg.Get(configTestAppNameKey, &name)) + core.AssertNoError(t, err) + core.AssertEqual(t, "existing", name) +} + +func TestConfig_New_LoadExistingSchema_Bad(t *core.T) { + m := coreio.NewMockMedium() + m.Files[configTestYAMLPath] = "features: enabled\n" + + _, err := configResult(New(WithMedium(m), WithPath(configTestYAMLPath))) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "schema validation failed") +} + +func TestConfig_New_Env_Good(t *core.T) { + // Set environment variable + t.Setenv("CORE_CONFIG_DEV_EDITOR", "nano") + + m := coreio.NewMockMedium() + cfg, err := configResult(New(WithMedium(m), WithPath(configTestYAMLPath))) + core.AssertNoError(t, err) + + var editor string + err = resultError(cfg.Get(configTestDevEditorKey, &editor)) + core.AssertNoError(t, err) + core.AssertEqual(t, "nano", editor) +} + +func TestConfig_New_EnvOverridesFile_Good(t *core.T) { + // Set file config + m := coreio.NewMockMedium() + m.Files[configTestYAMLPath] = "dev:\n editor: vim\n" + + // Set environment override + t.Setenv("CORE_CONFIG_DEV_EDITOR", "nano") + + cfg, err := configResult(New(WithMedium(m), WithPath(configTestYAMLPath))) + core.AssertNoError(t, err) + + var editor string + err = resultError(cfg.Get(configTestDevEditorKey, &editor)) + core.AssertNoError(t, err) + core.AssertEqual(t, "nano", editor) +} + +func TestConfig_Config_Get_AssignTypes_Good(t *core.T) { + m := coreio.NewMockMedium() + m.Files[configTestYAMLPath] = "count: 42\nenabled: true\nratio: 3.14\n" + + cfg, err := configResult(New(WithMedium(m), WithPath(configTestYAMLPath))) + core.AssertNoError(t, err) + + var count int + err = resultError(cfg.Get("count", &count)) + core.AssertNoError(t, err) + core.AssertEqual(t, 42, count) + + var enabled bool + err = resultError(cfg.Get("enabled", &enabled)) + core.AssertNoError(t, err) + core.AssertTrue(t, enabled) + + var ratio float64 + err = resultError(cfg.Get("ratio", &ratio)) + core.AssertNoError(t, err) + core.AssertInDelta(t, 3.14, ratio, 0.001) +} + +func TestConfig_Config_Get_AssignAny_Good(t *core.T) { + m := coreio.NewMockMedium() + + cfg, err := configResult(New(WithMedium(m), WithPath(configTestYAMLPath))) + core.AssertNoError(t, err) + + _ = cfg.Set("key", "value") + + var val any + err = resultError(cfg.Get("key", &val)) + core.AssertNoError(t, err) + core.AssertEqual(t, "value", val) +} + +func TestConfig_New_DefaultPath_Good(t *core.T) { + m := coreio.NewMockMedium() + + cfg, err := configResult(New(WithMedium(m))) + core.AssertNoError(t, err) + + home := core.UserHomeDir().Value.(string) + core.AssertEqual(t, home+"/.core/config.yaml", cfg.Path()) +} + +func TestConfig_New_NoHome_Bad(t *core.T) { + m := coreio.NewMockMedium() + core.RequireNoError(t, m.Write("/tmp/nohome.yaml", "bad: [yaml")) + cfg, err := configResult(New(WithMedium(m), WithPath("/tmp/nohome.yaml"))) + core.AssertNil(t, cfg) + core.AssertError(t, err) +} + +func TestLoadEnv_Good(t *core.T) { + t.Setenv("CORE_CONFIG_FOO_BAR", "baz") + t.Setenv("CORE_CONFIG_SIMPLE", "value") + + result := LoadEnv("CORE_CONFIG_") + core.AssertEqual(t, "baz", result["foo.bar"]) + core.AssertEqual(t, "value", result["simple"]) +} + +func TestConfig_Env_PrefixNormalisation_Good(t *core.T) { + t.Setenv("MYAPP_SETTING", "secret") + t.Setenv("MYAPP_ALPHA", "first") + + keys := make([]string, 0, 2) + values := make([]string, 0, 2) + for key, value := range Env("MYAPP") { + keys = append(keys, key) + values = append(values, value.(string)) + } + + core.AssertEqual(t, []string{"alpha", "setting"}, keys) + core.AssertEqual(t, []string{"first", "secret"}, values) +} + +func TestLoad_Bad(t *core.T) { + m := coreio.NewMockMedium() + + _, err := settingsResult(Load(m, "/nonexistent/file.yaml")) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "failed to read config file") +} + +func TestConfig_Load_UnsupportedPath_Bad(t *core.T) { + m := coreio.NewMockMedium() + m.Files[configTestJSONPath] = `{"app":{"name":"core"}}` + + _, err := settingsResult(Load(m, configTestJSONPath)) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), unsupportedConfigFileType) +} + +func TestConfig_Load_InvalidYAML_Bad(t *core.T) { + m := coreio.NewMockMedium() + m.Files[configTestYAMLPath] = "invalid: yaml: content: [[[[" + + _, err := settingsResult(Load(m, configTestYAMLPath)) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "failed to parse config file") +} + +func TestConfig_New_LoadFileJSON_Good(t *core.T) { + m := coreio.NewMockMedium() + m.Files[configTestJSONPath] = `{"app":{"name":"core"}}` + + cfg, err := configResult(New(WithMedium(m), WithPath(configTestJSONPath))) + core.AssertNoError(t, err) + + var name string + err = resultError(cfg.Get(configTestAppNameKey, &name)) + core.AssertNoError(t, err) + core.AssertEqual(t, "core", name) +} + +func TestConfig_New_LoadFileExtensionless_Good(t *core.T) { + m := coreio.NewMockMedium() + m.Files[configTestBasePath] = configTestCoreYAML + + cfg, err := configResult(New(WithMedium(m), WithPath(configTestBasePath))) + core.AssertNoError(t, err) + + var name string + err = resultError(cfg.Get(configTestAppNameKey, &name)) + core.AssertNoError(t, err) + core.AssertEqual(t, "core", name) +} + +func TestConfig_New_LoadFileTOML_Good(t *core.T) { + m := coreio.NewMockMedium() + m.Files["/tmp/test/config.toml"] = "app = { name = \"core\" }\n" + + cfg, err := configResult(New(WithMedium(m), WithPath("/tmp/test/config.toml"))) + core.AssertNoError(t, err) + + var name string + err = resultError(cfg.Get(configTestAppNameKey, &name)) + core.AssertNoError(t, err) + core.AssertEqual(t, "core", name) +} + +func TestConfig_LoadFile_Unsupported_Bad(t *core.T) { + m := coreio.NewMockMedium() + + cfg, err := configResult(New(WithMedium(m), WithPath(configTestTextPath))) + core.AssertNoError(t, err) + + m.Files[configTestTextPath] = "app.name=core" + err = resultError(cfg.LoadFile(m, configTestTextPath)) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), unsupportedConfigFileType) +} + +func TestConfig_LoadFile_Unsupported_NoRead_Bad(t *core.T) { + m := coreio.NewMockMedium() + + cfg, err := configResult(New(WithMedium(m), WithPath(configTestTextPath))) + core.AssertNoError(t, err) + + err = resultError(cfg.LoadFile(m, configTestTextPath)) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), unsupportedConfigFileType) +} + +func TestSave_Good(t *core.T) { + m := coreio.NewMockMedium() + + data := map[string]any{ + "key": "value", + } + + err := resultError(Save(m, configTestYAMLPath, data)) + core.AssertNoError(t, err) + + content, readErr := m.Read(configTestYAMLPath) + core.AssertNoError(t, readErr) + core.AssertContains(t, content, "key: value") + core.AssertContains(t, content, "version: 1") + + info, statErr := m.Stat(configTestYAMLPath) + core.AssertNoError(t, statErr) + core.AssertEqual(t, fs.FileMode(0600), info.Mode()) +} + +func TestConfig_Save_Extensionless_Good(t *core.T) { + m := coreio.NewMockMedium() + + err := resultError(Save(m, configTestBasePath, map[string]any{"key": "value"})) + core.AssertNoError(t, err) + + content, readErr := m.Read(configTestBasePath) + core.AssertNoError(t, readErr) + core.AssertContains(t, content, "key: value") +} + +func TestConfig_Save_UnsupportedPath_Bad(t *core.T) { + m := coreio.NewMockMedium() + + err := resultError(Save(m, configTestJSONPath, map[string]any{"key": "value"})) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), unsupportedConfigFileType) +} + +func TestConfig_Commit_UnsupportedPath_Bad(t *core.T) { + m := coreio.NewMockMedium() + + cfg, err := configResult(New(WithMedium(m), WithPath(configTestJSONPath))) + core.AssertNoError(t, err) + + err = resultError(cfg.Set("key", "value")) + core.AssertNoError(t, err) + + err = resultError(cfg.Commit()) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), unsupportedConfigFileType) +} + +func TestConfig_LoadFile_Env_Good(t *core.T) { + m := coreio.NewMockMedium() + m.Files["/.env"] = "FOO=bar\nBAZ=qux" + + cfg, err := configResult(New(WithMedium(m), WithPath(configTestRootPath))) + core.AssertNoError(t, err) + + err = resultError(cfg.LoadFile(m, "/.env")) + core.AssertNoError(t, err) + + var foo string + err = resultError(cfg.Get("foo", &foo)) + core.AssertNoError(t, err) + core.AssertEqual(t, "bar", foo) +} + +func TestConfig_WithEnvPrefix_Good(t *core.T) { + t.Setenv("MYAPP_SETTING", "secret") + + m := coreio.NewMockMedium() + cfg, err := configResult(New(WithMedium(m), WithEnvPrefix("MYAPP"))) + core.AssertNoError(t, err) + + var setting string + err = resultError(cfg.Get("setting", &setting)) + core.AssertNoError(t, err) + core.AssertEqual(t, "secret", setting) +} + +func TestConfig_WithEnvPrefix_TrailingUnderscore_Good(t *core.T) { + t.Setenv("MYAPP_SETTING", "secret") + + m := coreio.NewMockMedium() + cfg, err := configResult(New(WithMedium(m), WithEnvPrefix("MYAPP_"))) + core.AssertNoError(t, err) + + var setting string + err = resultError(cfg.Get("setting", &setting)) + core.AssertNoError(t, err) + core.AssertEqual(t, "secret", setting) +} + +func TestService_OnStartup_WithEnvPrefix_Good(t *core.T) { + t.Setenv("MYAPP_SETTING", "secret") + + m := coreio.NewMockMedium() + svc := &Service{ + ServiceRuntime: core.NewServiceRuntime(nil, ServiceOptions{ + EnvPrefix: "MYAPP", + Medium: m, + }), + } + + result := svc.OnStartup(context.Background()) + core.AssertTrue(t, result.OK) + + var setting string + err := resultError(svc.Get("setting", &setting)) + core.AssertNoError(t, err) + core.AssertEqual(t, "secret", setting) +} + +func TestConfig_Get_EmptyKey_Good(t *core.T) { + m := coreio.NewMockMedium() + m.Files[configTestRootPath] = "app:\n name: test\nversion: 1" + + cfg, err := configResult(New(WithMedium(m), WithPath(configTestRootPath))) + core.AssertNoError(t, err) + + type AppConfig struct { + App struct { + Name string `mapstructure:"name"` + } `mapstructure:"app"` + Version int `mapstructure:"version"` + } + + var full AppConfig + err = resultError(cfg.Get("", &full)) + core.AssertNoError(t, err) + core.AssertEqual(t, "test", full.App.Name) + core.AssertEqual(t, 1, full.Version) +} + +func axConfigFixture(t *core.T) (*Config, *coreio.MockMedium, string) { + t.Helper() + m := coreio.NewMockMedium() + path := "/ax7/config.yaml" + cfg, err := configResult(New(WithMedium(m), WithPath(path))) + core.RequireNoError(t, err) + return cfg, m, path +} + +func TestConfig_WithMedium_Good(t *core.T) { + m := coreio.NewMockMedium() + cfg, err := configResult(New(WithMedium(m), WithPath(configTestAx7MediumPath))) + core.RequireNoError(t, err) + core.AssertSame(t, m, cfg.Medium()) +} + +func TestConfig_WithMedium_Bad(t *core.T) { + cfg, err := configResult(New(WithMedium(nil), WithPath(configTestAx7MediumPath))) + core.RequireNoError(t, err) + core.AssertNotNil(t, cfg.Medium()) +} + +func TestConfig_WithMedium_Ugly(t *core.T) { + first := coreio.NewMockMedium() + second := coreio.NewMockMedium() + cfg, err := configResult(New(WithMedium(first), WithMedium(second), WithPath(configTestAx7MediumPath))) + core.RequireNoError(t, err) + core.AssertSame(t, second, cfg.Medium()) +} + +func TestConfig_WithPath_Good(t *core.T) { + m := coreio.NewMockMedium() + cfg, err := configResult(New(WithMedium(m), WithPath("/ax7/path.yaml"))) + core.RequireNoError(t, err) + core.AssertEqual(t, "/ax7/path.yaml", cfg.Path()) +} + +func TestConfig_WithPath_Bad(t *core.T) { + m := coreio.NewMockMedium() + cfg, err := configResult(New(WithMedium(m), WithPath(""))) + core.RequireNoError(t, err) + core.AssertContains(t, cfg.Path(), ".core/config.yaml") +} + +func TestConfig_WithPath_Ugly(t *core.T) { + m := coreio.NewMockMedium() + cfg, err := configResult(New(WithMedium(m), WithPath("/ax7/first.yaml"), WithPath("/ax7/second.yaml"))) + core.RequireNoError(t, err) + core.AssertEqual(t, "/ax7/second.yaml", cfg.Path()) +} + +func TestConfig_WithEnvPrefix_Bad(t *core.T) { + t.Setenv("AX7_TRAIL_NAME", "trail") + cfg, err := configResult(New(WithMedium(coreio.NewMockMedium()), WithPath("/ax7/env.yaml"), WithEnvPrefix("AX7_TRAIL_"))) + core.RequireNoError(t, err) + core.AssertEqual(t, "trail", mapFromSeq(cfg.All())["name"]) +} + +func TestConfig_WithEnvPrefix_Ugly(t *core.T) { + cfg, err := configResult(New(WithMedium(coreio.NewMockMedium()), WithPath("/ax7/env.yaml"), WithEnvPrefix(""))) + core.RequireNoError(t, err) + core.AssertEqual(t, "CORE_CONFIG_", envPrefixOf(cfg.full)) +} + +func TestConfig_WithCore_Good(t *core.T) { + c := core.New() + cfg, err := configResult(New(WithMedium(coreio.NewMockMedium()), WithPath(configTestAx7CorePath), WithCore(c))) + core.RequireNoError(t, err) + core.AssertSame(t, c, cfg.core) +} + +func TestConfig_WithCore_Bad(t *core.T) { + cfg, err := configResult(New(WithMedium(coreio.NewMockMedium()), WithPath(configTestAx7CorePath), WithCore(nil))) + core.RequireNoError(t, err) + core.AssertNil(t, cfg.core) +} + +func TestConfig_WithCore_Ugly(t *core.T) { + first := core.New() + second := core.New() + cfg, err := configResult(New(WithMedium(coreio.NewMockMedium()), WithPath(configTestAx7CorePath), WithCore(first), WithCore(second))) + core.RequireNoError(t, err) + core.AssertSame(t, second, cfg.core) +} + +func TestConfig_WithStore_Good(t *core.T) { + store := &mockConfigStore{} + cfg, err := configResult(New(WithMedium(coreio.NewMockMedium()), WithPath(configTestAx7StorePath), WithStore(store))) + core.RequireNoError(t, err) + core.AssertSame(t, store, cfg.store) +} + +func TestConfig_WithStore_Bad(t *core.T) { + cfg, err := configResult(New(WithMedium(coreio.NewMockMedium()), WithPath(configTestAx7StorePath), WithStore(nil))) + core.RequireNoError(t, err) + core.AssertNil(t, cfg.store) +} + +func TestConfig_WithStore_Ugly(t *core.T) { + store := &mockConfigStore{failWith: core.NewError("store refused")} + cfg, err := configResult(New(WithMedium(coreio.NewMockMedium()), WithPath(configTestAx7StorePath), WithStore(store))) + core.RequireNoError(t, err) + core.AssertNoError(t, resultError(cfg.Set("agent", "codex"))) +} + +func TestConfig_WithDefaults_Good(t *core.T) { + m := coreio.NewMockMedium() + cfg, err := configResult(New( + WithMedium(m), + WithPath("/defaults.yaml"), + WithDefaults(map[string]any{ + configTestDevEditorKey: "vim", + "app.version": "0.1.0", + }), + )) + core.AssertNoError(t, err) + + var editor, version string + core.AssertNoError(t, resultError(cfg.Get(configTestDevEditorKey, &editor))) + core.AssertEqual(t, "vim", editor) + core.AssertNoError(t, resultError(cfg.Get("app.version", &version))) + core.AssertEqual(t, "0.1.0", version) +} + +func TestConfig_WithDefaults_Bad(t *core.T) { + m := coreio.NewMockMedium() + cfg, err := configResult(New( + WithMedium(m), + WithPath("/defaults.yaml"), + WithDefaults(map[string]any{configTestDevEditorKey: "vim"}), + )) + core.AssertNoError(t, err) + core.AssertNoError(t, resultError(cfg.Set(configTestDevEditorKey, "nano"))) + + var editor string + core.AssertNoError(t, resultError(cfg.Get(configTestDevEditorKey, &editor))) + core.AssertEqual(t, "nano", editor) +} + +func TestConfig_WithDefaults_Ugly(t *core.T) { + cfg, err := configResult(New(WithMedium(coreio.NewMockMedium()), WithPath("/ax7/defaults.yaml"), WithDefaults(nil))) + core.RequireNoError(t, err) + core.AssertEmpty(t, mapFromSeq(cfg.All())) +} + +func TestConfig_Config_AttachCore_Good(t *core.T) { + cfg, _, _ := axConfigFixture(t) + c := core.New() + cfg.AttachCore(c) + core.AssertSame(t, c, cfg.core) +} + +func TestConfig_Config_AttachCore_Bad(t *core.T) { + cfg, _, _ := axConfigFixture(t) + cfg.AttachCore(nil) + core.AssertNil(t, cfg.core) +} + +func TestConfig_Config_AttachCore_Ugly(t *core.T) { + cfg, _, _ := axConfigFixture(t) + first := core.New() + second := core.New() + cfg.AttachCore(first) + cfg.AttachCore(second) + core.AssertSame(t, second, cfg.core) +} + +func TestConfig_New_Good(t *core.T) { + cfg, err := configResult(New(WithMedium(coreio.NewMockMedium()), WithPath(configTestAx7NewPath))) + core.RequireNoError(t, err) + core.AssertEqual(t, configTestAx7NewPath, cfg.Path()) +} + +func TestConfig_New_Bad(t *core.T) { + m := coreio.NewMockMedium() + core.RequireNoError(t, m.Write(configTestAx7NewPath, "bad: [yaml")) + cfg, err := configResult(New(WithMedium(m), WithPath(configTestAx7NewPath))) + core.AssertNil(t, cfg) + core.AssertError(t, err) +} + +func TestConfig_New_Ugly(t *core.T) { + cfg, err := configResult(New(WithMedium(coreio.NewMockMedium()), WithPath(configTestAx7NewPath), WithEnvPrefix("AX7__"))) + core.RequireNoError(t, err) + core.AssertEqual(t, "AX7_", envPrefixOf(cfg.full)) +} + +func TestConfig_Config_LoadFile_Good(t *core.T) { + cfg, m, _ := axConfigFixture(t) + core.RequireNoError(t, m.Write(configTestAx7LoadPath, "app:\n name: loaded\n")) + err := resultError(cfg.LoadFile(m, configTestAx7LoadPath)) + core.AssertNoError(t, err) +} + +func TestConfig_Config_LoadFile_Bad(t *core.T) { + cfg, m, _ := axConfigFixture(t) + err := resultError(cfg.LoadFile(m, "/ax7/missing.yaml")) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "failed to read config file") +} + +func TestConfig_Config_LoadFile_Ugly(t *core.T) { + cfg, m, _ := axConfigFixture(t) + core.RequireNoError(t, m.Write("/ax7/load.unsupported", "app: config\n")) + err := resultError(cfg.LoadFile(m, "/ax7/load.unsupported")) + core.AssertError(t, err) +} + +func TestConfig_Config_Get_Good(t *core.T) { + cfg, _, _ := axConfigFixture(t) + core.RequireNoError(t, resultError(cfg.Set(configTestAppNameKey, "core"))) + var got string + core.AssertNoError(t, resultError(cfg.Get(configTestAppNameKey, &got))) + core.AssertEqual(t, "core", got) +} + +func TestConfig_Config_Get_Bad(t *core.T) { + cfg, _, _ := axConfigFixture(t) + var got string + err := resultError(cfg.Get("missing", &got)) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "key not found") +} + +func TestConfig_Config_Get_Ugly(t *core.T) { + cfg, _, _ := axConfigFixture(t) + core.RequireNoError(t, resultError(cfg.Set(configTestAppNameKey, "core"))) + var got map[string]any + core.AssertNoError(t, resultError(cfg.Get("", &got))) + core.AssertEqual(t, "core", got["app"].(map[string]any)["name"]) +} + +func TestConfig_Config_SetDefault_Good(t *core.T) { + cfg, _, _ := axConfigFixture(t) + cfg.SetDefault(configTestAppNameKey, "default") + var got string + core.AssertNoError(t, resultError(cfg.Get(configTestAppNameKey, &got))) + core.AssertEqual(t, "default", got) +} + +func TestConfig_Config_SetDefault_Bad(t *core.T) { + cfg, _, _ := axConfigFixture(t) + cfg.SetDefault(configTestAppNameKey, "default") + core.RequireNoError(t, resultError(cfg.Set(configTestAppNameKey, "set"))) + var got string + core.RequireNoError(t, resultError(cfg.Get(configTestAppNameKey, &got))) + core.AssertEqual(t, "set", got) +} + +func TestConfig_Config_SetDefault_Ugly(t *core.T) { + cfg, _, _ := axConfigFixture(t) + cfg.SetDefault("", "root-default") + got := mapFromSeq(cfg.All()) + core.AssertEqual(t, "root-default", got[""]) +} + +func TestConfig_Config_Set_Good(t *core.T) { + cfg, _, _ := axConfigFixture(t) + err := resultError(cfg.Set(configTestAgentNameKey, "codex")) + core.AssertNoError(t, err) + core.AssertEqual(t, "codex", mapFromSeq(cfg.All())[configTestAgentNameKey]) +} + +func TestConfig_Config_Set_Bad(t *core.T) { + store := &mockConfigStore{failWith: core.NewError("store refused")} + cfg, err := configResult(New(WithMedium(coreio.NewMockMedium()), WithPath("/ax7/set.yaml"), WithStore(store))) + core.RequireNoError(t, err) + core.AssertNoError(t, resultError(cfg.Set(configTestAgentNameKey, "codex"))) + core.AssertEqual(t, 1, store.calls) +} + +func TestConfig_Config_Set_Ugly(t *core.T) { + cfg, _, _ := axConfigFixture(t) + core.AssertNoError(t, resultError(cfg.Set("", "root"))) + core.AssertEqual(t, "root", cfg.file.Get("")) +} + +func TestConfig_Config_Commit_Good(t *core.T) { + cfg, m, path := axConfigFixture(t) + core.RequireNoError(t, resultError(cfg.Set("agent", "codex"))) + err := resultError(cfg.Commit()) + core.AssertNoError(t, err) + core.AssertTrue(t, m.Exists(path)) +} + +func TestConfig_Config_Commit_Bad(t *core.T) { + cfg, _, _ := axConfigFixture(t) + cfg.path = "/ax7/config.json" + err := resultError(cfg.Commit()) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), unsupportedConfigFileType) +} + +func TestConfig_Config_Commit_Ugly(t *core.T) { + cfg, m, path := axConfigFixture(t) + err := resultError(cfg.Commit()) + core.AssertNoError(t, err) + core.AssertTrue(t, m.Exists(path)) +} + +func TestConfig_Config_All_Good(t *core.T) { + cfg, _, _ := axConfigFixture(t) + core.RequireNoError(t, resultError(cfg.Set("b.key", "second"))) + core.RequireNoError(t, resultError(cfg.Set("a.key", "first"))) + core.AssertEqual(t, []string{"a.key", "b.key"}, keysFromSeq(cfg.All())) +} + +func TestConfig_Config_All_Bad(t *core.T) { + cfg, _, _ := axConfigFixture(t) + got := mapFromSeq(cfg.All()) + core.AssertEmpty(t, got) +} + +func TestConfig_Config_All_Ugly(t *core.T) { + t.Setenv("AX7_ALL_DYNAMIC", "env") + cfg, err := configResult(New(WithMedium(coreio.NewMockMedium()), WithPath("/ax7/all.yaml"), WithEnvPrefix("AX7_ALL"))) + core.RequireNoError(t, err) + core.AssertEqual(t, "env", mapFromSeq(cfg.All())["dynamic"]) +} + +func TestConfig_Config_Path_Good(t *core.T) { + cfg, _, path := axConfigFixture(t) + got := cfg.Path() + core.AssertEqual(t, path, got) +} + +func TestConfig_Config_Path_Bad(t *core.T) { + cfg, _, _ := axConfigFixture(t) + cfg.path = "" + core.AssertEqual(t, "", cfg.Path()) +} + +func TestConfig_Config_Path_Ugly(t *core.T) { + cfg, _, _ := axConfigFixture(t) + cfg.path = "/tmp/../tmp/config.yaml" + core.AssertContains(t, cfg.Path(), "../") +} + +func TestConfig_Config_Medium_Good(t *core.T) { + cfg, m, _ := axConfigFixture(t) + got := cfg.Medium() + core.AssertSame(t, m, got) +} + +func TestConfig_Config_Medium_Bad(t *core.T) { + cfg, _, _ := axConfigFixture(t) + cfg.medium = nil + got := cfg.Medium() + core.AssertNil(t, got) +} + +func TestConfig_Config_Medium_Ugly(t *core.T) { + cfg, _, _ := axConfigFixture(t) + replacement := coreio.NewMockMedium() + cfg.medium = replacement + core.AssertSame(t, replacement, cfg.Medium()) +} + +func TestConfig_Config_MergeFrom_Good(t *core.T) { + target, _, _ := axConfigFixture(t) + source, err := configResult(New(WithMedium(coreio.NewMockMedium()), WithPath("/ax7/source.yaml"))) + core.RequireNoError(t, err) + core.RequireNoError(t, resultError(source.Set(configTestDevEditorKey, "vim"))) + target.MergeFrom(source) + core.AssertEqual(t, "vim", mapFromSeq(target.All())[configTestDevEditorKey]) +} + +func TestConfig_Config_MergeFrom_Bad(t *core.T) { + target, _, _ := axConfigFixture(t) + target.MergeFrom(nil) + core.AssertEmpty(t, mapFromSeq(target.All())) +} + +func TestConfig_Config_MergeFrom_Ugly(t *core.T) { + target, _, _ := axConfigFixture(t) + source, err := configResult(New(WithMedium(coreio.NewMockMedium()), WithPath("/ax7/source.yaml"))) + core.RequireNoError(t, err) + core.RequireNoError(t, resultError(target.Set(configTestDevEditorKey, "emacs"))) + core.RequireNoError(t, resultError(source.Set(configTestDevEditorKey, "vim"))) + target.MergeFrom(source) + core.AssertEqual(t, "emacs", mapFromSeq(target.All())[configTestDevEditorKey]) +} + +func TestConfig_Config_OnChange_Good(t *core.T) { + cfg, _, _ := axConfigFixture(t) + seen := map[string]any{} + cfg.OnChange(func(key string, value any) { seen[key] = value }) + core.RequireNoError(t, resultError(cfg.Set(configTestDevEditorKey, "vim"))) + core.AssertEqual(t, "vim", seen[configTestDevEditorKey]) +} + +func TestConfig_Config_OnChange_Bad(t *core.T) { + cfg, _, _ := axConfigFixture(t) + cfg.OnChange(nil) + core.RequireNoError(t, resultError(cfg.Set(configTestDevEditorKey, "vim"))) + core.AssertEqual(t, "vim", mapFromSeq(cfg.All())[configTestDevEditorKey]) +} + +func TestConfig_Config_OnChange_Ugly(t *core.T) { + cfg, _, _ := axConfigFixture(t) + count := 0 + cfg.OnChange(func(string, any) { count++ }) + cfg.OnChange(func(string, any) { count++ }) + core.RequireNoError(t, resultError(cfg.Set(configTestDevEditorKey, "vim"))) + core.AssertEqual(t, 2, count) +} + +func TestConfig_Load_Good(t *core.T) { + m := coreio.NewMockMedium() + core.RequireNoError(t, m.Write(configTestAx7LoadPath, configTestCoreYAML)) + got, err := settingsResult(Load(m, configTestAx7LoadPath)) + core.AssertNoError(t, err) + core.AssertEqual(t, "core", got["app"].(map[string]any)["name"]) +} + +func TestConfig_Load_Bad(t *core.T) { + m := coreio.NewMockMedium() + got, err := settingsResult(Load(m, "/ax7/missing.yaml")) + core.AssertNil(t, got) + core.AssertError(t, err) +} + +func TestConfig_Load_Ugly(t *core.T) { + m := coreio.NewMockMedium() + core.RequireNoError(t, m.Write("/ax7/.env", "FOO=bar\n")) + got, err := settingsResult(Load(m, "/ax7/.env")) + core.AssertNoError(t, err) + core.AssertEqual(t, "bar", got["foo"]) +} + +func TestConfig_Save_Good(t *core.T) { + m := coreio.NewMockMedium() + err := resultError(Save(m, "/ax7/save.yaml", map[string]any{"app": map[string]any{"name": "core"}})) + core.AssertNoError(t, err) + core.AssertTrue(t, m.Exists("/ax7/save.yaml")) +} + +func TestConfig_Save_Bad(t *core.T) { + m := coreio.NewMockMedium() + err := resultError(Save(m, "/ax7/save.json", map[string]any{"app": "core"})) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), unsupportedConfigFileType) +} + +func TestConfig_Save_Ugly(t *core.T) { + m := coreio.NewMockMedium() + err := resultError(Save(m, "/ax7/save", nil)) + core.AssertNoError(t, err) + core.AssertTrue(t, m.Exists("/ax7/save")) +} + +func mapFromSeq(seq iter.Seq2[string, any]) map[string]any { + out := map[string]any{} + for key, value := range seq { + out[key] = value + } + return out +} + +func keysFromSeq(seq iter.Seq2[string, any]) []string { + var keys []string + for key := range seq { + keys = append(keys, key) + } + return keys +} diff --git a/go/discover.go b/go/discover.go new file mode 100644 index 0000000..87128d0 --- /dev/null +++ b/go/discover.go @@ -0,0 +1,166 @@ +package config + +import ( + core "dappco.re/go" + coreio "dappco.re/go/io" + coreerr "dappco.re/go/log" +) + +const callerDiscoverFrom = "config.DiscoverFrom" + +// Discover walks from the current working directory upward, collecting every +// `.core/` directory found, and returns a merged Config with closest-wins +// precedence. Walks stop at the filesystem root or when a `.git/` directory +// is found at the same level as a `.core/` (repository boundary). +// +// cfg, err := config.Discover() +// cfg.Get("build.target", &target) // merged from all .core/ dirs +func Discover(opts ...Option) core.Result { + r := core.Getwd() + if !r.OK { + return core.Fail(coreerr.E("config.Discover", "failed to read working directory", resultCause(r).(error))) + } + return DiscoverFrom(r.Value.(string), opts...) +} + +// DiscoverFrom walks upward from start and builds a merged Config. +// Primarily used by tests; callers usually want Discover(). +// +// cfg, _ := config.DiscoverFrom("/srv/app", config.WithMedium(io.Local)) +func DiscoverFrom(start string, opts ...Option) core.Result { + baseResult := newConfig(false, opts...) + if !baseResult.OK { + return core.Fail(coreerr.E(callerDiscoverFrom, "failed to initialise base config", resultCause(baseResult).(error))) + } + base := baseResult.Value.(*Config) + medium := base.medium + if medium == nil { + medium = coreio.Local + } + envPrefix := envPrefixOf(base.full) + + paths := discoverPaths(medium, start) + + // paths are ordered closest → furthest; closest-wins via MergeFrom. + for _, p := range paths { + layerOpts := []Option{WithMedium(medium), WithPath(p)} + if envPrefix != "" { + layerOpts = append(layerOpts, WithEnvPrefix(envPrefix)) + } + layerResult := New(layerOpts...) + if !layerResult.OK { + return core.Fail(coreerr.E(callerDiscoverFrom, "failed to load discovered config: "+p, resultCause(layerResult).(error))) + } + layer := layerResult.Value.(*Config) + base.MergeFrom(layer) + } + if r := base.loadStoreState(); !r.OK { + return core.Fail(coreerr.E(callerDiscoverFrom, "failed to load config store state", resultCause(r).(error))) + } + + return core.Ok(base) +} + +// discoverPaths returns paths to `.core/config.yaml` files from the starting +// directory up to the filesystem root (or repository boundary), followed by +// the global `~/.core/config.yaml` as the lowest-precedence layer. +func discoverPaths(medium coreio.Medium, start string) []string { + var paths []string + dir := normalizeUpwardStart(medium, start) + for { + coreDir := core.Path(dir, ".core") + if !isSymlinkedCoreDir(medium, coreDir) { + candidate := core.Path(coreDir, "config.yaml") + if medium.Exists(candidate) { + paths = append(paths, candidate) + } + } + + // Repository boundary: stop once a .git sits next to the .core dir. + if medium.Exists(core.Path(dir, ".git")) { + break + } + + parent := core.PathDir(dir) + if parent == dir { + break + } + dir = parent + } + + if home := core.Env("DIR_HOME"); home != "" { + globalCore := core.Path(home, ".core") + global := core.Path(globalCore, "config.yaml") + if !isSymlinkedCoreDir(medium, globalCore) && medium.Exists(global) && !contains(paths, global) { + paths = append(paths, global) + } + } + + return paths +} + +func contains(list []string, value string) bool { + for _, s := range list { + if s == value { + return true + } + } + return false +} + +// CoreDirs walks upward from start and returns every .core/ directory found, +// closest first. The walk stops at the filesystem root or when a .git sits at +// the same level as a .core. The user-global ~/.core/ is appended last. +// +// dirs := config.CoreDirs(io.Local, cwd) +// for _, dir := range dirs { /* check for build.yaml, test.yaml, ... */ } +func CoreDirs(medium coreio.Medium, start string) []string { + if medium == nil { + medium = coreio.Local + } + var dirs []string + dir := normalizeUpwardStart(medium, start) + for { + coreDir := core.Path(dir, ".core") + if medium.Exists(coreDir) && !isSymlinkedCoreDir(medium, coreDir) { + dirs = append(dirs, coreDir) + } + if medium.Exists(core.Path(dir, ".git")) { + break + } + parent := core.PathDir(dir) + if parent == dir { + break + } + dir = parent + } + if home := core.Env("DIR_HOME"); home != "" { + global := core.Path(home, ".core") + if medium.Exists(global) && !isSymlinkedCoreDir(medium, global) && !contains(dirs, global) { + dirs = append(dirs, global) + } + } + return dirs +} + +// FindManifest searches .core/ directories walking up from start for the first +// existing file with the given name (e.g. config.FileBuild). Returns the full +// path or an empty string if none is found. +// +// path := config.FindManifest(io.Local, cwd, config.FileBuild) +// if path != "" { config.LoadManifest(io.Local, path, &build) } +func FindManifest(medium coreio.Medium, start string, name string) string { + if medium == nil { + medium = coreio.Local + } + if !isSafePathElement(name) { + return "" + } + for _, dir := range CoreDirs(medium, start) { + candidate := core.Path(dir, name) + if medium.Exists(candidate) { + return candidate + } + } + return "" +} diff --git a/go/discover_example_test.go b/go/discover_example_test.go new file mode 100644 index 0000000..9bd49da --- /dev/null +++ b/go/discover_example_test.go @@ -0,0 +1,47 @@ +package config + +import ( + core "dappco.re/go" + coreio "dappco.re/go/io" +) + +func exampleDiscoveryMedium() (*coreio.MockMedium, string) { + m := coreio.NewMockMedium() + root := core.PathJoin("/", "workspace", "repo") + child := core.PathJoin(root, "cmd", "app") + _ = m.EnsureDir(core.PathJoin(root, ".core")) + _ = m.EnsureDir(core.PathJoin(root, ".git")) + _ = m.EnsureDir(child) + _ = m.Write(core.PathJoin(root, ".core", FileConfig), "app:\n name: discovered\n") + _ = m.Write(core.PathJoin(root, ".core", FileBuild), "version: 1\nproject:\n name: app\n") + return m, child +} + +func ExampleDiscover() { + cfg, err := configResult(Discover(WithMedium(coreio.NewMockMedium()))) + core.Println(err == nil && cfg != nil) + // Output: true +} + +func ExampleDiscoverFrom() { + m, child := exampleDiscoveryMedium() + cfg, err := configResult(DiscoverFrom(child, WithMedium(m))) + var name string + _ = cfg.Get("app.name", &name) + core.Println(err == nil, name) + // Output: true discovered +} + +func ExampleCoreDirs() { + m, child := exampleDiscoveryMedium() + dirs := CoreDirs(m, child) + core.Println(core.PathBase(dirs[0])) + // Output: .core +} + +func ExampleFindManifest() { + m, child := exampleDiscoveryMedium() + path := FindManifest(m, child, FileBuild) + core.Println(core.PathBase(path)) + // Output: build.yaml +} diff --git a/go/discover_test.go b/go/discover_test.go new file mode 100644 index 0000000..0af585e --- /dev/null +++ b/go/discover_test.go @@ -0,0 +1,247 @@ +package config + +import ( + core "dappco.re/go" + coreio "dappco.re/go/io" +) + +const ( + discoverAppNameKey = "app.name" + discoverDevEditorKey = "dev.editor" +) + +func TestDiscover_DiscoverFrom_Good(t *core.T) { + m := coreio.NewMockMedium() + repo := core.Path("repo") + sub := core.Path(repo, "service") + core.AssertNoError(t, m.EnsureDir(core.Path(repo, ".core"))) + core.AssertNoError(t, m.EnsureDir(core.Path(sub, ".core"))) + core.AssertNoError(t, m.EnsureDir(core.Path(repo, ".git"))) + core.AssertNoError(t, m.EnsureDir(sub)) + + core.AssertNoError(t, m.Write(core.Path(repo, ".core", FileConfig), "dev:\n editor: vim\napp:\n name: repo\n")) + core.AssertNoError(t, m.Write(core.Path(sub, ".core", FileConfig), "app:\n name: service\n")) + + cfg, err := configResult(DiscoverFrom(sub, WithMedium(m), WithPath(core.Path(sub, ".core", FileConfig)))) + core.AssertNoError(t, err) + + // Closest (service) wins on app.name. + var name string + core.AssertNoError(t, resultError(cfg.Get(discoverAppNameKey, &name))) + core.AssertEqual(t, "service", name) + + // Parent fills the gap on dev.editor. + var editor string + core.AssertNoError(t, resultError(cfg.Get(discoverDevEditorKey, &editor))) + core.AssertEqual(t, "vim", editor) +} + +func TestDiscover_DiscoverFrom_Bad(t *core.T) { + // A .core/config.yaml with malformed YAML makes the layered load fail. + m := coreio.NewMockMedium() + root := core.Path("bad-repo") + core.AssertNoError(t, m.EnsureDir(core.Path(root, ".core"))) + core.AssertNoError(t, m.Write(core.Path(root, ".core", FileConfig), "invalid: [yaml")) + + _, err := configResult(DiscoverFrom(root, WithMedium(m))) + core.AssertError(t, err) +} + +func TestDiscover_DiscoverFrom_Ugly(t *core.T) { + // Empty start directory — uses filesystem root walk, should still return a + // usable (but empty) config rather than panicking. + m := coreio.NewMockMedium() + cfg, err := configResult(DiscoverFrom("/nonexistent/path", WithMedium(m), WithPath(core.Path("/nonexistent/path", FileConfig)))) + core.AssertNoError(t, err) + core.AssertNotNil(t, cfg) +} + +func TestDiscover_CoreDirs_Good(t *core.T) { + m := coreio.NewMockMedium() + repo := core.Path("dirs-repo") + sub := core.Path(repo, "service") + core.AssertNoError(t, m.EnsureDir(core.Path(repo, ".core"))) + core.AssertNoError(t, m.EnsureDir(core.Path(sub, ".core"))) + core.AssertNoError(t, m.EnsureDir(core.Path(repo, ".git"))) + core.AssertNoError(t, m.EnsureDir(sub)) + + dirs := CoreDirs(m, sub) + // Closest first: sub/.core, then repo/.core. Walk stops at repo (.git boundary). + core.AssertGreaterOrEqual(t, len(dirs), 2) + core.AssertEqual(t, core.Path(sub, ".core"), dirs[0]) + core.AssertEqual(t, core.Path(repo, ".core"), dirs[1]) +} + +func TestDiscover_CoreDirs_Bad(t *core.T) { + // A directory tree with no .core anywhere just returns the home layer (if any). + m := coreio.NewMockMedium() + root := core.Path("empty-repo") + core.AssertNoError(t, m.EnsureDir(root)) + dirs := CoreDirs(m, root) + for _, dir := range dirs { + core.AssertNotContains(t, dir, root) + } +} + +func TestDiscover_FindManifest_Good(t *core.T) { + m := coreio.NewMockMedium() + repo := core.Path("manifest-repo") + sub := core.Path(repo, "service") + core.AssertNoError(t, m.EnsureDir(core.Path(repo, ".core"))) + core.AssertNoError(t, m.EnsureDir(core.Path(sub, ".core"))) + core.AssertNoError(t, m.EnsureDir(core.Path(repo, ".git"))) + core.AssertNoError(t, m.EnsureDir(sub)) + // Only the repo-level .core/ has build.yaml. + core.AssertNoError(t, m.Write(core.Path(repo, ".core", "build.yaml"), "name: core\n")) + + path := FindManifest(m, sub, FileBuild) + core.AssertEqual(t, core.Path(repo, ".core", "build.yaml"), path) +} + +func TestDiscover_FindManifest_Ugly(t *core.T) { + // Missing file returns empty string, not an error. + m := coreio.NewMockMedium() + start := core.Path("missing-repo") + got := FindManifest(m, start, FileBuild) + core.AssertEmpty(t, got) +} + +func TestDiscover_DiscoverFrom_EnvOverridesDiscovered_Good(t *core.T) { + // .core/ convention §5.3: "Env vars override everything." + // A discovered file value must be shadowed by CORE_CONFIG_* at Get time. + m := coreio.NewMockMedium() + root := core.Path("env-repo") + t.Setenv("CORE_CONFIG_APP_NAME", "env-wins") + + core.AssertNoError(t, m.EnsureDir(core.Path(root, ".core"))) + core.AssertNoError(t, m.Write( + core.Path(root, ".core", FileConfig), + "app:\n name: fromfile\n", + )) + + cfg, err := configResult(DiscoverFrom(root, WithMedium(m))) + core.AssertNoError(t, err) + + var name string + core.AssertNoError(t, resultError(cfg.Get(discoverAppNameKey, &name))) + core.AssertEqual(t, "env-wins", name) +} + +func TestDiscover_DiscoverFrom_MergeFillsGaps_Good(t *core.T) { + // Project .core/ wins over global .core/ — global only fills gaps. + m := coreio.NewMockMedium() + repo := core.Path("merge-repo") + core.AssertNoError(t, m.EnsureDir(core.Path(repo, ".core"))) + core.AssertNoError(t, m.EnsureDir(core.Path(repo, ".git"))) + core.AssertNoError(t, m.Write( + core.Path(repo, ".core", FileConfig), + "app:\n name: project\n", + )) + + cfg, err := configResult(DiscoverFrom(repo, WithMedium(m))) + core.AssertNoError(t, err) + + var name string + core.AssertNoError(t, resultError(cfg.Get(discoverAppNameKey, &name))) + core.AssertEqual(t, "project", name) +} + +func TestDiscover_DiscoverFrom_CommitDoesNotLeakInherited_Good(t *core.T) { + // Regression guard: Commit on a discovered Config must only persist the + // owning file's keys + Set() calls, never inherited layer values — or + // global ~/.core/ secrets would spray into every project config. + m := coreio.NewMockMedium() + repo := core.Path("commit-repo") + core.AssertNoError(t, m.EnsureDir(core.Path(repo, ".core"))) + core.AssertNoError(t, m.EnsureDir(core.Path(repo, ".git"))) + core.AssertNoError(t, m.Write( + core.Path(repo, ".core", FileConfig), + "secret:\n token: GLOBAL_ONLY\n", + )) + + commitPath := core.Path("commit-repo", "newcfg.yaml") + cfg, err := configResult(DiscoverFrom(repo, WithMedium(m), WithPath(commitPath))) + core.AssertNoError(t, err) + + // Set our own key then Commit; the inherited GLOBAL_ONLY must NOT appear. + core.AssertNoError(t, resultError(cfg.Set("dev.shell", "zsh"))) + core.AssertNoError(t, resultError(cfg.Commit())) + + body, err := m.Read(commitPath) + core.AssertNoError(t, err) + core.AssertContains(t, body, "dev:") + core.AssertContains(t, body, "shell: zsh") + core.AssertNotContains(t, body, "GLOBAL_ONLY") + core.AssertNotContains(t, body, "secret:") +} + +func TestDiscover_DiscoverFrom_GlobalFallback_Good(t *core.T) { + m := coreio.NewMockMedium() + repo := core.Path("global-repo") + home := core.Env("DIR_HOME") + + core.AssertNoError(t, m.EnsureDir(core.Path(repo, ".git"))) + core.AssertNoError(t, m.EnsureDir(core.Path(home, ".core"))) + core.AssertNoError(t, m.Write(core.Path(home, ".core", FileConfig), "app:\n name: global\n")) + + cfg, err := configResult(DiscoverFrom(repo, WithMedium(m))) + core.AssertNoError(t, err) + + var name string + core.AssertNoError(t, resultError(cfg.Get(discoverAppNameKey, &name))) + core.AssertEqual(t, "global", name) +} + +func TestDiscover_Discover_Good(t *core.T) { + root := t.TempDir() + coreDir := core.PathJoin(root, ".core") + testMkdirAll(t, coreDir, 0o755) + testWriteFile(t, core.PathJoin(coreDir, FileConfig), []byte("app:\n name: discovered\n"), 0o600) + previous := testGetwd(t) + testChdir(t, root) + t.Cleanup(func() { testChdir(t, previous) }) + + cfg, err := configResult(Discover()) + core.RequireNoError(t, err) + var got string + core.AssertNoError(t, resultError(cfg.Get(discoverAppNameKey, &got))) + core.AssertEqual(t, "discovered", got) +} + +func TestDiscover_Discover_Bad(t *core.T) { + root := t.TempDir() + coreDir := core.PathJoin(root, ".core") + testMkdirAll(t, coreDir, 0o755) + testWriteFile(t, core.PathJoin(coreDir, FileConfig), []byte("bad: [yaml"), 0o600) + previous := testGetwd(t) + testChdir(t, root) + t.Cleanup(func() { testChdir(t, previous) }) + + cfg, err := configResult(Discover()) + core.AssertNil(t, cfg) + core.AssertError(t, err) +} + +func TestDiscover_Discover_Ugly(t *core.T) { + root := t.TempDir() + previous := testGetwd(t) + testChdir(t, root) + t.Cleanup(func() { testChdir(t, previous) }) + + cfg, err := configResult(Discover()) + core.RequireNoError(t, err) + core.AssertError(t, cfg.Get("missing", new(string))) +} + +func TestDiscover_CoreDirs_Ugly(t *core.T) { + m := coreio.NewMockMedium() + start := core.Path("lonely", "service") + got := CoreDirs(m, start) + core.AssertEmpty(t, got) +} + +func TestDiscover_FindManifest_Bad(t *core.T) { + m := coreio.NewMockMedium() + got := FindManifest(m, core.Path("repo"), "../config.yaml") + core.AssertEqual(t, "", got) +} diff --git a/go/docs b/go/docs new file mode 120000 index 0000000..a9594bf --- /dev/null +++ b/go/docs @@ -0,0 +1 @@ +../docs \ No newline at end of file diff --git a/env.go b/go/env.go similarity index 93% rename from env.go rename to go/env.go index de9e087..1849e71 100644 --- a/env.go +++ b/go/env.go @@ -1,8 +1,9 @@ package config import ( + "cmp" "iter" - "sort" + "slices" core "dappco.re/go" ) @@ -53,8 +54,8 @@ func Env(prefix string) iter.Seq2[string, any] { entries = append(entries, entry{key: key, value: value}) } - sort.Slice(entries, func(i, j int) bool { - return entries[i].key < entries[j].key + slices.SortFunc(entries, func(a, b entry) int { + return cmp.Compare(a.key, b.key) }) for _, entry := range entries { diff --git a/go/env_example_test.go b/go/env_example_test.go new file mode 100644 index 0000000..f569cd5 --- /dev/null +++ b/go/env_example_test.go @@ -0,0 +1,18 @@ +package config + +import core "dappco.re/go" + +func ExampleEnv() { + count := 0 + for range Env("CONFIG_EXAMPLE_NOT_SET_") { + count++ + } + core.Println(count) + // Output: 0 +} + +func ExampleLoadEnv() { + values := LoadEnv("CONFIG_EXAMPLE_NOT_SET_") + core.Println(len(values)) + // Output: 0 +} diff --git a/go/env_test.go b/go/env_test.go new file mode 100644 index 0000000..dcee81f --- /dev/null +++ b/go/env_test.go @@ -0,0 +1,79 @@ +package config + +import core "dappco.re/go" + +func TestEnv_Env_Good(t *core.T) { + t.Setenv("CORE_CONFIG_APP_NAME", "core") + t.Setenv("CORE_CONFIG_DEV_EDITOR", "vim") + + got := map[string]string{} + for key, value := range Env("CORE_CONFIG_") { + got[key] = value.(string) + } + + core.AssertEqual(t, map[string]string{ + "app.name": "core", + "dev.editor": "vim", + }, got) +} + +func TestEnv_LoadEnv_Bad(t *core.T) { + t.Setenv("MYAPP_FEATURE_FLAG", "true") + + got := LoadEnv("CORE_CONFIG") + core.AssertEmpty(t, got) +} + +func TestEnv_Env_Ugly(t *core.T) { + // Prefix normalisation accepts the trailing underscore form too. + t.Setenv("MYAPP_FOO_BAR", "baz") + + var keys []string + for key := range Env("MYAPP_") { + keys = append(keys, key) + } + + core.AssertEqual(t, []string{"foo.bar"}, keys) +} + +func TestEnv_normaliseEnvPrefix_Good(t *core.T) { + got := normaliseEnvPrefix("CORE_CONFIG") + core.AssertEqual(t, "CORE_CONFIG_", got) + + got = normaliseEnvPrefix("CORE_CONFIG_") + core.AssertEqual(t, "CORE_CONFIG_", got) +} + +func TestEnv_normaliseEnvPrefix_Bad(t *core.T) { + got := normaliseEnvPrefix("") + core.AssertEqual(t, "", got) + core.AssertFalse(t, core.HasSuffix(got, "_")) +} + +func TestEnv_normaliseEnvPrefix_Ugly(t *core.T) { + // A nil-like value is still normalised consistently. + got := normaliseEnvPrefix("my_app") + core.AssertEqual(t, "my_app_", got) + core.AssertTrue(t, core.HasSuffix(got, "_")) +} + +func TestEnv_Env_Bad(t *core.T) { + t.Setenv("AX7_OTHER_NAME", "codex") + got := map[string]any{} + for key, value := range Env("AX7_CONFIG") { + got[key] = value + } + core.AssertEmpty(t, got) +} + +func TestEnv_LoadEnv_Good(t *core.T) { + t.Setenv("AX7_LOAD_NAME", "codex") + got := LoadEnv("AX7_LOAD") + core.AssertEqual(t, "codex", got["name"]) +} + +func TestEnv_LoadEnv_Ugly(t *core.T) { + t.Setenv("AX7_EMPTY_VALUE", "") + got := LoadEnv("AX7_EMPTY") + core.AssertEqual(t, "", got["value"]) +} diff --git a/go/feature.go b/go/feature.go new file mode 100644 index 0000000..7d737ba --- /dev/null +++ b/go/feature.go @@ -0,0 +1,150 @@ +package config + +import ( + "strconv" + "sync" + + core "dappco.re/go" +) + +// featurePrefix is the environment variable prefix for feature flag overrides. +const featurePrefix = "CORE_FEATURE_" + +// featureConfigKey is the config-file key under which feature flags live. A +// .core/config.yaml entry `features: { dark-mode: true }` maps to the flag +// `dark-mode`. +const featureConfigKey = "features" + +var ( + featureMu sync.RWMutex + featureDefault = &featureRegistry{values: map[string]bool{}} + featureSource *Config +) + +type featureRegistry struct { + values map[string]bool +} + +// Feature returns whether a feature flag is enabled. Checks in order: +// environment variable, loaded config (`features.*`), process-level registry, +// then false. +// +// if config.Feature("dark-mode") { +// theme.UseDark() +// } +func Feature(name string) bool { + if v, ok := featureEnv(name); ok { + return v + } + if v, ok := featureFromSource(name); ok { + return v + } + featureMu.RLock() + defer featureMu.RUnlock() + return featureDefault.values[name] +} + +// FeatureFromConfig checks a specific Config instance for a feature flag without +// touching process-level state. Environment overrides still win. +// +// if config.FeatureFromConfig(cfg, "beta-api") { mux.Use(betaRoutes) } +func FeatureFromConfig(cfg *Config, name string) bool { + if v, ok := featureEnv(name); ok { + return v + } + if cfg == nil { + return false + } + return featureLookup(cfg, name) +} + +// SetFeatureSource registers a Config instance to back Feature() lookups from +// loaded config files. Pass nil to clear. This lets a Core service publish its +// `features:` tree as the global source without every caller plumbing the cfg. +// +// cfg, _ := config.Discover() +// config.SetFeatureSource(cfg) +// defer config.SetFeatureSource(nil) +func SetFeatureSource(cfg *Config) { + featureMu.Lock() + defer featureMu.Unlock() + featureSource = cfg +} + +func featureFromSource(name string) (bool, bool) { + featureMu.RLock() + cfg := featureSource + featureMu.RUnlock() + if cfg == nil { + return false, false + } + return featureLookup(cfg, name), cfg.hasFeature(name) +} + +func featureLookup(cfg *Config, name string) bool { + cfg.mu.RLock() + defer cfg.mu.RUnlock() + key := featureConfigKey + "." + name + if !cfg.full.IsSet(key) { + return false + } + return cfg.full.GetBool(key) +} + +// hasFeature reports whether a flag is explicitly present in the loaded config. +// Used by Feature() to decide whether the config layer should be trusted vs +// falling through to the process-level registry. +func (c *Config) hasFeature(name string) bool { + c.mu.RLock() + defer c.mu.RUnlock() + return c.full.IsSet(featureConfigKey + "." + name) +} + +// SetFeature sets a process-level feature flag. Environment variables still win. +// +// config.SetFeature("dark-mode", true) +func SetFeature(name string, enabled bool) { + featureMu.Lock() + defer featureMu.Unlock() + featureDefault.values[name] = enabled +} + +// Features returns the set of enabled feature flag names from the process-level +// registry. Environment overrides are not included in the returned slice. +// +// for _, flag := range config.Features() { +// fmt.Println(flag) +// } +func Features() []string { + featureMu.RLock() + defer featureMu.RUnlock() + var out []string + for name, enabled := range featureDefault.values { + if enabled { + out = append(out, name) + } + } + return out +} + +func featureEnv(name string) (bool, bool) { + envName := featurePrefix + core.Upper(core.Replace(name, "-", "_")) + raw, ok := core.LookupEnv(envName) + if !ok { + return false, false + } + b, err := strconv.ParseBool(raw) + if err != nil { + return false, false + } + return b, true +} + +// resetFeatureRegistry clears process-level feature state. Test-only helper; +// the exported Feature/SetFeature API is the public contract. +func resetFeatureRegistry() { + featureMu.Lock() + defer featureMu.Unlock() + featureDefault = &featureRegistry{values: map[string]bool{}} + featureSource = nil +} diff --git a/go/feature_example_test.go b/go/feature_example_test.go new file mode 100644 index 0000000..20466c8 --- /dev/null +++ b/go/feature_example_test.go @@ -0,0 +1,54 @@ +package config + +import ( + core "dappco.re/go" + coreio "dappco.re/go/io" +) + +func ExampleFeature() { + resetFeatureRegistry() + defer resetFeatureRegistry() + SetFeature("dark-mode", true) + core.Println(Feature("dark-mode")) + // Output: true +} + +func ExampleFeatureFromConfig() { + cfg, _ := configResult(New( + WithMedium(coreio.NewMockMedium()), + WithPath("/example/config.yaml"), + WithDefaults(map[string]any{"features.dark-mode": true}), + )) + core.Println(FeatureFromConfig(cfg, "dark-mode")) + // Output: true +} + +func ExampleSetFeatureSource() { + resetFeatureRegistry() + defer resetFeatureRegistry() + cfg, _ := configResult(New( + WithMedium(coreio.NewMockMedium()), + WithPath("/example/config.yaml"), + WithDefaults(map[string]any{"features.beta": true}), + )) + SetFeatureSource(cfg) + core.Println(Feature("beta")) + // Output: true +} + +func ExampleSetFeature() { + resetFeatureRegistry() + defer resetFeatureRegistry() + SetFeature("beta", true) + core.Println(Feature("beta")) + // Output: true +} + +func ExampleFeatures() { + resetFeatureRegistry() + defer resetFeatureRegistry() + SetFeature("alpha", true) + SetFeature("beta", false) + core.Println(Features()[0]) + // Output: alpha +} diff --git a/go/feature_test.go b/go/feature_test.go new file mode 100644 index 0000000..8bcbde6 --- /dev/null +++ b/go/feature_test.go @@ -0,0 +1,198 @@ +package config + +import ( + core "dappco.re/go" + coreio "dappco.re/go/io" +) + +const ( + featureDarkModeFlag = "dark-mode" + featureBetaAPIFlag = "beta-api" + featureCfgPath = "/cfg.yaml" + featureDarkModeYAML = "features:\n dark-mode: true\n" +) + +func TestFeature_Feature_Good(t *core.T) { + resetFeatureRegistry() + t.Cleanup(resetFeatureRegistry) + + core.AssertFalse(t, Feature(featureDarkModeFlag)) + SetFeature(featureDarkModeFlag, true) + core.AssertTrue(t, Feature(featureDarkModeFlag)) + core.AssertContains(t, Features(), featureDarkModeFlag) +} + +func TestFeature_Feature_Bad(t *core.T) { + resetFeatureRegistry() + t.Cleanup(resetFeatureRegistry) + + // Unknown flag → false, no panic. + core.AssertFalse(t, Feature("never-declared")) +} + +func TestFeature_Feature_Ugly(t *core.T) { + resetFeatureRegistry() + t.Cleanup(resetFeatureRegistry) + + // Environment override wins over registry, including mapping hyphens to underscores. + t.Setenv("CORE_FEATURE_DARK_MODE", "true") + SetFeature(featureDarkModeFlag, false) + core.AssertTrue(t, Feature(featureDarkModeFlag)) +} + +func TestFeature_SetFeature_Good(t *core.T) { + resetFeatureRegistry() + t.Cleanup(resetFeatureRegistry) + + SetFeature(featureBetaAPIFlag, true) + SetFeature("verbose-logging", false) + flags := Features() + core.AssertContains(t, flags, featureBetaAPIFlag) + core.AssertNotContains(t, flags, "verbose-logging") +} + +func TestFeature_FeatureFromConfig_LoadsConfig_Good(t *core.T) { + resetFeatureRegistry() + t.Cleanup(resetFeatureRegistry) + + // A loaded config with `features.dark-mode: true` enables the flag without + // any env var or process-level SetFeature call. + m := coreio.NewMockMedium() + m.Files[featureCfgPath] = featureDarkModeYAML + " beta-api: false\n" + + cfg, err := configResult(New(WithMedium(m), WithPath(featureCfgPath))) + core.AssertNoError(t, err) + + core.AssertTrue(t, FeatureFromConfig(cfg, featureDarkModeFlag)) + core.AssertFalse(t, FeatureFromConfig(cfg, featureBetaAPIFlag)) + core.AssertFalse(t, FeatureFromConfig(cfg, "never-declared")) +} + +func TestFeature_FeatureFromConfig_NilConfig_Bad(t *core.T) { + resetFeatureRegistry() + t.Cleanup(resetFeatureRegistry) + + // Nil config must never panic; returns false for every flag. + core.AssertFalse(t, FeatureFromConfig(nil, featureDarkModeFlag)) +} + +func TestFeature_FeatureFromConfig_EnvOverride_Ugly(t *core.T) { + resetFeatureRegistry() + t.Cleanup(resetFeatureRegistry) + + // Environment override still wins over a loaded config value. + t.Setenv("CORE_FEATURE_DARK_MODE", "false") + + m := coreio.NewMockMedium() + m.Files[featureCfgPath] = featureDarkModeYAML + cfg, err := configResult(New(WithMedium(m), WithPath(featureCfgPath))) + core.AssertNoError(t, err) + + core.AssertFalse(t, FeatureFromConfig(cfg, featureDarkModeFlag)) +} + +func TestFeature_SetFeatureSource_Good(t *core.T) { + resetFeatureRegistry() + t.Cleanup(resetFeatureRegistry) + + m := coreio.NewMockMedium() + m.Files[featureCfgPath] = featureDarkModeYAML + cfg, err := configResult(New(WithMedium(m), WithPath(featureCfgPath))) + core.AssertNoError(t, err) + + // Before registering the source, the flag is false (default registry). + core.AssertFalse(t, Feature(featureDarkModeFlag)) + + SetFeatureSource(cfg) + t.Cleanup(func() { SetFeatureSource(nil) }) + core.AssertTrue(t, Feature(featureDarkModeFlag)) +} + +func TestFeature_SetFeatureSource_Bad(t *core.T) { + resetFeatureRegistry() + t.Cleanup(resetFeatureRegistry) + + // Registering a nil source is a safe reset — no panic on lookup afterwards. + SetFeatureSource(nil) + core.AssertFalse(t, Feature(featureDarkModeFlag)) +} + +func TestFeature_FeatureFromConfig_Good(t *core.T) { + resetFeatureRegistry() + t.Cleanup(resetFeatureRegistry) + m := coreio.NewMockMedium() + core.RequireNoError(t, m.Write("/ax7/features.yaml", featureDarkModeYAML)) + cfg, err := configResult(New(WithMedium(m), WithPath("/ax7/features.yaml"))) + core.RequireNoError(t, err) + + got := FeatureFromConfig(cfg, featureDarkModeFlag) + core.AssertTrue(t, got) +} + +func TestFeature_FeatureFromConfig_Bad(t *core.T) { + resetFeatureRegistry() + t.Cleanup(resetFeatureRegistry) + got := FeatureFromConfig(nil, featureDarkModeFlag) + core.AssertFalse(t, got) +} + +func TestFeature_FeatureFromConfig_Ugly(t *core.T) { + resetFeatureRegistry() + t.Cleanup(resetFeatureRegistry) + t.Setenv("CORE_FEATURE_DARK_MODE", "true") + got := FeatureFromConfig(nil, featureDarkModeFlag) + core.AssertTrue(t, got) +} + +func TestFeature_SetFeatureSource_Ugly(t *core.T) { + resetFeatureRegistry() + t.Cleanup(resetFeatureRegistry) + first, err := configResult(New(WithMedium(coreio.NewMockMedium()), WithPath("/ax7/first.yaml"), WithDefaults(map[string]any{"features.dark-mode": true}))) + core.RequireNoError(t, err) + second, err := configResult(New(WithMedium(coreio.NewMockMedium()), WithPath("/ax7/second.yaml"), WithDefaults(map[string]any{"features.dark-mode": false}))) + core.RequireNoError(t, err) + + SetFeatureSource(first) + SetFeatureSource(second) + core.AssertFalse(t, Feature(featureDarkModeFlag)) +} + +func TestFeature_SetFeature_Bad(t *core.T) { + resetFeatureRegistry() + t.Cleanup(resetFeatureRegistry) + SetFeature(featureDarkModeFlag, false) + got := Feature(featureDarkModeFlag) + core.AssertFalse(t, got) +} + +func TestFeature_SetFeature_Ugly(t *core.T) { + resetFeatureRegistry() + t.Cleanup(resetFeatureRegistry) + SetFeature("", true) + got := Features() + core.AssertContains(t, got, "") +} + +func TestFeature_Features_Good(t *core.T) { + resetFeatureRegistry() + t.Cleanup(resetFeatureRegistry) + SetFeature("alpha", true) + SetFeature("beta", true) + got := Features() + core.AssertContains(t, got, "alpha") +} + +func TestFeature_Features_Bad(t *core.T) { + resetFeatureRegistry() + t.Cleanup(resetFeatureRegistry) + SetFeature("alpha", false) + got := Features() + core.AssertNotContains(t, got, "alpha") +} + +func TestFeature_Features_Ugly(t *core.T) { + resetFeatureRegistry() + t.Cleanup(resetFeatureRegistry) + got := Features() + core.AssertEmpty(t, got) +} diff --git a/go/go.mod b/go/go.mod new file mode 100644 index 0000000..4a146c4 --- /dev/null +++ b/go/go.mod @@ -0,0 +1,36 @@ +module dappco.re/go/config + +go 1.26.0 + +require ( + dappco.re/go v0.9.0 + github.com/fsnotify/fsnotify v1.9.0 + github.com/spf13/viper v1.21.0 + github.com/xeipuuv/gojsonschema v1.2.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect + github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect +) + +require ( + dappco.re/go/io v0.9.0 + dappco.re/go/log v0.9.0 + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/sagikazarmark/locafero v0.12.0 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/text v0.36.0 // indirect +) diff --git a/go/go.sum b/go/go.sum new file mode 100644 index 0000000..c9bb439 --- /dev/null +++ b/go/go.sum @@ -0,0 +1,100 @@ +dappco.re/go v0.9.0 h1:4ruZRNqKDDva8o6g65tYggjGVe42E6/lMZfVKXtr3p0= +dappco.re/go v0.9.0/go.mod h1:xapr7fLK4/9Pu2iSCr4qZuIuatmtx1j56zS/oPDbGyQ= +dappco.re/go/io v0.9.0 h1:TyHUuUJdZ73CXQlBpqx47SNyFFzgwA5OPSKu4Twb2f0= +dappco.re/go/io v0.9.0/go.mod h1:K5jWSLMdk0X9HqJ6b1I+8tKqcNpNWgpcUZi/fGm28Q8= +dappco.re/go/log v0.9.0 h1:9+OiBUDyUNvqZZ++XemcjJPCgypr+Yf/1e5OP3X2nrk= +dappco.re/go/log v0.9.0/go.mod h1:IC04Em9SfVTcXiWc1BqZDQfa1MtOuMDEermZkQcTz9c= +forge.lthn.ai/Snider/Borg v0.3.1 h1:gfC1ZTpLoZai07oOWJiVeQ8+qJYK8A795tgVGJHbVL8= +forge.lthn.ai/Snider/Borg v0.3.1/go.mod h1:Z7DJD0yHXsxSyM7Mjl6/g4gH1NBsIz44Bf5AFlV76Wg= +forge.lthn.ai/Snider/Enchantrix v0.0.4 h1:biwpix/bdedfyc0iVeK15awhhJKH6TEMYOTXzHXx5TI= +forge.lthn.ai/Snider/Enchantrix v0.0.4/go.mod h1:OGCwuVeZPq3OPe2h6TX/ZbgEjHU6B7owpIBeXQGbSe0= +github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= +github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= +github.com/aws/aws-sdk-go-v2 v1.41.4 h1:10f50G7WyU02T56ox1wWXq+zTX9I1zxG46HYuG1hH/k= +github.com/aws/aws-sdk-go-v2 v1.41.4/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7 h1:3kGOqnh1pPeddVa/E37XNTaWJ8W6vrbYV9lJEkCnhuY= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 h1:CNXO7mvgThFGqOFgbNAP2nol2qAWBOGfqR/7tQlvLmc= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20/go.mod h1:oydPDJKcfMhgfcgBUZaG+toBbwy8yPWubJXBVERtI4o= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 h1:tN6W/hg+pkM+tf9XDkWUbDEjGLb+raoBMFsTodcoYKw= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20/go.mod h1:YJ898MhD067hSHA6xYCx5ts/jEd8BSOLtQDL3iZsvbc= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.21 h1:SwGMTMLIlvDNyhMteQ6r8IJSBPlRdXX5d4idhIGbkXA= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.21/go.mod h1:UUxgWxofmOdAMuqEsSppbDtGKLfR04HGsD0HXzvhI1k= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.12 h1:qtJZ70afD3ISKWnoX3xB0J2otEqu3LqicRcDBqsj0hQ= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.12/go.mod h1:v2pNpJbRNl4vEUWEh5ytQok0zACAKfdmKS51Hotc3pQ= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 h1:2HvVAIq+YqgGotK6EkMf+KIEqTISmTYh5zLpYyeTo1Y= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20/go.mod h1:V4X406Y666khGa8ghKmphma/7C0DAtEQYhkq9z4vpbk= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.20 h1:siU1A6xjUZ2N8zjTHSXFhB9L/2OY8Dqs0xXiLjF30jA= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.20/go.mod h1:4TLZCmVJDM3FOu5P5TJP0zOlu9zWgDWU7aUxWbr+rcw= +github.com/aws/aws-sdk-go-v2/service/s3 v1.97.1 h1:csi9NLpFZXb9fxY7rS1xVzgPRGMt7MSNWeQ6eo247kE= +github.com/aws/aws-sdk-go-v2/service/s3 v1.97.1/go.mod h1:qXVal5H0ChqXP63t6jze5LmFalc7+ZE7wOdLtZ0LCP0= +github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= +github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= +github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= +github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU= +github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4= +github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= +github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= +github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/go/images_manifest.go b/go/images_manifest.go new file mode 100644 index 0000000..58429e8 --- /dev/null +++ b/go/images_manifest.go @@ -0,0 +1,152 @@ +package config + +import ( + "io/fs" + "time" + + core "dappco.re/go" + coreio "dappco.re/go/io" + coreerr "dappco.re/go/log" + "github.com/xeipuuv/gojsonschema" +) + +const ( + callerLoadImagesManifest = "config.LoadImagesManifest" + callerSaveImagesManifest = "config.SaveImagesManifest" + callerValidateImagesSchema = "config.validateImagesSchema" +) + +var defaultImagesManifestMedium = func() coreio.Medium { + return coreio.Local +} + +// ImagesManifest tracks user-level LinuxKit image metadata under +// ~/.core/images/manifest.json. +// +// manifest, err := config.ResolveImagesManifest(io.Local) +type ImagesManifest struct { + Images map[string]ImageInfo `json:"images" yaml:"images"` +} + +// ImageInfo stores metadata for a single installed image. +// +// info := config.ImageInfo{ +// Version: "1.0.0", +// SHA256: "abc123", +// Downloaded: time.Now(), +// Source: "github", +// } +type ImageInfo struct { + Version string `json:"version" yaml:"version"` + SHA256 string `json:"sha256,omitempty" yaml:"sha256,omitempty"` + Downloaded time.Time `json:"downloaded" yaml:"downloaded"` + Source string `json:"source" yaml:"source"` +} + +// ResolveImagesManifest loads the user-global images registry from +// ~/.core/images/manifest.json. If the file does not exist, it returns an +// empty manifest instead of an error so callers can initialise the registry +// on demand. +func ResolveImagesManifest(medium coreio.Medium) core.Result { + if medium == nil { + medium = defaultImagesManifestMedium() + } + path := FindUserImagesManifest(medium) + if path == "" { + return core.Ok(&ImagesManifest{Images: map[string]ImageInfo{}}) + } + return LoadImagesManifest(medium, path) +} + +// LoadImagesManifest reads a JSON images registry from disk. +func LoadImagesManifest(medium coreio.Medium, path string) core.Result { + if medium == nil { + medium = defaultImagesManifestMedium() + } + manifest := &ImagesManifest{Images: map[string]ImageInfo{}} + + content, err := medium.Read(path) + if err != nil { + if core.Is(err, fs.ErrNotExist) { + return core.Ok(manifest) + } + return core.Fail(coreerr.E(callerLoadImagesManifest, "failed to read images manifest: "+path, err)) + } + + var raw map[string]any + if r := core.JSONUnmarshalString(content, &raw); !r.OK { + return core.Fail(coreerr.E(callerLoadImagesManifest, "failed to parse images manifest: "+path, resultCause(r).(error))) + } + if r := validateImagesSchema(path, raw); !r.OK { + return r + } + if r := core.JSONUnmarshalString(content, manifest); !r.OK { + return core.Fail(coreerr.E(callerLoadImagesManifest, "failed to decode images manifest: "+path, resultCause(r).(error))) + } + if manifest.Images == nil { + manifest.Images = map[string]ImageInfo{} + } + return core.Ok(manifest) +} + +// SaveImagesManifest writes the JSON images registry to disk. +func SaveImagesManifest(medium coreio.Medium, path string, manifest *ImagesManifest) core.Result { + if medium == nil { + medium = defaultImagesManifestMedium() + } + if manifest == nil { + manifest = &ImagesManifest{Images: map[string]ImageInfo{}} + } + if manifest.Images == nil { + manifest.Images = map[string]ImageInfo{} + } + + payloadResult := core.JSONMarshal(manifest) + if !payloadResult.OK { + return core.Fail(coreerr.E(callerSaveImagesManifest, "failed to marshal images manifest", resultCause(payloadResult).(error))) + } + payload := payloadResult.Value.([]byte) + + dir := core.PathDir(path) + if err := medium.EnsureDir(dir); err != nil { + return core.Fail(coreerr.E(callerSaveImagesManifest, "failed to create images manifest directory: "+dir, err)) + } + if err := medium.WriteMode(path, string(payload), 0o600); err != nil { + return core.Fail(coreerr.E(callerSaveImagesManifest, "failed to write images manifest: "+path, err)) + } + return core.Ok(nil) +} + +func validateImagesSchema(path string, raw map[string]any) core.Result { + if len(raw) == 0 { + return core.Ok(nil) + } + + schemaBody, err := schemaFS.ReadFile("schema/images.schema.json") + if err != nil { + return core.Fail(coreerr.E(callerValidateImagesSchema, "failed to read embedded schema: schema/images.schema.json", err)) + } + + documentResult := core.JSONMarshal(raw) + if !documentResult.OK { + return core.Fail(coreerr.E(callerValidateImagesSchema, "failed to encode images manifest for schema validation: "+path, resultCause(documentResult).(error))) + } + documentBody := documentResult.Value.([]byte) + + result, err := gojsonschema.Validate( + gojsonschema.NewBytesLoader(schemaBody), + gojsonschema.NewBytesLoader(documentBody), + ) + if err != nil { + return core.Fail(coreerr.E(callerValidateImagesSchema, "schema validation failed: "+path, err)) + } + if result.Valid() { + return core.Ok(nil) + } + + var problems []string + for _, issue := range result.Errors() { + problems = append(problems, issue.String()) + } + return core.Fail(coreerr.E(callerValidateImagesSchema, "schema validation failed: "+path+": "+core.Join("; ", problems...), nil)) +} diff --git a/go/images_manifest_example_test.go b/go/images_manifest_example_test.go new file mode 100644 index 0000000..ef7d7b3 --- /dev/null +++ b/go/images_manifest_example_test.go @@ -0,0 +1,43 @@ +package config + +import ( + "time" + + core "dappco.re/go" + coreio "dappco.re/go/io" +) + +func exampleImagesManifest() *ImagesManifest { + return &ImagesManifest{Images: map[string]ImageInfo{ + "core-dev": { + Version: "1.0.0", + SHA256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + Downloaded: time.Unix(0, 0).UTC(), + Source: "example", + }, + }} +} + +func ExampleResolveImagesManifest() { + m := coreio.NewMockMedium() + manifest, err := imagesManifestResult(ResolveImagesManifest(m)) + core.Println(err == nil, len(manifest.Images)) + // Output: true 0 +} + +func ExampleLoadImagesManifest() { + m := coreio.NewMockMedium() + path := core.PathJoin("/", "home", ".core", DirectoryImages, FileImagesManifest) + _ = SaveImagesManifest(m, path, exampleImagesManifest()) + manifest, err := imagesManifestResult(LoadImagesManifest(m, path)) + core.Println(err == nil, manifest.Images["core-dev"].Version) + // Output: true 1.0.0 +} + +func ExampleSaveImagesManifest() { + m := coreio.NewMockMedium() + path := core.PathJoin("/", "home", ".core", DirectoryImages, FileImagesManifest) + err := resultError(SaveImagesManifest(m, path, exampleImagesManifest())) + core.Println(err == nil && m.Exists(path)) + // Output: true +} diff --git a/go/images_manifest_test.go b/go/images_manifest_test.go new file mode 100644 index 0000000..b82ccd7 --- /dev/null +++ b/go/images_manifest_test.go @@ -0,0 +1,202 @@ +package config + +import ( + core "dappco.re/go" + "io/fs" + "time" + + coreio "dappco.re/go/io" +) + +const imagesManifestCoreDev = "core-dev" + +type failingImagesWriteMedium struct { + *coreio.MockMedium +} + +func withDefaultImagesManifestMedium(t *core.T, medium coreio.Medium) { + t.Helper() + previous := defaultImagesManifestMedium + defaultImagesManifestMedium = func() coreio.Medium { + return medium + } + t.Cleanup(func() { + defaultImagesManifestMedium = previous + }) +} + +func (m failingImagesWriteMedium) WriteMode(string, string, fs.FileMode) error { + return core.NewError("write failed") +} + +func TestImagesManifest_SaveImagesManifest_LoadSave_Good(t *core.T) { + m := coreio.NewMockMedium() + path := core.PathJoin("home", ".core", DirectoryImages, FileImagesManifest) + + manifest := &ImagesManifest{ + Images: map[string]ImageInfo{ + imagesManifestCoreDev: { + Version: "1.2.3", + SHA256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + Downloaded: time.Date(2026, time.April, 15, 12, 0, 0, 0, time.UTC), + Source: "github", + }, + }, + } + + core.RequireNoError(t, resultError(SaveImagesManifest(m, path, manifest))) + + loaded, err := imagesManifestResult(LoadImagesManifest(m, path)) + core.RequireNoError(t, err) + core.RequireTrue(t, loaded != nil) + core.AssertLen(t, loaded.Images, 1) + core.AssertEqual(t, manifest.Images[imagesManifestCoreDev], loaded.Images[imagesManifestCoreDev]) +} + +func TestImagesManifest_ResolveImagesManifest_Good(t *core.T) { + manifest, err := imagesManifestResult(ResolveImagesManifest(coreio.NewMockMedium())) + core.RequireNoError(t, err) + core.RequireTrue(t, manifest != nil) + core.AssertEmpty(t, manifest.Images) +} + +func TestImagesManifest_LoadImagesManifest_Missing_Good(t *core.T) { + m := coreio.NewMockMedium() + path := core.PathJoin("home", ".core", DirectoryImages, FileImagesManifest) + + manifest, err := imagesManifestResult(LoadImagesManifest(m, path)) + core.RequireNoError(t, err) + core.RequireTrue(t, manifest != nil) + core.AssertEmpty(t, manifest.Images) +} + +func TestImagesManifest_LoadImagesManifest_NilMedium_Good(t *core.T) { + m := coreio.NewMockMedium() + path := core.PathJoin("home", ".core", DirectoryImages, FileImagesManifest) + withDefaultImagesManifestMedium(t, m) + core.RequireNoError(t, m.Write(path, `{"images":{}}`)) + + manifest, err := imagesManifestResult(LoadImagesManifest(nil, path)) + core.RequireNoError(t, err) + core.RequireTrue(t, manifest != nil) + core.AssertEmpty(t, manifest.Images) +} + +func TestImagesManifest_SaveImagesManifest_Nil_Good(t *core.T) { + m := coreio.NewMockMedium() + path := core.PathJoin("home", ".core", DirectoryImages, FileImagesManifest) + + core.RequireNoError(t, resultError(SaveImagesManifest(m, path, nil))) + + content, err := m.Read(path) + core.RequireNoError(t, err) + core.AssertEqual(t, `{"images":{}}`, content) + + info, err := m.Stat(path) + core.RequireNoError(t, err) + core.AssertEqual(t, fs.FileMode(0600), info.Mode()) +} + +func TestImagesManifest_SaveImagesManifest_NilMedium_Good(t *core.T) { + m := coreio.NewMockMedium() + path := core.PathJoin("home", ".core", DirectoryImages, FileImagesManifest) + withDefaultImagesManifestMedium(t, m) + + core.RequireNoError(t, resultError(SaveImagesManifest(nil, path, &ImagesManifest{}))) + + content, err := m.Read(path) + core.RequireNoError(t, err) + core.AssertEqual(t, `{"images":{}}`, content) +} + +func TestImagesManifest_SaveImagesManifest_Bad(t *core.T) { + m := failingImagesWriteMedium{MockMedium: coreio.NewMockMedium()} + path := core.PathJoin("home", ".core", DirectoryImages, FileImagesManifest) + + err := resultError(SaveImagesManifest(m, path, &ImagesManifest{})) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "failed to write images manifest") +} + +func TestImagesManifest_LoadImagesManifest_Bad(t *core.T) { + m := coreio.NewMockMedium() + path := core.PathJoin("home", ".core", DirectoryImages, FileImagesManifest) + core.RequireNoError(t, m.EnsureDir(core.PathDir(path))) + core.RequireNoError(t, m.Write(path, "{not-json")) + + manifest, err := imagesManifestResult(LoadImagesManifest(m, path)) + core.AssertNil(t, manifest) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "failed to parse images manifest") +} + +func TestImagesManifest_LoadImagesManifest_Ugly(t *core.T) { + m := coreio.NewMockMedium() + path := core.PathJoin("home", ".core", DirectoryImages, FileImagesManifest) + core.RequireNoError(t, m.EnsureDir(core.PathDir(path))) + bad := map[string]any{ + "images": map[string]any{ + imagesManifestCoreDev: map[string]any{ + "version": 123, + }, + }, + } + + payload := core.JSONMarshal(bad) + core.RequireTrue(t, payload.OK) + core.RequireNoError(t, m.Write(path, string(payload.Value.([]byte)))) + + manifest, err := imagesManifestResult(LoadImagesManifest(m, path)) + core.AssertNil(t, manifest) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "schema validation failed") +} + +func TestImagesManifest_ResolveImagesManifest_Bad(t *core.T) { + m := coreio.NewMockMedium() + home := core.Env("DIR_HOME") + path := core.PathJoin(home, Directory, DirectoryImages, FileImagesManifest) + core.RequireNoError(t, m.EnsureDir(core.PathDir(path))) + core.RequireNoError(t, m.Write(path, "{not-json")) + + manifest, err := imagesManifestResult(ResolveImagesManifest(m)) + core.AssertNil(t, manifest) + core.AssertError(t, err) +} + +func TestImagesManifest_ResolveImagesManifest_Ugly(t *core.T) { + m := coreio.NewMockMedium() + manifest, err := imagesManifestResult(ResolveImagesManifest(m)) + core.RequireNoError(t, err) + core.AssertNotNil(t, manifest) + core.AssertEmpty(t, manifest.Images) +} + +func TestImagesManifest_LoadImagesManifest_Good(t *core.T) { + m := coreio.NewMockMedium() + path := core.PathJoin("home", ".core", DirectoryImages, FileImagesManifest) + core.RequireNoError(t, m.EnsureDir(core.PathDir(path))) + core.RequireNoError(t, m.Write(path, `{"images":{"core-dev":{"version":"1.0.0","downloaded":"2026-04-15T12:00:00Z","source":"github"}}}`)) + + manifest, err := imagesManifestResult(LoadImagesManifest(m, path)) + core.RequireNoError(t, err) + core.AssertEqual(t, "1.0.0", manifest.Images[imagesManifestCoreDev].Version) +} + +func TestImagesManifest_SaveImagesManifest_Good(t *core.T) { + m := coreio.NewMockMedium() + path := core.PathJoin("home", ".core", DirectoryImages, FileImagesManifest) + manifest := &ImagesManifest{Images: map[string]ImageInfo{imagesManifestCoreDev: {Version: "1.0.0", Downloaded: time.Date(2026, time.April, 15, 12, 0, 0, 0, time.UTC), Source: "github"}}} + + err := resultError(SaveImagesManifest(m, path, manifest)) + core.AssertNoError(t, err) + core.AssertTrue(t, m.Exists(path)) +} + +func TestImagesManifest_SaveImagesManifest_Ugly(t *core.T) { + m := coreio.NewMockMedium() + path := core.PathJoin("home", ".core", DirectoryImages, FileImagesManifest) + err := resultError(SaveImagesManifest(m, path, &ImagesManifest{})) + core.AssertNoError(t, err) + core.AssertTrue(t, m.Exists(path)) +} diff --git a/go/manifest.go b/go/manifest.go new file mode 100644 index 0000000..8beaff1 --- /dev/null +++ b/go/manifest.go @@ -0,0 +1,1410 @@ +package config + +import ( + "crypto/ed25519" + + core "dappco.re/go" + coreio "dappco.re/go/io" + coreerr "dappco.re/go/log" + "gopkg.in/yaml.v3" +) + +// Known .core/ file names. Consumers should reference these constants rather +// than hard-coding paths so future renames are a single-site change. +// +// path := filepath.Join(dir, ".core", config.FileBuild) +const ( + FileConfig = "config.yaml" // go-config — identity, preferences, feature flags + FileBuild = "build.yaml" // go-build — targets, ldflags, cgo + FileRelease = "release.yaml" // go-build — archive, checksums, publish + FileTest = "test.yaml" // core dev — test framework override + FileRun = "run.yaml" // core dev — dev services, server, env + FileView = "view.yaml" // go-webview / dAppServer — HLCRF slots, permissions + FileManifest = "manifest.yaml" // go-scm — package identity + signature + FileWorkspace = "workspace.yaml" // core — project dependencies + FileRepos = "repos.yaml" // go-scm — multi-repo registry + FileIDE = "ide.yaml" // ide — editor integration, LSP, formatters + FilePHP = "php.yaml" // core dev — PHP/Laravel settings + FileAgent = "agent.yaml" // core agent — daemon config (user-level) + FileZone = "zone.yaml" // lethernet — network zone (user-level) + FileImagesManifest = "manifest.json" // core dev — LinuxKit image registry + FileLinuxKit = "core-dev.yml" // core dev — LinuxKit base image config + + // Directory is the conventional directory name that holds the .core/ files. + Directory = ".core" + + // Directory names that live under ~/.core/ for user-level registries. + DirectoryImages = "images" + DirectorySecrets = "secrets" + DirectoryDaemons = "daemons" + DirectoryWorkspaces = "workspaces" + + // WorkspaceDirectory is the sandbox root inside a project-local .core/. + WorkspaceDirectory = "workspace" + + // WorkspaceSourceDirectory is the checked-out repository source inside a + // sandboxed workspace. + WorkspaceSourceDirectory = "src" + + // WorkspaceMetaDirectory stores agent logs, status files, and other + // workspace-local bookkeeping. + WorkspaceMetaDirectory = ".meta" + + // WorkspaceInstructionsFile is the agent instruction file stored at the + // root of a sandboxed workspace. + WorkspaceInstructionsFile = "CODEX.md" + + // LinuxKitDirectory is the conventional directory for LinuxKit templates + // under either a project-local or user-global .core/ tree. + LinuxKitDirectory = "linuxkit" +) + +const ( + callerLoadManifest = "config.LoadManifest" + callerTrustedManifestPublicKeys = "config.trustedManifestPublicKeys" + callerValidateViewManifestSignature = "config.ValidateViewManifestSignature" + callerVerifyViewManifestSignature = "config.VerifyViewManifestSignature" + callerSignViewManifest = "config.SignViewManifest" + callerSignPackageManifest = "config.SignPackageManifest" + callerVerifyPackageManifest = "config.VerifyPackageManifest" + callerParseManifestPublicKey = "config.parseManifestPublicKey" + + errCanonicalMarshalFailed = "canonical marshal failed" + errDecodeTrustedKeyFailed = "decode trusted key failed" + manifestTargetOSKey = "o" + "s" +) + +var manifestHomeDir = func() string { + return core.Env("DIR_HOME") +} + +// KnownFiles enumerates the canonical .core/ file names in discovery order. +// +// for _, name := range config.KnownFiles { /* check existence */ } +var KnownFiles = []string{ + FileConfig, + FileBuild, + FileRelease, + FileTest, + FileRun, + FileView, + FileManifest, + FileWorkspace, + FileRepos, + FileIDE, + FilePHP, + FileAgent, + FileZone, +} + +// ViewManifest defines the structure of .core/view.yaml. +// Used by go-webview and dAppServer to configure window behaviour, permissions, +// and mounted slots. +// +// var view config.ViewManifest +// _ = config.LoadManifest(io.Local, ".core/view.yaml", &view) +type ViewManifest struct { + Version ViewVersion `yaml:"version"` + Code string `yaml:"code"` + Name string `yaml:"name"` + Sign string `yaml:"sign"` + Title string `yaml:"title"` + Width int `yaml:"width"` + Height int `yaml:"height"` + Resizable bool `yaml:"resizable"` + Layout string `yaml:"layout"` + Slots map[string]any `yaml:"slots"` + Modules []string `yaml:"modules"` + Permissions ViewPermissions `yaml:"permissions"` + Config map[string]any `yaml:"config"` +} + +// ViewVersion accepts either the folder-spec integer form (`version: 1`) or +// the RFC example's semantic string form (`version: 0.1.0`). +type ViewVersion string + +// UnmarshalYAML keeps view.yaml backward-compatible across the RFC's mixed +// version examples while preserving the public string-shaped API. +func (v *ViewVersion) UnmarshalYAML(node *yaml.Node) core.Result { + if node.Kind == yaml.AliasNode && node.Alias != nil { + return v.UnmarshalYAML(node.Alias) + } + switch node.Kind { + case yaml.ScalarNode: + var asString string + if err := node.Decode(&asString); err == nil { + *v = ViewVersion(asString) + return core.Ok(nil) + } + var asInt int + if err := node.Decode(&asInt); err == nil { + *v = ViewVersion(core.Sprintf("%d", asInt)) + return core.Ok(nil) + } + } + return core.Fail(coreerr.E("config.ViewVersion.UnmarshalYAML", "invalid view manifest version", nil)) +} + +// ViewPermissions controls what a webview or application surface is allowed to do. +// +// if view.Permissions.Clipboard { /* enable paste */ } +type ViewPermissions struct { + Clipboard bool `yaml:"clipboard"` + Filesystem bool `yaml:"filesystem"` + Network bool `yaml:"network"` + Notifications bool `yaml:"notifications"` + Camera bool `yaml:"camera"` + Microphone bool `yaml:"microphone"` + Read []string `yaml:"read"` + Net []string `yaml:"net"` + Run []string `yaml:"run"` +} + +// BuildManifest defines the structure of .core/build.yaml. +// Used by go-build to configure compilation targets and flags. +// +// var build config.BuildManifest +// _ = config.LoadManifest(io.Local, ".core/build.yaml", &build) +type BuildManifest struct { + Version int `yaml:"version"` + Project BuildProject `yaml:"project"` + Build BuildSettings `yaml:"build"` + Targets []BuildTarget `yaml:"targets"` + Signing BuildSigning `yaml:"sign"` + SDK BuildSDK `yaml:"sdk"` + Name string `yaml:"-"` + Main string `yaml:"-"` + Binary string `yaml:"-"` + Output string `yaml:"-"` + Flags []string `yaml:"-"` + LDFlags string `yaml:"-"` + CGO bool `yaml:"-"` + Env map[string]string `yaml:"env"` +} + +// BuildProject describes the source package being built. +type BuildProject struct { + Name string `yaml:"name"` + Main string `yaml:"main"` + Binary string `yaml:"binary"` + Output string `yaml:"output"` +} + +// BuildSettings captures the compiler and linker settings for a build. +type BuildSettings struct { + Type string `yaml:"type"` + CGO bool `yaml:"cgo"` + Flags []string `yaml:"flags"` + LDFlags []string `yaml:"ldflags"` +} + +// BuildTarget defines a single platform target. +// +// target := config.BuildTarget{OS: "darwin", Arch: "arm64"} +type BuildTarget struct { + OS string + Arch string `yaml:"arch"` +} + +// UnmarshalYAML accepts either the structured `{os, arch}` form or the RFC +// shorthand `linux/amd64` form. +func (t *BuildTarget) UnmarshalYAML(value *yaml.Node) core.Result { + if value.Kind == yaml.AliasNode && value.Alias != nil { + return t.UnmarshalYAML(value.Alias) + } + switch value.Kind { + case yaml.ScalarNode: + var raw string + if err := value.Decode(&raw); err != nil { + return core.Fail(err) + } + if raw == "" { + *t = BuildTarget{} + return core.Ok(nil) + } + parsed := buildTargetFromString(raw) + if !parsed.OK { + return parsed + } + *t = parsed.Value.(BuildTarget) + return core.Ok(nil) + case yaml.MappingNode: + type alias BuildTarget + var raw alias + if err := value.Decode(&raw); err != nil { + return core.Fail(err) + } + *t = BuildTarget(raw) + return core.Ok(nil) + default: + *t = BuildTarget{} + return core.Ok(nil) + } +} + +// BuildSigning controls artifact signing for build outputs. +type BuildSigning struct { + Enabled bool `yaml:"enabled"` + GPG BuildSigningGPG `yaml:"gpg"` + MacOS BuildSigningMacOS `yaml:"macos"` +} + +// BuildSigningGPG configures GPG signing. +type BuildSigningGPG struct { + Key string `yaml:"key"` +} + +// BuildSigningMacOS configures macOS signing and notarization. +type BuildSigningMacOS struct { + Identity string `yaml:"identity"` + Notarize bool `yaml:"notarize"` +} + +// BuildSDK configures SDK generation from an OpenAPI or similar source. +type BuildSDK struct { + Spec string `yaml:"spec"` + Languages []string `yaml:"languages"` + Output string `yaml:"output"` + Diff bool `yaml:"diff"` +} + +// PackageManifest defines the structure of .core/manifest.yaml. +// Used by go-scm and go-build for package identity and signing. +// +// var pkg config.PackageManifest +// _ = config.LoadManifest(io.Local, ".core/manifest.yaml", &pkg) +type PackageManifest struct { + Code string `yaml:"code"` + Name string `yaml:"name"` + Module string `yaml:"module"` + Version string `yaml:"version"` + Description string `yaml:"description"` + Licence string `yaml:"licence"` + Sign string `yaml:"sign"` + SignKey string `yaml:"sign_key"` + Dependencies []string `yaml:"dependencies"` + Tags []string `yaml:"tags"` +} + +// WorkspaceManifest defines the structure of .core/workspace.yaml. +// Used by core workspace setup to declare project dependencies. +// +// var ws config.WorkspaceManifest +// _ = config.LoadManifest(io.Local, ".core/workspace.yaml", &ws) +type WorkspaceManifest struct { + Version int `yaml:"version"` + Dependencies []string `yaml:"dependencies"` + Active string `yaml:"active"` + PackagesDir string `yaml:"packages_dir"` + Settings map[string]any `yaml:"settings"` +} + +// TestManifest defines the structure of .core/test.yaml. +// Used by core dev to override the auto-detected test framework. +// +// var test config.TestManifest +// _ = config.LoadManifest(io.Local, ".core/test.yaml", &test) +type TestManifest struct { + Version int `yaml:"version"` + Commands []TestCommand `yaml:"commands"` + Env map[string]string `yaml:"env"` +} + +// TestCommand is a single named test step (unit, types, lint, ...). +// +// cmd := config.TestCommand{Name: "unit", Run: "vendor/bin/pest"} +type TestCommand struct { + Name string `yaml:"name"` + Run string `yaml:"run"` +} + +// RunManifest defines the structure of .core/run.yaml. +// Used by core dev to start the project in its intended dev environment. +// +// var run config.RunManifest +// _ = config.LoadManifest(io.Local, ".core/run.yaml", &run) +type RunManifest struct { + Version int `yaml:"version"` + Services []RunService `yaml:"services"` + Dev RunDev `yaml:"dev"` + Env map[string]string `yaml:"env"` +} + +// RunService is a backing service (database, cache, mail) started alongside dev. +// +// svc := config.RunService{Name: "db", Image: "postgres:16", Port: 5432} +type RunService struct { + Name string `yaml:"name"` + Image string `yaml:"image"` + Port int `yaml:"port"` + Env map[string]string `yaml:"env"` +} + +// RunDev is the primary dev-loop process (serve, watch, reload). +// +// dev := config.RunDev{Command: "php artisan serve", Port: 8000, Watch: []string{"app/"}} +type RunDev struct { + Command string `yaml:"command"` + Port int `yaml:"port"` + Watch []string `yaml:"watch"` +} + +// ReleaseManifest defines the structure of .core/release.yaml. +// Used by go-build to format archives, attach checksums, and publish to GitHub. +// +// var rel config.ReleaseManifest +// _ = config.LoadManifest(io.Local, ".core/release.yaml", &rel) +type ReleaseManifest struct { + Version int `yaml:"version"` + Archive ReleaseArchive `yaml:"archive"` + Checksums bool `yaml:"checksums"` + GitHub ReleaseGitHub `yaml:"github"` + Changelog ReleaseChangelog `yaml:"changelog"` +} + +// ReleaseArchive describes the output archive format. +// +// arc := config.ReleaseArchive{Format: "tar.gz", Include: []string{"LICENSE.txt"}} +type ReleaseArchive struct { + Format string `yaml:"format"` + Include []string `yaml:"include"` +} + +// ReleaseGitHub controls the GitHub Releases publish step. +// +// gh := config.ReleaseGitHub{Draft: false, Prerelease: false} +type ReleaseGitHub struct { + Draft bool `yaml:"draft"` + Prerelease bool `yaml:"prerelease"` +} + +// ReleaseChangelog controls changelog generation from conventional commits. +// +// log := config.ReleaseChangelog{Include: []string{"feat", "fix"}} +type ReleaseChangelog struct { + Include []string `yaml:"include"` +} + +// ReposManifest defines the structure of .core/repos.yaml. +// Used by go-scm and `core dev health` for multi-repo workspace operations. +// Lives at the workspace root (e.g. `~/Code/.core/repos.yaml`) and enumerates +// every repository that belongs to the federated monorepo. +// +// var repos config.ReposManifest +// _ = config.LoadManifest(io.Local, "~/Code/.core/repos.yaml", &repos) +type ReposManifest struct { + Version int `yaml:"version"` + Org string `yaml:"org"` + Repos []ReposRepo `yaml:"repos"` +} + +// IDEManifest defines the structure of .core/ide.yaml. +// Used by editor and LSP integrations to discover workspace-local IDE hints. +// +// var ide config.IDEManifest +// _ = config.LoadManifest(io.Local, ".core/ide.yaml", &ide) +type IDEManifest struct { + Version int `yaml:"version"` + Editor string `yaml:"editor"` + LSP map[string]any `yaml:"lsp"` + Format map[string]any `yaml:"format"` + Tasks map[string]any `yaml:"tasks"` + Settings map[string]any `yaml:"settings"` +} + +// PHPManifest defines the structure of .core/php.yaml. +// Used by core dev / core php commands to configure the local PHP runtime, +// test runner, lint tooling, and optional deploy integration. +// +// var php config.PHPManifest +// _ = config.LoadManifest(io.Local, ".core/php.yaml", &php) +type PHPManifest struct { + Version int `yaml:"version"` + Server PHPServer `yaml:"server"` + Test PHPTest `yaml:"test"` + Lint PHPLint `yaml:"lint"` + Deploy PHPDeploy `yaml:"deploy"` +} + +// PHPServer configures the dev server used by `core php serve`. +type PHPServer struct { + Type string `yaml:"type"` + Port int `yaml:"port"` + Workers int `yaml:"workers"` +} + +// PHPTest configures the PHP test runner used by `core php test`. +type PHPTest struct { + Framework string `yaml:"framework"` + Parallel bool `yaml:"parallel"` +} + +// PHPLint configures the lint tool used by `core php lint`. +type PHPLint struct { + Tool string `yaml:"tool"` + Config string `yaml:"config"` +} + +// PHPDeploy configures optional PHP deploy settings for higher-level tooling. +type PHPDeploy struct { + Type string `yaml:"type"` + Environment string `yaml:"environment"` + Command string `yaml:"command"` + Inventory string `yaml:"inventory"` + Environments map[string]string `yaml:"environments"` +} + +// AgentManifest defines the structure of ~/.core/agent.yaml. +// Used by the agent daemon to configure watch roots, schedules, MCP/API +// listeners, and pool sizing for each model backend. +// +// var agent config.AgentManifest +// _ = config.LoadManifest(io.Local, "~/.core/agent.yaml", &agent) +type AgentManifest struct { + Daemon DaemonConfig `yaml:"daemon"` + Agents map[string]AgentPool `yaml:"agents"` +} + +// DaemonConfig contains the top-level daemon settings for ~/.core/agent.yaml. +type DaemonConfig struct { + Enabled bool `yaml:"enabled"` + Watch []string `yaml:"watch"` + Schedule []DaemonSchedule `yaml:"schedule"` + MCP DaemonMCP `yaml:"mcp"` + API DaemonAPI `yaml:"api"` +} + +// DaemonSchedule defines a single cron-like daemon task. +type DaemonSchedule struct { + Cron string `yaml:"cron"` + Action string `yaml:"action"` +} + +// DaemonMCP configures the daemon's MCP listener. +type DaemonMCP struct { + Port int `yaml:"port"` +} + +// DaemonAPI configures the daemon's API listener. +type DaemonAPI struct { + Port int `yaml:"port"` + Bind string `yaml:"bind"` +} + +// AgentPool configures the total worker count for a named agent backend. +type AgentPool struct { + Total int `yaml:"total"` +} + +// ZoneManifest defines the structure of ~/.core/zone.yaml. +// Used by lethernet/network tooling to configure identity, chain mode, +// advertised services, and staking. +// +// var zone config.ZoneManifest +// _ = config.LoadManifest(io.Local, "~/.core/zone.yaml", &zone) +type ZoneManifest struct { + Zone ZoneConfig `yaml:"zone"` +} + +// ZoneConfig is the root `zone:` object in ~/.core/zone.yaml. +type ZoneConfig struct { + Name string `yaml:"name"` + Identity string `yaml:"identity"` + Chain ZoneChain `yaml:"chain"` + Network ZoneNetwork `yaml:"network"` + Services ZoneServices `yaml:"services"` + Staking ZoneStaking `yaml:"staking"` +} + +// ZoneChain configures blockchain connectivity for the zone. +type ZoneChain struct { + Mode string `yaml:"mode"` + Daemon string `yaml:"daemon"` +} + +// ZoneNetwork configures network transport settings for the zone. +type ZoneNetwork struct { + WireGuard ZoneWireGuard `yaml:"wireguard"` +} + +// ZoneWireGuard configures the WireGuard listener for the zone. +type ZoneWireGuard struct { + Interface string `yaml:"interface"` + Listen int `yaml:"listen"` +} + +// ZoneServices enumerates the services this zone offers. +type ZoneServices struct { + VPN ZoneServiceVPN `yaml:"vpn"` + DNS ZoneServiceToggle `yaml:"dns"` + Compute ZoneServiceCompute `yaml:"compute"` +} + +// ZoneServiceToggle is a simple enabled/disabled service switch. +type ZoneServiceToggle struct { + Enabled bool `yaml:"enabled"` +} + +// ZoneServiceVPN configures the VPN service advertisement. +type ZoneServiceVPN struct { + Enabled bool `yaml:"enabled"` + Price float64 `yaml:"price"` + Capacity int `yaml:"capacity"` +} + +// ZoneServiceCompute configures the compute service advertisement. +type ZoneServiceCompute struct { + Enabled bool `yaml:"enabled"` + Models []string `yaml:"models"` +} + +// ZoneStaking configures the zone's staking posture. +type ZoneStaking struct { + Amount int `yaml:"amount"` + Tier string `yaml:"tier"` +} + +type buildManifestYAML struct { + Version int `yaml:"version"` + Project buildManifestProject `yaml:"project"` + Build buildManifestBuild `yaml:"build"` + Targets []any `yaml:"targets"` + Signing buildManifestSigning `yaml:"sign"` + SDK buildManifestSDK `yaml:"sdk"` + Name string `yaml:"name"` + Main string `yaml:"main"` + Binary string `yaml:"binary"` + Output string `yaml:"output"` + Flags []string `yaml:"flags"` + LDFlags any `yaml:"ldflags"` + CGO *bool `yaml:"cgo"` + Env map[string]string `yaml:"env"` +} + +type buildManifestProject struct { + Name string `yaml:"name"` + Main string `yaml:"main"` + Binary string `yaml:"binary"` + Output string `yaml:"output"` +} + +type buildManifestBuild struct { + Type string `yaml:"type"` + CGO *bool `yaml:"cgo"` + Flags []string `yaml:"flags"` + LDFlags any `yaml:"ldflags"` +} + +type buildManifestSigning struct { + Enabled bool `yaml:"enabled"` + GPG buildManifestSigningGPG `yaml:"gpg"` + MacOS buildManifestSigningMacOS `yaml:"macos"` +} + +type buildManifestSigningGPG struct { + Key string `yaml:"key"` +} + +type buildManifestSigningMacOS struct { + Identity string `yaml:"identity"` + Notarize bool `yaml:"notarize"` +} + +type buildManifestSDK struct { + Spec string `yaml:"spec"` + Languages []string `yaml:"languages"` + Output string `yaml:"output"` + Diff bool `yaml:"diff"` +} + +type buildmanifestldflags []string + +func (l *buildmanifestldflags) UnmarshalYAML(value *yaml.Node) core.Result { + if value.Kind == yaml.AliasNode && value.Alias != nil { + return l.UnmarshalYAML(value.Alias) + } + switch value.Kind { + case yaml.ScalarNode: + var single string + if err := value.Decode(&single); err != nil { + return core.Fail(err) + } + if single == "" { + *l = nil + return core.Ok(nil) + } + *l = []string{single} + return core.Ok(nil) + case yaml.SequenceNode: + var values []string + if err := value.Decode(&values); err != nil { + return core.Fail(err) + } + *l = append([]string(nil), values...) + return core.Ok(nil) + case yaml.MappingNode: + return core.Fail(coreerr.E("config.buildmanifestldflags.UnmarshalYAML", "unsupported ldflags mapping", nil)) + default: + *l = nil + return core.Ok(nil) + } +} + +func (l buildmanifestldflags) String() string { + return core.Join(" ", l...) +} + +// UnmarshalYAML accepts both the legacy flat build schema and the nested +// RFC shape with project/build sections. +func (m *BuildManifest) UnmarshalYAML(value *yaml.Node) core.Result { + if value.Kind == yaml.AliasNode && value.Alias != nil { + return m.UnmarshalYAML(value.Alias) + } + var raw buildManifestYAML + if err := value.Decode(&raw); err != nil { + return core.Fail(err) + } + + targetsResult := buildTargetsFromYAML(raw.Targets) + buildLDFlagsResult := buildLDFlagsFromYAML(raw.Build.LDFlags) + legacyLDFlagsResult := buildLDFlagsFromYAML(raw.LDFlags) + for _, result := range []core.Result{targetsResult, buildLDFlagsResult, legacyLDFlagsResult} { + if !result.OK { + return result + } + } + + m.Version = raw.Version + m.Project = BuildProject{ + Name: firstNonEmpty(raw.Project.Name, raw.Name), + Main: firstNonEmpty(raw.Project.Main, raw.Main), + Binary: firstNonEmpty(raw.Project.Binary, raw.Binary), + Output: firstNonEmpty(raw.Project.Output, raw.Output), + } + m.Build = BuildSettings{ + Type: raw.Build.Type, + CGO: firstBool(raw.Build.CGO, raw.CGO), + Flags: firstStrings(raw.Build.Flags, raw.Flags), + LDFlags: firstLDFlags(buildLDFlagsResult.Value.(buildmanifestldflags), legacyLDFlagsResult.Value.(buildmanifestldflags)), + } + m.Targets = targetsResult.Value.([]BuildTarget) + m.Signing = BuildSigning{ + Enabled: raw.Signing.Enabled, + GPG: BuildSigningGPG{ + Key: raw.Signing.GPG.Key, + }, + MacOS: BuildSigningMacOS{ + Identity: raw.Signing.MacOS.Identity, + Notarize: raw.Signing.MacOS.Notarize, + }, + } + m.SDK = BuildSDK{ + Spec: raw.SDK.Spec, + Languages: append([]string(nil), raw.SDK.Languages...), + Output: raw.SDK.Output, + Diff: raw.SDK.Diff, + } + m.Name = m.Project.Name + m.Main = m.Project.Main + m.Binary = m.Project.Binary + m.Output = m.Project.Output + m.Flags = append([]string(nil), m.Build.Flags...) + m.LDFlags = core.Join(" ", m.Build.LDFlags...) + m.CGO = m.Build.CGO + m.Env = raw.Env + return core.Ok(nil) +} + +func buildTargetFromString(raw string) core.Result { + parts := core.SplitN(raw, "/", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return core.Fail(coreerr.E("config.BuildTarget.UnmarshalYAML", "invalid target shorthand: "+raw, nil)) + } + return core.Ok(BuildTarget{OS: parts[0], Arch: parts[1]}) +} + +func buildTargetsFromYAML(values []any) core.Result { + targets := make([]BuildTarget, 0, len(values)) + for _, value := range values { + targetResult := buildTargetFromYAML(value) + if !targetResult.OK { + return targetResult + } + targets = append(targets, targetResult.Value.(BuildTarget)) + } + return core.Ok(targets) +} + +func buildTargetFromYAML(value any) core.Result { + switch typed := value.(type) { + case string: + if typed == "" { + return core.Ok(BuildTarget{}) + } + return buildTargetFromString(typed) + case map[string]any: + return core.Ok(BuildTarget{ + OS: stringFromYAMLMap(typed, manifestTargetOSKey), + Arch: stringFromYAMLMap(typed, "arch"), + }) + case map[any]any: + return core.Ok(BuildTarget{ + OS: stringFromAnyYAMLMap(typed, manifestTargetOSKey), + Arch: stringFromAnyYAMLMap(typed, "arch"), + }) + case nil: + return core.Ok(BuildTarget{}) + default: + return core.Fail(coreerr.E("config.BuildTarget.UnmarshalYAML", "invalid target entry", nil)) + } +} + +func stringFromYAMLMap(values map[string]any, key string) string { + if value, ok := values[key].(string); ok { + return value + } + return "" +} + +func stringFromAnyYAMLMap(values map[any]any, key string) string { + if value, ok := values[key]; ok { + if s, ok := value.(string); ok { + return s + } + } + return "" +} + +func buildLDFlagsFromYAML(value any) core.Result { + switch typed := value.(type) { + case nil: + return core.Ok(buildmanifestldflags(nil)) + case string: + if typed == "" { + return core.Ok(buildmanifestldflags(nil)) + } + return core.Ok(buildmanifestldflags{typed}) + case []string: + return core.Ok(buildmanifestldflags(append([]string(nil), typed...))) + case []any: + out := make(buildmanifestldflags, 0, len(typed)) + for _, value := range typed { + s, ok := value.(string) + if !ok { + return core.Fail(coreerr.E("config.buildmanifestldflags.UnmarshalYAML", "invalid ldflags sequence", nil)) + } + out = append(out, s) + } + return core.Ok(out) + case map[string]any, map[any]any: + return core.Fail(coreerr.E("config.buildmanifestldflags.UnmarshalYAML", "unsupported ldflags mapping", nil)) + default: + return core.Fail(coreerr.E("config.buildmanifestldflags.UnmarshalYAML", "invalid ldflags value", nil)) + } +} + +// ReposRepo is a single repository entry in repos.yaml. +// +// repo := config.ReposRepo{Path: "core/go", Remote: "ssh://…/go.git", Branch: "dev"} +type ReposRepo struct { + Path string + Remote string `yaml:"remote"` + Branch string `yaml:"branch"` + Type string `yaml:"type"` + Description string `yaml:"description"` + Depends []string `yaml:"depends"` +} + +// LoadManifest reads a YAML manifest file from the given medium and decodes +// it into the destination value. Accepts any of the ViewManifest / BuildManifest / +// PackageManifest / WorkspaceManifest / ReposManifest types (or any YAML-tagged +// struct). +// +// var build config.BuildManifest +// err := config.LoadManifest(io.Local, ".core/build.yaml", &build) +func LoadManifest(m coreio.Medium, path string, out any) core.Result { + content, err := m.Read(path) + if err != nil { + return core.Fail(coreerr.E(callerLoadManifest, "failed to read manifest: "+path, err)) + } + var raw map[string]any + if err := yaml.Unmarshal([]byte(content), &raw); err != nil { + return core.Fail(coreerr.E(callerLoadManifest, "failed to parse manifest: "+path, err)) + } + if r := validateSchema(path, raw); !r.OK { + return r + } + if r := decodeManifestYAML(content, out); !r.OK { + return core.Fail(coreerr.E(callerLoadManifest, "failed to decode manifest: "+path, resultCause(r).(error))) + } + if r := validateManifest(path, out, raw); !r.OK { + return r + } + return core.Ok(nil) +} + +func decodeManifestYAML(content string, out any) core.Result { + var node yaml.Node + if err := yaml.Unmarshal([]byte(content), &node); err != nil { + return core.Fail(err) + } + root := manifestYAMLRoot(&node) + switch target := out.(type) { + case *BuildManifest: + return target.UnmarshalYAML(root) + case *ViewManifest: + return decodeViewManifestYAML(root, target) + default: + if err := yaml.Unmarshal([]byte(content), out); err != nil { + return core.Fail(err) + } + return core.Ok(nil) + } +} + +func manifestYAMLRoot(node *yaml.Node) *yaml.Node { + if node == nil { + return &yaml.Node{} + } + if node.Kind == yaml.DocumentNode && len(node.Content) > 0 { + return manifestYAMLRoot(node.Content[0]) + } + if node.Kind == yaml.AliasNode && node.Alias != nil { + return manifestYAMLRoot(node.Alias) + } + return node +} + +type viewManifestYAML struct { + Version any `yaml:"version"` + Code string `yaml:"code"` + Name string `yaml:"name"` + Sign string `yaml:"sign"` + Title string `yaml:"title"` + Width int `yaml:"width"` + Height int `yaml:"height"` + Resizable bool `yaml:"resizable"` + Layout string `yaml:"layout"` + Slots map[string]any `yaml:"slots"` + Modules []string `yaml:"modules"` + Permissions ViewPermissions `yaml:"permissions"` + Config map[string]any `yaml:"config"` +} + +func decodeViewManifestYAML(value *yaml.Node, view *ViewManifest) core.Result { + var raw viewManifestYAML + if err := value.Decode(&raw); err != nil { + return core.Fail(err) + } + versionResult := viewVersionFromYAML(raw.Version) + if !versionResult.OK { + return versionResult + } + view.Version = versionResult.Value.(ViewVersion) + view.Code = raw.Code + view.Name = raw.Name + view.Sign = raw.Sign + view.Title = raw.Title + view.Width = raw.Width + view.Height = raw.Height + view.Resizable = raw.Resizable + view.Layout = raw.Layout + view.Slots = raw.Slots + view.Modules = append([]string(nil), raw.Modules...) + view.Permissions = raw.Permissions + view.Config = raw.Config + return core.Ok(nil) +} + +func viewVersionFromYAML(value any) core.Result { + switch typed := value.(type) { + case nil: + return core.Ok(ViewVersion("")) + case string: + return core.Ok(ViewVersion(typed)) + case int: + return core.Ok(ViewVersion(core.Sprintf("%d", typed))) + case int64: + return core.Ok(ViewVersion(core.Sprintf("%d", typed))) + case float64: + return core.Ok(ViewVersion(core.Sprintf("%v", typed))) + default: + return core.Fail(coreerr.E("config.ViewVersion.UnmarshalYAML", "invalid view manifest version", nil)) + } +} + +func validateManifest(path string, out any, raw map[string]any) core.Result { + switch core.PathBase(path) { + case FileView: + view, ok := out.(*ViewManifest) + if !ok { + return core.Ok(nil) + } + if r := validateLoadedViewManifest(path, view, raw); !r.OK { + return r + } + case FileManifest: + pkg, ok := out.(*PackageManifest) + if !ok { + return core.Ok(nil) + } + if r := verifyLoadedPackageManifest(path, pkg, raw); !r.OK { + return r + } + } + return core.Ok(nil) +} + +func validateLoadedViewManifest(path string, view *ViewManifest, raw map[string]any) core.Result { + if missingOrEmptyStringField(raw, "sign", view.Sign) { + return core.Fail(coreerr.E(callerLoadManifest, "unsigned view manifest rejected: "+path, nil)) + } + if r := ValidateViewManifestSignature(view); !r.OK { + msg := r.Error() + switch { + case core.Contains(msg, "not ed25519-sized"): + return core.Fail(coreerr.E(callerLoadManifest, "view manifest signature is not ed25519-sized: "+path, nil)) + case core.Contains(msg, "unsigned"): + return core.Fail(coreerr.E(callerLoadManifest, "unsigned view manifest rejected: "+path, nil)) + default: + return core.Fail(coreerr.E(callerLoadManifest, "invalid view manifest signature: "+path, resultCause(r).(error))) + } + } + return core.Ok(nil) +} + +func verifyLoadedPackageManifest(path string, pkg *PackageManifest, raw map[string]any) core.Result { + if missingOrEmptyStringField(raw, "sign", pkg.Sign) { + return core.Fail(coreerr.E(callerLoadManifest, "unsigned package manifest rejected: "+path, nil)) + } + if missingOrEmptyStringField(raw, "sign_key", pkg.SignKey) { + return core.Fail(coreerr.E(callerLoadManifest, "missing package sign_key: "+path, nil)) + } + if r := VerifyPackageManifest(pkg); !r.OK { + msg := r.Error() + switch { + case core.Contains(msg, "missing package sign_key"): + return core.Fail(coreerr.E(callerLoadManifest, "missing package sign_key: "+path, nil)) + case core.Contains(msg, "not an ed25519 public key"): + return core.Fail(coreerr.E(callerLoadManifest, "package sign_key is not an ed25519 public key: "+path, nil)) + case core.Contains(msg, "not trusted"): + return core.Fail(coreerr.E(callerLoadManifest, "package sign_key is not trusted: "+path, nil)) + case core.Contains(msg, "not ed25519-sized"): + return core.Fail(coreerr.E(callerLoadManifest, "package manifest signature is not ed25519-sized: "+path, nil)) + case core.Contains(msg, "signature mismatch"): + return core.Fail(coreerr.E(callerLoadManifest, "package manifest signature mismatch: "+path, nil)) + case core.Contains(msg, errCanonicalMarshalFailed): + return core.Fail(coreerr.E(callerLoadManifest, errCanonicalMarshalFailed+": "+path, resultCause(r).(error))) + case core.Contains(msg, "decode package sign_key failed"): + return core.Fail(coreerr.E(callerLoadManifest, "decode package sign_key failed: "+path, resultCause(r).(error))) + default: + return core.Fail(coreerr.E(callerLoadManifest, "invalid package manifest signature: "+path, resultCause(r).(error))) + } + } + return core.Ok(nil) +} + +func manifestKeyTrusted(candidate ed25519.PublicKey, trusted []ed25519.PublicKey) bool { + for _, key := range trusted { + if len(key) != ed25519.PublicKeySize { + continue + } + if core.Lower(core.HexEncode(candidate)) == core.Lower(core.HexEncode(key)) { + return true + } + } + return false +} + +func trustedManifestTrustedEnvKeys() core.Result { + fromEnv := core.Trim(core.Env("CORE_MANIFEST_TRUST_KEYS")) + if fromEnv == "" { + return core.Ok([]ed25519.PublicKey(nil)) + } + return parseTrustedManifestKeyList(fromEnv) +} + +func decodeManifestSignature(value string) core.Result { + return core.Base64Decode(core.Trim(value)) +} + +// CanonicalViewManifestBytes returns the RFC canonical view manifest body with +// the sign field cleared so callers can sign or verify it consistently. +// +// body, _ := config.CanonicalViewManifestBytes(&view) +func CanonicalViewManifestBytes(view *ViewManifest) core.Result { + return viewManifestBytes(view) +} + +// ValidateViewManifestSignature checks only that view.yaml carries a base64 +// ed25519-sized signature. Trust-root verification belongs to the caller. +// +// if err := config.ValidateViewManifestSignature(&view); err != nil { ... } +func ValidateViewManifestSignature(view *ViewManifest) core.Result { + if view == nil || core.Trim(view.Sign) == "" { + return core.Fail(coreerr.E(callerValidateViewManifestSignature, "unsigned view manifest rejected", nil)) + } + sigResult := decodeManifestSignature(view.Sign) + if !sigResult.OK { + return core.Fail(coreerr.E(callerValidateViewManifestSignature, "invalid view manifest signature", resultCause(sigResult).(error))) + } + sig := sigResult.Value.([]byte) + if len(sig) != ed25519.SignatureSize { + return core.Fail(coreerr.E(callerValidateViewManifestSignature, "view manifest signature is not ed25519-sized", nil)) + } + return core.Ok(nil) +} + +// VerifyViewManifestSignature verifies a signed view manifest against the +// caller-supplied ed25519 public key. +// +// if err := config.VerifyViewManifestSignature(&view, pub); err != nil { ... } +func VerifyViewManifestSignature(view *ViewManifest, publicKey ed25519.PublicKey) core.Result { + if r := ValidateViewManifestSignature(view); !r.OK { + return r + } + if len(publicKey) != ed25519.PublicKeySize { + return core.Fail(coreerr.E(callerVerifyViewManifestSignature, "view manifest public key is not an ed25519 public key", nil)) + } + bodyResult := CanonicalViewManifestBytes(view) + if !bodyResult.OK { + return core.Fail(coreerr.E(callerVerifyViewManifestSignature, errCanonicalMarshalFailed, resultCause(bodyResult).(error))) + } + body := bodyResult.Value.([]byte) + sigResult := decodeManifestSignature(view.Sign) + if !sigResult.OK { + return core.Fail(coreerr.E(callerVerifyViewManifestSignature, "invalid view manifest signature", resultCause(sigResult).(error))) + } + sig := sigResult.Value.([]byte) + if !ed25519.Verify(publicKey, body, sig) { + return core.Fail(coreerr.E(callerVerifyViewManifestSignature, "view manifest signature mismatch", nil)) + } + return core.Ok(nil) +} + +// SignViewManifest signs view.yaml in place using the RFC canonical body and +// stores the resulting base64 signature in Sign. +// +// _ = config.SignViewManifest(&view, priv) +func SignViewManifest(view *ViewManifest, privateKey ed25519.PrivateKey) core.Result { + if view == nil { + return core.Fail(coreerr.E(callerSignViewManifest, "nil view manifest", nil)) + } + if len(privateKey) != ed25519.PrivateKeySize { + return core.Fail(coreerr.E(callerSignViewManifest, "view manifest private key is not an ed25519 private key", nil)) + } + bodyResult := CanonicalViewManifestBytes(view) + if !bodyResult.OK { + return core.Fail(coreerr.E(callerSignViewManifest, errCanonicalMarshalFailed, resultCause(bodyResult).(error))) + } + body := bodyResult.Value.([]byte) + view.Sign = core.Base64Encode(ed25519.Sign(privateKey, body)) + return core.Ok(nil) +} + +func viewManifestBytes(view *ViewManifest) core.Result { + if view == nil { + return core.ResultOf(yaml.Marshal(nil)) + } + tmp := *view + tmp.Sign = "" + return core.ResultOf(yaml.Marshal(&tmp)) +} + +// CanonicalPackageManifestBytes returns the RFC canonical package manifest body +// with the sign field cleared so callers can sign or verify it consistently. +// +// body, _ := config.CanonicalPackageManifestBytes(&pkg) +func CanonicalPackageManifestBytes(pkg *PackageManifest) core.Result { + return packageManifestBytes(pkg) +} + +// SignPackageManifest signs manifest.yaml in place and ensures SignKey matches +// the supplied ed25519 private key's public half. +// +// _ = config.SignPackageManifest(&pkg, priv) +func SignPackageManifest(pkg *PackageManifest, privateKey ed25519.PrivateKey) core.Result { + if pkg == nil { + return core.Fail(coreerr.E(callerSignPackageManifest, "nil package manifest", nil)) + } + if len(privateKey) != ed25519.PrivateKeySize { + return core.Fail(coreerr.E(callerSignPackageManifest, "package manifest private key is not an ed25519 private key", nil)) + } + publicKey, ok := privateKey.Public().(ed25519.PublicKey) + if !ok || len(publicKey) != ed25519.PublicKeySize { + return core.Fail(coreerr.E(callerSignPackageManifest, "derive package manifest public key failed", nil)) + } + pkg.SignKey = core.HexEncode(publicKey) + bodyResult := CanonicalPackageManifestBytes(pkg) + if !bodyResult.OK { + return core.Fail(coreerr.E(callerSignPackageManifest, errCanonicalMarshalFailed, resultCause(bodyResult).(error))) + } + body := bodyResult.Value.([]byte) + pkg.Sign = core.Base64Encode(ed25519.Sign(privateKey, body)) + return core.Ok(nil) +} + +func packageManifestBytes(pkg *PackageManifest) core.Result { + if pkg == nil { + return core.ResultOf(yaml.Marshal(nil)) + } + tmp := *pkg + tmp.Sign = "" + return core.ResultOf(yaml.Marshal(&tmp)) +} + +// VerifyPackageManifest verifies manifest.yaml against its embedded sign_key +// and the optional trust roots from CORE_MANIFEST_TRUST_KEYS / ~/.core/keys. +// +// if err := config.VerifyPackageManifest(&pkg); err != nil { ... } +func VerifyPackageManifest(pkg *PackageManifest) core.Result { + if pkg == nil || core.Trim(pkg.Sign) == "" { + return core.Fail(coreerr.E(callerVerifyPackageManifest, "unsigned package manifest rejected", nil)) + } + if core.Trim(pkg.SignKey) == "" { + return core.Fail(coreerr.E(callerVerifyPackageManifest, "missing package sign_key", nil)) + } + + pubResult := core.HexDecode(core.Trim(pkg.SignKey)) + if !pubResult.OK { + return core.Fail(coreerr.E(callerVerifyPackageManifest, "decode package sign_key failed", resultCause(pubResult).(error))) + } + pub := pubResult.Value.([]byte) + if len(pub) != ed25519.PublicKeySize { + return core.Fail(coreerr.E(callerVerifyPackageManifest, "package sign_key is not an ed25519 public key", nil)) + } + + trustedKeysResult := trustedManifestVerificationKeys() + if !trustedKeysResult.OK { + return core.Fail(coreerr.E(callerVerifyPackageManifest, "load trusted manifest public keys failed", resultCause(trustedKeysResult).(error))) + } + trustedKeys := trustedKeysResult.Value.([]ed25519.PublicKey) + if len(trustedKeys) > 0 && !manifestKeyTrusted(ed25519.PublicKey(pub), trustedKeys) { + return core.Fail(coreerr.E(callerVerifyPackageManifest, "package sign_key is not trusted", nil)) + } + + sigResult := decodeManifestSignature(pkg.Sign) + if !sigResult.OK { + return core.Fail(coreerr.E(callerVerifyPackageManifest, "invalid package manifest signature", resultCause(sigResult).(error))) + } + sig := sigResult.Value.([]byte) + if len(sig) != ed25519.SignatureSize { + return core.Fail(coreerr.E(callerVerifyPackageManifest, "package manifest signature is not ed25519-sized", nil)) + } + + bodyResult := CanonicalPackageManifestBytes(pkg) + if !bodyResult.OK { + return core.Fail(coreerr.E(callerVerifyPackageManifest, errCanonicalMarshalFailed, resultCause(bodyResult).(error))) + } + body := bodyResult.Value.([]byte) + if !ed25519.Verify(ed25519.PublicKey(pub), body, sig) { + return core.Fail(coreerr.E(callerVerifyPackageManifest, "package manifest signature mismatch", nil)) + } + return core.Ok(nil) +} + +// TrustedManifestPublicKeys returns the deduplicated trust roots discovered +// from CORE_MANIFEST_TRUST_KEYS or ~/.core/keys/*.pub. +// +// keys, _ := config.TrustedManifestPublicKeys() +func TrustedManifestPublicKeys() core.Result { + return trustedManifestPublicKeys() +} + +func trustedManifestPublicKeys() core.Result { + if fromEnv := core.Trim(core.Env("CORE_MANIFEST_TRUST_KEYS")); fromEnv != "" { + return parseTrustedManifestKeyList(fromEnv) + } + + return trustedManifestPublicKeysFromDisk(manifestHomeDir()) +} + +func parseTrustedManifestKeyList(raw string) core.Result { + var keys []ed25519.PublicKey + for _, item := range splitManifestTrustedKeys(raw) { + pubResult := parseManifestPublicKey(item) + if !pubResult.OK { + return core.Fail(coreerr.E(callerTrustedManifestPublicKeys, errDecodeTrustedKeyFailed, resultCause(pubResult).(error))) + } + keys = append(keys, pubResult.Value.(ed25519.PublicKey)) + } + return core.Ok(dedupeManifestKeys(keys)) +} + +func trustedManifestPublicKeysFromDisk(home string) core.Result { + if home == "" { + return core.Ok([]ed25519.PublicKey(nil)) + } + + coreDir := core.PathJoin(home, ".core") + keyDir := core.PathJoin(coreDir, "keys") + if isSymlinkedCoreDir(coreio.Local, coreDir) { + return core.Fail(coreerr.E(callerTrustedManifestPublicKeys, "symlinked .core directory rejected: "+coreDir, nil)) + } + if isSymlinkedLocalPath(keyDir) { + return core.Fail(coreerr.E(callerTrustedManifestPublicKeys, "symlinked trusted keys directory rejected: "+keyDir, nil)) + } + + entriesResult := core.ReadDir(core.DirFS(keyDir), ".") + if !entriesResult.OK && core.IsNotExist(resultCause(entriesResult).(error)) { + return core.Ok([]ed25519.PublicKey(nil)) + } + if !entriesResult.OK { + return core.Fail(coreerr.E(callerTrustedManifestPublicKeys, "read trusted keys directory failed", resultCause(entriesResult).(error))) + } + return trustedManifestKeysFromEntries(keyDir, entriesResult.Value.([]core.FsDirEntry)) +} + +func trustedManifestKeysFromEntries(keyDir string, entries []core.FsDirEntry) core.Result { + keys := make([]ed25519.PublicKey, 0, len(entries)) + for _, entry := range entries { + entryResult := trustedManifestPublicKeyFromEntry(keyDir, entry) + if !entryResult.OK { + return entryResult + } + trustedEntry := entryResult.Value.(trustedManifestPublicKeyEntry) + if trustedEntry.Found { + keys = append(keys, trustedEntry.Key) + } + } + return core.Ok(dedupeManifestKeys(keys)) +} + +type trustedManifestPublicKeyEntry struct { + Key ed25519.PublicKey + Found bool +} + +func trustedManifestPublicKeyFromEntry(keyDir string, entry core.FsDirEntry) core.Result { + if entry.IsDir() || !core.HasSuffix(entry.Name(), ".pub") { + return core.Ok(trustedManifestPublicKeyEntry{}) + } + + entryPath := core.PathJoin(keyDir, entry.Name()) + if isSymlinkedLocalPath(entryPath) { + return core.Fail(coreerr.E(callerTrustedManifestPublicKeys, "symlinked trusted key rejected: "+entry.Name(), nil)) + } + bodyResult := core.ReadFSFile(core.DirFS(keyDir), entry.Name()) + if !bodyResult.OK { + return core.Fail(coreerr.E(callerTrustedManifestPublicKeys, "read trusted key file failed: "+entry.Name(), resultCause(bodyResult).(error))) + } + pubResult := parseManifestPublicKey(core.Trim(string(bodyResult.Value.([]byte)))) + if !pubResult.OK { + return core.Fail(coreerr.E(callerTrustedManifestPublicKeys, errDecodeTrustedKeyFailed+": "+entry.Name(), resultCause(pubResult).(error))) + } + return core.Ok(trustedManifestPublicKeyEntry{Key: pubResult.Value.(ed25519.PublicKey), Found: true}) +} + +func trustedManifestVerificationKeys() core.Result { + if _, ok := core.LookupEnv("CORE_MANIFEST_TRUST_KEYS"); ok { + return trustedManifestTrustedEnvKeys() + } + + return trustedManifestPublicKeys() +} + +func splitManifestTrustedKeys(raw string) []string { + var out []string + start := -1 + for i, r := range raw { + if isManifestTrustKeySeparator(r) { + if start >= 0 { + out = append(out, raw[start:i]) + start = -1 + } + continue + } + if start < 0 { + start = i + } + } + if start >= 0 { + out = append(out, raw[start:]) + } + return out +} + +func isManifestTrustKeySeparator(r rune) bool { + return r == ',' || r == ';' || r == '\n' || r == '\t' || r == ' ' || r == '\r' +} + +func parseManifestPublicKey(raw string) core.Result { + trimmed := core.Trim(raw) + if trimmed == "" { + return core.Fail(coreerr.E(callerParseManifestPublicKey, "empty manifest public key", nil)) + } + pubResult := core.HexDecode(trimmed) + if !pubResult.OK { + return core.Fail(coreerr.E(callerParseManifestPublicKey, "decode manifest public key failed", resultCause(pubResult).(error))) + } + pub := pubResult.Value.([]byte) + if len(pub) != ed25519.PublicKeySize { + return core.Fail(coreerr.E(callerParseManifestPublicKey, "manifest public key has invalid size", nil)) + } + return core.Ok(ed25519.PublicKey(pub)) +} + +func dedupeManifestKeys(keys []ed25519.PublicKey) []ed25519.PublicKey { + seen := make(map[string]struct{}, len(keys)) + out := make([]ed25519.PublicKey, 0, len(keys)) + for _, key := range keys { + if len(key) != ed25519.PublicKeySize { + continue + } + serialized := string(key) + if _, ok := seen[serialized]; ok { + continue + } + out = append(out, key) + seen[serialized] = struct{}{} + } + return out +} + +func missingOrEmptyStringField(raw map[string]any, key string, current string) bool { + if core.Trim(current) == "" { + return true + } + rawValue, ok := raw[key] + if !ok { + return true + } + s, ok := rawValue.(string) + return !ok || core.Trim(s) == "" +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} + +func firstBool(values ...*bool) bool { + for _, value := range values { + if value != nil { + return *value + } + } + return false +} + +func firstStrings(values ...[]string) []string { + for _, value := range values { + if len(value) > 0 { + return append([]string(nil), value...) + } + } + return nil +} + +func firstLDFlags(values ...buildmanifestldflags) buildmanifestldflags { + for _, value := range values { + if len(value) > 0 { + return append(buildmanifestldflags(nil), value...) + } + } + return nil +} diff --git a/go/manifest_example_test.go b/go/manifest_example_test.go new file mode 100644 index 0000000..25da635 --- /dev/null +++ b/go/manifest_example_test.go @@ -0,0 +1,159 @@ +package config + +import ( + "crypto/ed25519" + "encoding/hex" + "syscall" + + core "dappco.re/go" + coreio "dappco.re/go/io" + "gopkg.in/yaml.v3" +) + +func exampleSetManifestTrustKey(key string) func() { + previous, hadPrevious := syscall.Getenv("CORE_MANIFEST_TRUST_KEYS") + _ = syscall.Setenv("CORE_MANIFEST_TRUST_KEYS", key) + return func() { + if hadPrevious { + _ = syscall.Setenv("CORE_MANIFEST_TRUST_KEYS", previous) + return + } + _ = syscall.Unsetenv("CORE_MANIFEST_TRUST_KEYS") + } +} + +func exampleViewManifest() ViewManifest { + return ViewManifest{ + Version: ViewVersion("1"), + Code: "app", + Name: "Example App", + Layout: "HLCRF", + Slots: map[string]any{"C": "main"}, + } +} + +func exampleSignedViewManifest() (ViewManifest, ed25519.PublicKey) { + pub, priv, _ := ed25519.GenerateKey(nil) + view := exampleViewManifest() + _ = SignViewManifest(&view, priv) + return view, pub +} + +func examplePackageManifest() PackageManifest { + return PackageManifest{ + Code: "go-config", + Name: "Config", + Module: "dappco.re/go/config", + Version: "1.0.0", + Description: "Layered configuration", + Licence: "EUPL-1.2", + } +} + +func exampleSignedPackageManifest() (PackageManifest, func()) { + _, priv, _ := ed25519.GenerateKey(nil) + pkg := examplePackageManifest() + _ = SignPackageManifest(&pkg, priv) + return pkg, exampleSetManifestTrustKey(pkg.SignKey) +} + +func ExampleViewVersion_UnmarshalYAML() { + var version ViewVersion + err := resultError(version.UnmarshalYAML(&yaml.Node{Kind: yaml.ScalarNode, Value: "2"})) + core.Println(err == nil, version) + // Output: true 2 +} + +func ExampleBuildTarget_UnmarshalYAML() { + var target BuildTarget + err := resultError(target.UnmarshalYAML(&yaml.Node{Kind: yaml.ScalarNode, Value: "linux/amd64"})) + core.Println(err == nil, target.OS, target.Arch) + // Output: true linux amd64 +} + +func ExampleBuildManifest_UnmarshalYAML() { + var build BuildManifest + body := "version: 1\nproject:\n name: app\n main: ./cmd/app\nbuild:\n flags: [-trimpath]\ntargets:\n - linux/amd64\n" + var node yaml.Node + err := yaml.Unmarshal([]byte(body), &node) + if err == nil { + err = resultError(build.UnmarshalYAML(manifestYAMLRoot(&node))) + } + arch := "" + if len(build.Targets) > 0 { + arch = build.Targets[0].Arch + } + core.Println(err == nil, build.Project.Name, arch) + // Output: true app amd64 +} + +func ExampleLoadManifest() { + m := coreio.NewMockMedium() + path := "/example/.core/build.yaml" + _ = m.Write(path, "version: 1\nproject:\n name: app\n") + var build BuildManifest + err := resultError(LoadManifest(m, path, &build)) + core.Println(err == nil, build.Project.Name) + // Output: true app +} + +func ExampleCanonicalViewManifestBytes() { + view := exampleViewManifest() + body, err := bytesResult(CanonicalViewManifestBytes(&view)) + core.Println(err == nil, core.Contains(string(body), "code: app")) + // Output: true true +} + +func ExampleValidateViewManifestSignature() { + view, _ := exampleSignedViewManifest() + err := resultError(ValidateViewManifestSignature(&view)) + core.Println(err == nil) + // Output: true +} + +func ExampleVerifyViewManifestSignature() { + view, pub := exampleSignedViewManifest() + err := resultError(VerifyViewManifestSignature(&view, pub)) + core.Println(err == nil) + // Output: true +} + +func ExampleSignViewManifest() { + _, priv, _ := ed25519.GenerateKey(nil) + view := exampleViewManifest() + err := resultError(SignViewManifest(&view, priv)) + core.Println(err == nil, view.Sign != "") + // Output: true true +} + +func ExampleCanonicalPackageManifestBytes() { + pkg := examplePackageManifest() + body, err := bytesResult(CanonicalPackageManifestBytes(&pkg)) + core.Println(err == nil, core.Contains(string(body), "code: go-config")) + // Output: true true +} + +func ExampleSignPackageManifest() { + _, priv, _ := ed25519.GenerateKey(nil) + pkg := examplePackageManifest() + err := resultError(SignPackageManifest(&pkg, priv)) + core.Println(err == nil, pkg.Sign != "", pkg.SignKey != "") + // Output: true true true +} + +func ExampleVerifyPackageManifest() { + pkg, cleanup := exampleSignedPackageManifest() + defer cleanup() + err := resultError(VerifyPackageManifest(&pkg)) + core.Println(err == nil) + // Output: true +} + +func ExampleTrustedManifestPublicKeys() { + pub, _, _ := ed25519.GenerateKey(nil) + cleanup := exampleSetManifestTrustKey(hex.EncodeToString(pub)) + defer cleanup() + keys, err := trustedKeysResult(TrustedManifestPublicKeys()) + core.Println(err == nil, len(keys)) + // Output: true 1 +} diff --git a/go/manifest_test.go b/go/manifest_test.go new file mode 100644 index 0000000..1a0b614 --- /dev/null +++ b/go/manifest_test.go @@ -0,0 +1,1187 @@ +package config + +import ( + "crypto/ed25519" + core "dappco.re/go" + "encoding/base64" + "encoding/hex" + "runtime" + + coreio "dappco.re/go/io" + "gopkg.in/yaml.v3" +) + +const ( + manifestTestNotHex = "not-hex" + manifestTestTrustedPubFile = "trusted.pub" + manifestTestPhotoBrowserCode = "photo-browser" + manifestTestPhotoBrowserName = "Photo Browser" + manifestTestCoreIOName = "Core I/O" + manifestTestMandatoryIODescription = "Mandatory I/O abstraction layer" + manifestTestEUPL = "EUPL-1.2" + manifestTestDecodePackageSignKeyFailed = "decode package sign_key failed" + manifestTestSignPrefix = "\nsign: " + manifestTestBuildPath = "/.core/" + FileBuild + manifestTestViewPath = "/.core/" + FileView + manifestTestManifestPath = "/.core/" + FileManifest + manifestTestPackageContentPrefix = "code: go-io\nname: Core I/O\nversion: 0.3.0\ndescription: Mandatory I/O abstraction layer\nlicence: EUPL-1.2\nsign_key: " + manifestTestKeepMe = "keep-me" +) + +func setManifestTrustKeys(t *core.T, keys ...string) { + t.Helper() + t.Setenv("CORE_MANIFEST_TRUST_KEYS", core.Join(",", keys...)) +} + +func TestManifest_splitManifestTrustedKeys_Good(t *core.T) { + got := splitManifestTrustedKeys("a,b;c d\te\nf") + want := []string{"a", "b", "c", "d", "e", "f"} + core.AssertEqual(t, want, got) +} + +func TestManifest_splitManifestTrustedKeys_Bad(t *core.T) { + got := splitManifestTrustedKeys("") + core.AssertEmpty(t, got) + core.AssertLen(t, got, 0) +} + +func TestManifest_splitManifestTrustedKeys_Ugly(t *core.T) { + got := splitManifestTrustedKeys(" ") + core.AssertEmpty(t, got) + core.AssertLen(t, got, 0) +} + +func TestManifest_parseManifestPublicKey_Good(t *core.T) { + pub, _, err := ed25519.GenerateKey(nil) + core.AssertNoError(t, err) + + got, err := publicKeyResult(parseManifestPublicKey(hex.EncodeToString(pub))) + core.AssertNoError(t, err) + core.AssertEqual(t, hex.EncodeToString(pub), hex.EncodeToString(got)) +} + +func TestManifest_parseManifestPublicKey_Bad(t *core.T) { + _, err := publicKeyResult(parseManifestPublicKey(manifestTestNotHex)) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "decode manifest public key failed") +} + +func TestManifest_parseManifestPublicKey_Ugly(t *core.T) { + _, err := publicKeyResult(parseManifestPublicKey(" ")) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "empty manifest public key") +} + +func TestManifest_dedupeManifestKeys_Good(t *core.T) { + pub, _, err := ed25519.GenerateKey(nil) + core.AssertNoError(t, err) + got := dedupeManifestKeys([]ed25519.PublicKey{pub, pub}) + core.AssertEqual(t, []ed25519.PublicKey{pub}, got) +} + +func TestManifest_dedupeManifestKeys_Bad(t *core.T) { + out := dedupeManifestKeys(nil) + core.AssertEmpty(t, out) + core.AssertLen(t, out, 0) +} + +func TestManifest_dedupeManifestKeys_Ugly(t *core.T) { + pub, _, err := ed25519.GenerateKey(nil) + core.AssertNoError(t, err) + + invalid := ed25519.PublicKey("short") + out := dedupeManifestKeys([]ed25519.PublicKey{invalid, invalid, pub}) + core.AssertEqual(t, []ed25519.PublicKey{pub}, out) +} + +func TestManifest_missingOrEmptyStringField_Good(t *core.T) { + raw := map[string]any{"sign": "abc"} + got := missingOrEmptyStringField(raw, "sign", "abc") + core.AssertFalse(t, got) +} + +func TestManifest_missingOrEmptyStringField_Bad(t *core.T) { + raw := map[string]any{} + got := missingOrEmptyStringField(raw, "sign", "abc") + core.AssertTrue(t, got) +} + +func TestManifest_missingOrEmptyStringField_Ugly(t *core.T) { + raw := map[string]any{"sign": ""} + core.AssertTrue(t, missingOrEmptyStringField(raw, "sign", "abc")) + raw["sign"] = " " + core.AssertTrue(t, missingOrEmptyStringField(raw, "sign", "abc")) +} + +func setManifestHomeDir(t *core.T, home string) { + t.Helper() + previous := manifestHomeDir + manifestHomeDir = func() string { + return home + } + t.Cleanup(func() { + manifestHomeDir = previous + }) +} + +func TestManifest_TrustedManifestPublicKeys_Good(t *core.T) { + pub, _, err := ed25519.GenerateKey(nil) + core.AssertNoError(t, err) + setManifestTrustKeys(t, hex.EncodeToString(pub)) + + got, err := trustedKeysResult(TrustedManifestPublicKeys()) + core.AssertNoError(t, err) + core.AssertLen(t, got, 1) + core.AssertEqual(t, pub, got[0]) +} + +func TestManifest_TrustedManifestPublicKeys_Bad(t *core.T) { + setManifestTrustKeys(t, manifestTestNotHex) + _, err := trustedKeysResult(TrustedManifestPublicKeys()) + core.AssertError(t, err) +} + +func TestManifest_TrustedManifestPublicKeys_Ugly(t *core.T) { + home := t.TempDir() + setManifestHomeDir(t, home) + t.Setenv("CORE_MANIFEST_TRUST_KEYS", "") + + keysDir := core.PathJoin(home, ".core", "keys") + testMkdirAll(t, keysDir, 0o755) + + pub, _, err := ed25519.GenerateKey(nil) + core.AssertNoError(t, err) + testWriteFile(t, core.PathJoin(keysDir, manifestTestTrustedPubFile), []byte(core.Sprintf("%x\n", pub)), 0o644) + + got, err := trustedKeysResult(TrustedManifestPublicKeys()) + core.AssertNoError(t, err) + core.AssertLen(t, got, 1) +} + +func TestManifest_TrustedManifestPublicKeys_SymlinkedCore_Bad(t *core.T) { + if runtime.GOOS == "windows" { + t.Skip(serviceTestWindowsSymlinkSkipMessage) + } + + home := t.TempDir() + setManifestHomeDir(t, home) + coreDir := core.PathJoin(home, ".core") + + realCore := core.PathJoin(t.TempDir(), "real-core") + testMkdirAll(t, realCore, 0o755) + testSymlink(t, realCore, coreDir) + t.Cleanup(func() { testRemove(coreDir) }) + + _, err := trustedKeysResult(TrustedManifestPublicKeys()) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "symlinked .core directory rejected") +} + +func TestManifest_TrustedManifestPublicKeys_SymlinkedKeysDir_Bad(t *core.T) { + if runtime.GOOS == "windows" { + t.Skip(serviceTestWindowsSymlinkSkipMessage) + } + + home := t.TempDir() + setManifestHomeDir(t, home) + realKeys := core.PathJoin(t.TempDir(), "real-keys") + + t.Setenv("CORE_MANIFEST_TRUST_KEYS", "") + + coreDir := core.PathJoin(home, ".core") + keysDir := core.PathJoin(coreDir, "keys") + testMkdirAll(t, coreDir, 0o755) + testMkdirAll(t, realKeys, 0o755) + testSymlink(t, realKeys, keysDir) + t.Cleanup(func() { testRemove(keysDir) }) + + _, err := trustedKeysResult(TrustedManifestPublicKeys()) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "symlinked trusted keys directory rejected") +} + +func TestManifest_TrustedManifestPublicKeys_SymlinkedKeyFile_Bad(t *core.T) { + if runtime.GOOS == "windows" { + t.Skip(serviceTestWindowsSymlinkSkipMessage) + } + + home := t.TempDir() + setManifestHomeDir(t, home) + realKeys := core.PathJoin(t.TempDir(), "real-keys") + pub, _, err := ed25519.GenerateKey(nil) + core.AssertNoError(t, err) + + t.Setenv("CORE_MANIFEST_TRUST_KEYS", "") + + coreDir := core.PathJoin(home, ".core") + keysDir := core.PathJoin(coreDir, "keys") + testMkdirAll(t, keysDir, 0o755) + testMkdirAll(t, realKeys, 0o755) + testWriteFile(t, core.PathJoin(realKeys, manifestTestTrustedPubFile), []byte(core.Sprintf("%x\n", pub)), 0o644) + symlinkPath := core.PathJoin(keysDir, manifestTestTrustedPubFile) + testSymlink(t, core.PathJoin(realKeys, manifestTestTrustedPubFile), symlinkPath) + t.Cleanup(func() { testRemove(symlinkPath) }) + + _, err = trustedKeysResult(TrustedManifestPublicKeys()) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "symlinked trusted key rejected") +} + +func TestManifest_TrustedManifestPublicKeys_Exported_Good(t *core.T) { + pub, _, err := ed25519.GenerateKey(nil) + core.AssertNoError(t, err) + setManifestTrustKeys(t, hex.EncodeToString(pub)) + + got, err := trustedKeysResult(TrustedManifestPublicKeys()) + core.AssertNoError(t, err) + core.AssertLen(t, got, 1) + core.AssertEqual(t, pub, got[0]) +} + +func TestManifest_SignViewManifest_ViewSignatureHelpers_Good(t *core.T) { + pub, priv, err := ed25519.GenerateKey(nil) + core.AssertNoError(t, err) + + view := &ViewManifest{ + Code: manifestTestPhotoBrowserCode, + Name: manifestTestPhotoBrowserName, + Version: ViewVersion("0.1.0"), + Layout: "HLCRF", + Slots: map[string]any{ + "C": "photo-grid", + }, + } + + body, err := bytesResult(CanonicalViewManifestBytes(view)) + core.AssertNoError(t, err) + core.AssertContains(t, string(body), "sign: \"\"") + + err = resultError(SignViewManifest(view, priv)) + core.AssertNoError(t, err) + core.AssertNotEmpty(t, view.Sign) + core.AssertNoError(t, resultError(ValidateViewManifestSignature(view))) + core.AssertNoError(t, resultError(VerifyViewManifestSignature(view, pub))) +} + +func TestManifest_ValidateViewManifestSignature_ViewSignatureHelpers_Bad(t *core.T) { + view := &ViewManifest{ + Code: manifestTestPhotoBrowserCode, + Name: manifestTestPhotoBrowserName, + Sign: "not-base64!!", + } + + err := resultError(ValidateViewManifestSignature(view)) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "invalid view manifest signature") +} + +func TestManifest_VerifyViewManifestSignature_ViewSignatureHelpers_Ugly(t *core.T) { + pub, _, err := ed25519.GenerateKey(nil) + core.AssertNoError(t, err) + + view := &ViewManifest{ + Code: manifestTestPhotoBrowserCode, + Name: manifestTestPhotoBrowserName, + Sign: base64.StdEncoding.EncodeToString(make([]byte, ed25519.SignatureSize)), + } + + err = resultError(VerifyViewManifestSignature(view, pub[:ed25519.PublicKeySize-1])) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "not an ed25519 public key") +} + +func TestManifest_SignPackageManifest_PackageSignatureHelpers_Good(t *core.T) { + _, priv, err := ed25519.GenerateKey(nil) + core.AssertNoError(t, err) + t.Setenv("CORE_MANIFEST_TRUST_KEYS", "") + + pkg := &PackageManifest{ + Code: "go-io", + Name: manifestTestCoreIOName, + Version: "0.3.0", + Description: manifestTestMandatoryIODescription, + Licence: manifestTestEUPL, + } + + body, err := bytesResult(CanonicalPackageManifestBytes(pkg)) + core.AssertNoError(t, err) + core.AssertContains(t, string(body), "sign: \"\"") + core.AssertContains(t, string(body), "sign_key: \"\"") + + err = resultError(SignPackageManifest(pkg, priv)) + core.AssertNoError(t, err) + core.AssertNotEmpty(t, pkg.Sign) + core.AssertNotEmpty(t, pkg.SignKey) + core.AssertNoError(t, resultError(VerifyPackageManifest(pkg))) +} + +func TestManifest_VerifyPackageManifest_PackageSignatureHelpers_Bad(t *core.T) { + pkg := &PackageManifest{ + Code: "go-io", + Name: manifestTestCoreIOName, + Version: "0.3.0", + SignKey: manifestTestNotHex, + Sign: base64.StdEncoding.EncodeToString(make([]byte, ed25519.SignatureSize)), + } + + err := resultError(VerifyPackageManifest(pkg)) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), manifestTestDecodePackageSignKeyFailed) +} + +func TestManifest_VerifyPackageManifest_PackageSignatureHelpers_Ugly(t *core.T) { + _, priv, err := ed25519.GenerateKey(nil) + core.AssertNoError(t, err) + t.Setenv("CORE_MANIFEST_TRUST_KEYS", "") + + pkg := &PackageManifest{ + Code: "go-io", + Name: manifestTestCoreIOName, + Version: "0.3.0", + Description: manifestTestMandatoryIODescription, + Licence: manifestTestEUPL, + } + + err = resultError(SignPackageManifest(pkg, priv)) + core.AssertNoError(t, err) + pkg.Description = "Tampered" + + err = resultError(VerifyPackageManifest(pkg)) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "signature mismatch") +} + +func TestManifest_LoadManifest_Good(t *core.T) { + m := coreio.NewMockMedium() + pub, priv, err := ed25519.GenerateKey(nil) + core.AssertNoError(t, err) + setManifestTrustKeys(t, hex.EncodeToString(pub)) + + signedPkg := &PackageManifest{ + Code: "go-io", + Name: manifestTestCoreIOName, + Version: "0.3.0", + Licence: manifestTestEUPL, + SignKey: hex.EncodeToString(pub), + } + msg, err := bytesResult(packageManifestBytes(signedPkg)) + core.AssertNoError(t, err) + signedPkg.Sign = base64.StdEncoding.EncodeToString(ed25519.Sign(priv, msg)) + m.Files["/pkg/.core/manifest.yaml"] = "code: go-io\nname: Core I/O\nversion: 0.3.0\nlicence: EUPL-1.2\nsign_key: " + signedPkg.SignKey + manifestTestSignPrefix + signedPkg.Sign + "\n" + + var pkg PackageManifest + err = resultError(LoadManifest(m, "/pkg/.core/manifest.yaml", &pkg)) + core.AssertNoError(t, err) + core.AssertEqual(t, "go-io", pkg.Code) + core.AssertEqual(t, manifestTestCoreIOName, pkg.Name) + core.AssertEqual(t, "0.3.0", pkg.Version) + core.AssertEqual(t, manifestTestEUPL, pkg.Licence) +} + +func TestManifest_LoadManifest_Bad(t *core.T) { + m := coreio.NewMockMedium() + var pkg PackageManifest + err := resultError(LoadManifest(m, "/nonexistent.yaml", &pkg)) + core.AssertError(t, err) +} + +func TestManifest_LoadManifest_Ugly(t *core.T) { + m := coreio.NewMockMedium() + m.Files["/bad.yaml"] = "this is: [not: valid: yaml" + + var pkg PackageManifest + err := resultError(LoadManifest(m, "/bad.yaml", &pkg)) + core.AssertError(t, err) +} + +func TestManifest_LoadManifest_Build_Good(t *core.T) { + m := coreio.NewMockMedium() + m.Files[manifestTestBuildPath] = "name: core\noutput: dist\ncgo: false\ntargets:\n - os: linux\n arch: amd64\n - os: darwin\n arch: arm64\n" + + var build BuildManifest + err := resultError(LoadManifest(m, manifestTestBuildPath, &build)) + core.AssertNoError(t, err) + core.AssertEqual(t, "core", build.Name) + core.AssertEqual(t, "dist", build.Output) + core.AssertLen(t, build.Targets, 2) + core.AssertEqual(t, "linux", build.Targets[0].OS) + core.AssertEqual(t, "amd64", build.Targets[0].Arch) +} + +func TestManifest_LoadManifest_Build_ShorthandTargets_Good(t *core.T) { + m := coreio.NewMockMedium() + m.Files[manifestTestBuildPath] = "name: core\noutput: dist\ntargets:\n - linux/amd64\n - darwin/arm64\nsign:\n enabled: true\n gpg:\n key: $GPG_KEY_ID\n macos:\n identity: 'Developer ID Application: Example'\n notarize: false\nsdk:\n spec: openapi.yaml\n languages:\n - typescript\n - go\n output: sdk/\n diff: true\n" + + var build BuildManifest + err := resultError(LoadManifest(m, manifestTestBuildPath, &build)) + core.AssertNoError(t, err) + core.AssertLen(t, build.Targets, 2) + core.AssertEqual(t, "linux", build.Targets[0].OS) + core.AssertEqual(t, "amd64", build.Targets[0].Arch) + core.AssertTrue(t, build.Signing.Enabled) + core.AssertEqual(t, "$GPG_KEY_ID", build.Signing.GPG.Key) + core.AssertEqual(t, "Developer ID Application: Example", build.Signing.MacOS.Identity) + core.AssertTrue(t, build.SDK.Diff) + core.AssertEqual(t, "openapi.yaml", build.SDK.Spec) + core.AssertEqual(t, []string{"typescript", "go"}, build.SDK.Languages) +} + +func TestManifest_LoadManifest_Build_LegacyFlat_Good(t *core.T) { + m := coreio.NewMockMedium() + m.Files[manifestTestBuildPath] = "name: core\nmain: ./cmd/core\nbinary: core\noutput: dist\nflags:\n - -trimpath\nldflags: -s -w\ncgo: false\ntargets:\n - linux/amd64\n" + + var build BuildManifest + err := resultError(LoadManifest(m, manifestTestBuildPath, &build)) + core.AssertNoError(t, err) + core.AssertEqual(t, "core", build.Name) + core.AssertEqual(t, "./cmd/core", build.Main) + core.AssertEqual(t, "core", build.Binary) + core.AssertEqual(t, "dist", build.Output) + core.AssertEqual(t, []string{"-trimpath"}, build.Flags) + core.AssertEqual(t, "-s -w", build.LDFlags) + core.AssertFalse(t, build.CGO) + core.AssertLen(t, build.Targets, 1) +} + +func TestManifest_BuildTarget_UnmarshalYAML_Good(t *core.T) { + var target BuildTarget + core.AssertNoError(t, resultError(target.UnmarshalYAML(&yaml.Node{Kind: yaml.ScalarNode, Value: "linux/amd64"}))) + core.AssertEqual(t, BuildTarget{OS: "linux", Arch: "amd64"}, target) + + core.AssertNoError(t, resultError(target.UnmarshalYAML(&yaml.Node{ + Kind: yaml.MappingNode, + Content: []*yaml.Node{ + {Kind: yaml.ScalarNode, Value: "o" + "s"}, + {Kind: yaml.ScalarNode, Value: "darwin"}, + {Kind: yaml.ScalarNode, Value: "arch"}, + {Kind: yaml.ScalarNode, Value: "arm64"}, + }, + }))) + core.AssertEqual(t, BuildTarget{OS: "darwin", Arch: "arm64"}, target) +} + +func TestManifest_BuildTarget_UnmarshalYAML_Bad(t *core.T) { + var target BuildTarget + + err := resultError(target.UnmarshalYAML(&yaml.Node{Kind: yaml.ScalarNode, Value: "linux"})) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "invalid target shorthand") +} + +func TestManifest_BuildTarget_UnmarshalYAML_Ugly(t *core.T) { + var target BuildTarget + + core.AssertNoError(t, resultError(target.UnmarshalYAML(&yaml.Node{Kind: yaml.ScalarNode, Value: ""}))) + core.AssertEqual(t, BuildTarget{}, target) +} + +func TestManifest_BuildManifestLDFlags_String_Good(t *core.T) { + flags := buildmanifestldflags{"-s", "-w"} + got := flags.String() + core.AssertEqual(t, "-s -w", got) +} + +func TestManifest_BuildManifestLDFlags_UnmarshalYAML_Good(t *core.T) { + var flags buildmanifestldflags + + core.AssertNoError(t, resultError(flags.UnmarshalYAML(&yaml.Node{Kind: yaml.ScalarNode, Value: "-s -w"}))) + core.AssertEqual(t, buildmanifestldflags{"-s -w"}, flags) + + core.AssertNoError(t, resultError(flags.UnmarshalYAML(&yaml.Node{ + Kind: yaml.SequenceNode, + Content: []*yaml.Node{ + {Kind: yaml.ScalarNode, Value: "-s"}, + {Kind: yaml.ScalarNode, Value: "-w"}, + }, + }))) + core.AssertEqual(t, buildmanifestldflags{"-s", "-w"}, flags) +} + +func TestManifest_BuildManifestLDFlags_UnmarshalYAML_Bad(t *core.T) { + var flags buildmanifestldflags + + err := resultError(flags.UnmarshalYAML(&yaml.Node{ + Kind: yaml.MappingNode, + Content: []*yaml.Node{ + {Kind: yaml.ScalarNode, Value: "0"}, + {Kind: yaml.ScalarNode, Value: "-s"}, + }, + })) + core.AssertError(t, err) +} + +func TestManifest_BuildManifestLDFlags_UnmarshalYAML_Ugly(t *core.T) { + var flags buildmanifestldflags + + core.AssertNoError(t, resultError(flags.UnmarshalYAML(&yaml.Node{Kind: yaml.ScalarNode, Value: ""}))) + core.AssertNil(t, flags) +} + +func TestManifest_LoadManifest_View_Good(t *core.T) { + m := coreio.NewMockMedium() + pub, priv, err := ed25519.GenerateKey(nil) + core.AssertNoError(t, err) + setManifestTrustKeys(t, hex.EncodeToString(pub)) + + signedView := &ViewManifest{ + Code: manifestTestPhotoBrowserCode, + Name: manifestTestPhotoBrowserName, + Version: ViewVersion("0.1.0"), + Permissions: ViewPermissions{ + Clipboard: true, + Filesystem: true, + }, + } + msg, err := bytesResult(viewManifestBytes(signedView)) + core.AssertNoError(t, err) + signedView.Sign = base64.StdEncoding.EncodeToString(ed25519.Sign(priv, msg)) + m.Files[manifestTestViewPath] = "code: photo-browser\nname: Photo Browser\nversion: 0.1.0\npermissions:\n clipboard: true\n filesystem: true\nsign: " + signedView.Sign + "\n" + + var got ViewManifest + err = resultError(LoadManifest(m, manifestTestViewPath, &got)) + core.AssertNoError(t, err) + core.AssertEqual(t, manifestTestPhotoBrowserCode, got.Code) + core.AssertTrue(t, got.Permissions.Clipboard) + core.AssertTrue(t, got.Permissions.Filesystem) +} + +func TestManifest_LoadManifest_View_VersionInteger_Good(t *core.T) { + m := coreio.NewMockMedium() + m.Files[manifestTestViewPath] = "code: photo-browser\nname: Photo Browser\nversion: 1\nsign: " + base64.StdEncoding.EncodeToString(make([]byte, ed25519.SignatureSize)) + "\n" + + var view ViewManifest + err := resultError(LoadManifest(m, manifestTestViewPath, &view)) + core.AssertNoError(t, err) + core.AssertEqual(t, ViewVersion("1"), view.Version) +} + +func TestManifest_LoadManifest_View_Bad(t *core.T) { + m := coreio.NewMockMedium() + m.Files[manifestTestViewPath] = "code: photo-browser\nname: Photo Browser\npermissions:\n clipboard: true\n" + + var view ViewManifest + err := resultError(LoadManifest(m, manifestTestViewPath, &view)) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "unsigned view manifest rejected") +} + +func TestManifest_LoadManifest_View_Ugly(t *core.T) { + m := coreio.NewMockMedium() + m.Files[manifestTestViewPath] = "code: photo-browser\nname: Photo Browser\nsign: " + base64.StdEncoding.EncodeToString(make([]byte, ed25519.SignatureSize-1)) + "\npermissions:\n clipboard: true\n" + + var view ViewManifest + err := resultError(LoadManifest(m, manifestTestViewPath, &view)) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "view manifest signature is not ed25519-sized") +} + +func TestManifest_LoadManifest_Test_Good(t *core.T) { + m := coreio.NewMockMedium() + m.Files["/.core/test.yaml"] = "version: 1\ncommands:\n - name: unit\n run: vendor/bin/pest --parallel\n - name: types\n run: vendor/bin/phpstan analyse\nenv:\n APP_ENV: testing\n DB_CONNECTION: sqlite\n" + + var test TestManifest + err := resultError(LoadManifest(m, "/.core/test.yaml", &test)) + core.AssertNoError(t, err) + core.AssertEqual(t, 1, test.Version) + core.AssertLen(t, test.Commands, 2) + core.AssertEqual(t, "unit", test.Commands[0].Name) + core.AssertEqual(t, "vendor/bin/pest --parallel", test.Commands[0].Run) + core.AssertEqual(t, "testing", test.Env["APP_ENV"]) +} + +func TestManifest_LoadManifest_Run_Good(t *core.T) { + m := coreio.NewMockMedium() + m.Files["/.core/run.yaml"] = "version: 1\nservices:\n - name: database\n image: postgres:16\n port: 5432\n env:\n POSTGRES_DB: core_dev\ndev:\n command: php artisan serve\n port: 8000\n watch:\n - app/\n - resources/\nenv:\n APP_ENV: local\n" + + var run RunManifest + err := resultError(LoadManifest(m, "/.core/run.yaml", &run)) + core.AssertNoError(t, err) + core.AssertEqual(t, 1, run.Version) + core.AssertLen(t, run.Services, 1) + core.AssertEqual(t, "database", run.Services[0].Name) + core.AssertEqual(t, 5432, run.Services[0].Port) + core.AssertEqual(t, "core_dev", run.Services[0].Env["POSTGRES_DB"]) + core.AssertEqual(t, "php artisan serve", run.Dev.Command) + core.AssertEqual(t, 8000, run.Dev.Port) + core.AssertContains(t, run.Dev.Watch, "app/") +} + +func TestManifest_LoadManifest_Repos_Good(t *core.T) { + m := coreio.NewMockMedium() + m.Files["/Code/.core/repos.yaml"] = "org: host-uk\nrepos:\n - path: core/go\n remote: ssh://forge.example/core/go.git\n branch: dev\n type: lib\n depends:\n - go-io\n - path: core/config\n remote: ssh://forge.example/core/config.git\n branch: dev\n" + + var repos ReposManifest + err := resultError(LoadManifest(m, "/Code/.core/repos.yaml", &repos)) + core.AssertNoError(t, err) + core.AssertEqual(t, "host-uk", repos.Org) + core.AssertLen(t, repos.Repos, 2) + core.AssertEqual(t, "core/go", repos.Repos[0].Path) + core.AssertEqual(t, "dev", repos.Repos[0].Branch) + core.AssertEqual(t, "lib", repos.Repos[0].Type) + core.AssertContains(t, repos.Repos[0].Depends, "go-io") +} + +func TestManifest_LoadManifest_Repos_Bad(t *core.T) { + m := coreio.NewMockMedium() + var repos ReposManifest + err := resultError(LoadManifest(m, "/missing/repos.yaml", &repos)) + core.AssertError(t, err) +} + +func TestManifest_LoadManifest_Package_Bad(t *core.T) { + m := coreio.NewMockMedium() + m.Files[manifestTestManifestPath] = "code: go-io\nname: Core I/O\nversion: 0.3.0\nlicence: EUPL-1.2\nsign: " + base64.StdEncoding.EncodeToString(make([]byte, ed25519.SignatureSize)) + "\nsign_key: not-hex\n" + + var pkg PackageManifest + err := resultError(LoadManifest(m, manifestTestManifestPath, &pkg)) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), manifestTestDecodePackageSignKeyFailed) +} + +func TestManifest_LoadManifest_Package_Ugly(t *core.T) { + m := coreio.NewMockMedium() + t.Setenv("CORE_MANIFEST_TRUST_KEYS", "") + pub1, _, err := ed25519.GenerateKey(nil) + core.AssertNoError(t, err) + _, priv2, err := ed25519.GenerateKey(nil) + core.AssertNoError(t, err) + + pkg := &PackageManifest{ + Code: "go-io", + Name: manifestTestCoreIOName, + Version: "0.3.0", + Licence: manifestTestEUPL, + SignKey: hex.EncodeToString(pub1), + } + msg, err := bytesResult(packageManifestBytes(pkg)) + core.AssertNoError(t, err) + pkg.Sign = base64.StdEncoding.EncodeToString(ed25519.Sign(priv2, msg)) + + out, err := yaml.Marshal(pkg) + core.AssertNoError(t, err) + m.Files[manifestTestManifestPath] = string(out) + + var got PackageManifest + err = resultError(LoadManifest(m, manifestTestManifestPath, &got)) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "package manifest signature mismatch") +} + +func TestManifest_LoadManifest_Release_Good(t *core.T) { + m := coreio.NewMockMedium() + m.Files["/.core/release.yaml"] = "archive:\n format: tar.gz\n include:\n - LICENSE.txt\n - README.md\nchecksums: true\ngithub:\n draft: false\n prerelease: false\nchangelog:\n include:\n - feat\n - fix\n" + + var rel ReleaseManifest + err := resultError(LoadManifest(m, "/.core/release.yaml", &rel)) + core.AssertNoError(t, err) + core.AssertEqual(t, "tar.gz", rel.Archive.Format) + core.AssertContains(t, rel.Archive.Include, "LICENSE.txt") + core.AssertTrue(t, rel.Checksums) + core.AssertFalse(t, rel.GitHub.Draft) + core.AssertContains(t, rel.Changelog.Include, "feat") +} + +func TestManifest_LoadManifest_Agent_Good(t *core.T) { + m := coreio.NewMockMedium() + m.Files["/home/.core/agent.yaml"] = "daemon:\n enabled: true\n watch:\n - ~/Code/core/\n schedule:\n - cron: '*/5 * * * *'\n action: health.check\n mcp:\n port: 8080\n api:\n port: 8099\n bind: 127.0.0.1\nagents:\n codex:\n total: 2\n claude:\n total: 1\n" + + var agent AgentManifest + err := resultError(LoadManifest(m, "/home/.core/agent.yaml", &agent)) + core.AssertNoError(t, err) + core.AssertTrue(t, agent.Daemon.Enabled) + core.AssertEqual(t, "health.check", agent.Daemon.Schedule[0].Action) + core.AssertEqual(t, 8099, agent.Daemon.API.Port) + core.AssertEqual(t, 2, agent.Agents["codex"].Total) +} + +func TestManifest_LoadManifest_Zone_Good(t *core.T) { + m := coreio.NewMockMedium() + m.Files["/home/.core/zone.yaml"] = "zone:\n name: snider\n identity: '@snider@lthn'\n chain:\n mode: thin\n daemon: localhost:36941\n network:\n wireguard:\n interface: wg-lthn\n listen: 51820\n services:\n vpn:\n enabled: true\n price: 0.001\n capacity: 100\n dns:\n enabled: true\n compute:\n enabled: true\n models:\n - lem-1b\n - lem-4b\n staking:\n amount: 1000\n tier: trusted\n" + + var zone ZoneManifest + err := resultError(LoadManifest(m, "/home/.core/zone.yaml", &zone)) + core.AssertNoError(t, err) + core.AssertEqual(t, "snider", zone.Zone.Name) + core.AssertEqual(t, "thin", zone.Zone.Chain.Mode) + core.AssertEqual(t, "wg-lthn", zone.Zone.Network.WireGuard.Interface) + core.AssertEqual(t, 100, zone.Zone.Services.VPN.Capacity) + core.AssertContains(t, zone.Zone.Services.Compute.Models, "lem-4b") +} + +func TestManifest_LoadManifest_Schema_Bad(t *core.T) { + m := coreio.NewMockMedium() + m.Files[manifestTestBuildPath] = "targets: 42\n" + + var build BuildManifest + err := resultError(LoadManifest(m, manifestTestBuildPath, &build)) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "schema validation failed") +} + +func TestManifest_LoadManifest_PackageSignature_Good(t *core.T) { + pub, priv, err := ed25519.GenerateKey(nil) + core.AssertNoError(t, err) + setManifestTrustKeys(t, hex.EncodeToString(pub)) + + pkg := &PackageManifest{ + Code: "go-io", + Name: manifestTestCoreIOName, + Version: "0.3.0", + Description: manifestTestMandatoryIODescription, + Licence: manifestTestEUPL, + SignKey: hex.EncodeToString(pub), + } + + msg, err := bytesResult(packageManifestBytes(pkg)) + core.AssertNoError(t, err) + pkg.Sign = base64.StdEncoding.EncodeToString(ed25519.Sign(priv, msg)) + + m := coreio.NewMockMedium() + m.Files[manifestTestManifestPath] = manifestTestPackageContentPrefix + pkg.SignKey + manifestTestSignPrefix + pkg.Sign + "\n" + + var round PackageManifest + err = resultError(LoadManifest(m, manifestTestManifestPath, &round)) + core.AssertNoError(t, err) + core.AssertEqual(t, pkg.Code, round.Code) + core.AssertEqual(t, pkg.SignKey, round.SignKey) +} + +func TestManifest_LoadManifest_PackageSignature_UntrustedKey_Bad(t *core.T) { + trustedPub, _, err := ed25519.GenerateKey(nil) + core.AssertNoError(t, err) + untrustedPub, priv, err := ed25519.GenerateKey(nil) + core.AssertNoError(t, err) + setManifestTrustKeys(t, hex.EncodeToString(trustedPub)) + + pkg := &PackageManifest{ + Code: "go-io", + Name: manifestTestCoreIOName, + Version: "0.3.0", + Description: manifestTestMandatoryIODescription, + Licence: manifestTestEUPL, + SignKey: hex.EncodeToString(untrustedPub), + } + + msg, err := bytesResult(packageManifestBytes(pkg)) + core.AssertNoError(t, err) + pkg.Sign = base64.StdEncoding.EncodeToString(ed25519.Sign(priv, msg)) + + m := coreio.NewMockMedium() + m.Files[manifestTestManifestPath] = manifestTestPackageContentPrefix + pkg.SignKey + manifestTestSignPrefix + pkg.Sign + "\n" + + var round PackageManifest + err = resultError(LoadManifest(m, manifestTestManifestPath, &round)) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "package sign_key is not trusted") +} + +func TestManifest_LoadManifest_PackageSignature_Bad(t *core.T) { + pub, priv, err := ed25519.GenerateKey(nil) + core.AssertNoError(t, err) + setManifestTrustKeys(t, hex.EncodeToString(pub)) + + pkg := &PackageManifest{ + Code: "go-io", + Name: manifestTestCoreIOName, + Version: "0.3.0", + Description: manifestTestMandatoryIODescription, + Licence: manifestTestEUPL, + SignKey: hex.EncodeToString(pub), + } + + msg, err := bytesResult(packageManifestBytes(pkg)) + core.AssertNoError(t, err) + pkg.Sign = base64.StdEncoding.EncodeToString(ed25519.Sign(priv, msg)) + + m := coreio.NewMockMedium() + m.Files[manifestTestManifestPath] = manifestTestPackageContentPrefix + pkg.SignKey + manifestTestSignPrefix + pkg.Sign + "\n" + + // Tamper with the persisted content after signing. + m.Files[manifestTestManifestPath] = "code: go-io\nname: Core I/O\nversion: 0.3.0\ndescription: Tampered description\nlicence: EUPL-1.2\nsign_key: " + pkg.SignKey + manifestTestSignPrefix + pkg.Sign + "\n" + + var round PackageManifest + err = resultError(LoadManifest(m, manifestTestManifestPath, &round)) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "signature mismatch") +} + +func TestManifest_LoadManifest_ViewSignatureShape_Bad(t *core.T) { + m := coreio.NewMockMedium() + m.Files[manifestTestViewPath] = "code: photo-browser\nname: Photo Browser\nsign: not-base64!!\n" + + var view ViewManifest + err := resultError(LoadManifest(m, manifestTestViewPath, &view)) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "invalid view manifest signature") +} + +func TestManifest_LoadManifest_ViewUnsigned_Bad(t *core.T) { + m := coreio.NewMockMedium() + m.Files[manifestTestViewPath] = "code: photo-browser\nname: Photo Browser\n" + + var view ViewManifest + err := resultError(LoadManifest(m, manifestTestViewPath, &view)) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "unsigned view manifest rejected") +} + +func TestManifest_LoadManifest_PackageUnsigned_Bad(t *core.T) { + m := coreio.NewMockMedium() + m.Files[manifestTestManifestPath] = "code: go-io\nname: Core I/O\nversion: 0.3.0\nlicence: EUPL-1.2\n" + + var pkg PackageManifest + err := resultError(LoadManifest(m, manifestTestManifestPath, &pkg)) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "unsigned package manifest rejected") +} + +func TestManifest_LoadManifest_PackageMissingSignKey_Bad(t *core.T) { + pub, priv, err := ed25519.GenerateKey(nil) + core.AssertNoError(t, err) + setManifestTrustKeys(t, hex.EncodeToString(pub)) + + pkg := &PackageManifest{ + Code: "go-io", + Name: manifestTestCoreIOName, + Version: "0.3.0", + Description: manifestTestMandatoryIODescription, + Licence: manifestTestEUPL, + SignKey: hex.EncodeToString(pub), + } + msg, err := bytesResult(packageManifestBytes(pkg)) + core.AssertNoError(t, err) + pkg.Sign = base64.StdEncoding.EncodeToString(ed25519.Sign(priv, msg)) + + m := coreio.NewMockMedium() + m.Files[manifestTestManifestPath] = "code: go-io\nname: Core I/O\nversion: 0.3.0\ndescription: Mandatory I/O abstraction layer\nlicence: EUPL-1.2\nsign: " + pkg.Sign + "\n" + + var round PackageManifest + err = resultError(LoadManifest(m, manifestTestManifestPath, &round)) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "missing package sign_key") +} + +func TestManifest_KnownFiles_Good(t *core.T) { + // The constants are single-source-of-truth names; KnownFiles must contain + // every canonical project-level file and not duplicate any. + core.AssertContains(t, KnownFiles, FileConfig) + core.AssertContains(t, KnownFiles, FileBuild) + core.AssertContains(t, KnownFiles, FileTest) + core.AssertContains(t, KnownFiles, FileRun) + core.AssertContains(t, KnownFiles, FileRelease) + core.AssertContains(t, KnownFiles, FileView) + core.AssertContains(t, KnownFiles, FileManifest) + core.AssertContains(t, KnownFiles, FileWorkspace) + core.AssertContains(t, KnownFiles, FileRepos) + core.AssertContains(t, KnownFiles, FileIDE) + core.AssertContains(t, KnownFiles, FilePHP) + core.AssertEqual(t, ".core", Directory) + + // User-level files have constants but are not part of project discovery. + core.AssertEqual(t, "agent.yaml", FileAgent) + core.AssertEqual(t, "zone.yaml", FileZone) + core.AssertEqual(t, "ide.yaml", FileIDE) + core.AssertEqual(t, "php.yaml", FilePHP) + + seen := map[string]struct{}{} + for _, name := range KnownFiles { + _, dup := seen[name] + core.AssertFalse(t, dup, "duplicate known file: %s", name) + seen[name] = struct{}{} + } +} + +func axManifestView() ViewManifest { + return ViewManifest{ + Version: "1", + Code: manifestTestPhotoBrowserCode, + Name: manifestTestPhotoBrowserName, + Title: "Photos", + Width: 800, + Height: 600, + Resizable: true, + } +} + +func axManifestPackage() PackageManifest { + return PackageManifest{ + Code: "go-config", + Name: "Core Config", + Module: "dappco.re/go/config", + Version: "0.9.0", + Description: "config package", + Licence: manifestTestEUPL, + } +} + +func axSignedView(t *core.T) (ViewManifest, ed25519.PublicKey) { + t.Helper() + pub, priv, err := ed25519.GenerateKey(nil) + core.RequireNoError(t, err) + view := axManifestView() + core.RequireNoError(t, resultError(SignViewManifest(&view, priv))) + return view, pub +} + +func axSignedPackage(t *core.T) (PackageManifest, ed25519.PublicKey) { + t.Helper() + pub, priv, err := ed25519.GenerateKey(nil) + core.RequireNoError(t, err) + pkg := axManifestPackage() + core.RequireNoError(t, resultError(SignPackageManifest(&pkg, priv))) + return pkg, pub +} + +func testYAMLRoot(t *core.T, body string) *yaml.Node { + t.Helper() + var node yaml.Node + core.RequireNoError(t, yaml.Unmarshal([]byte(body), &node)) + return manifestYAMLRoot(&node) +} + +func TestManifest_ViewVersion_UnmarshalYAML_Good(t *core.T) { + var version ViewVersion + err := resultError(version.UnmarshalYAML(&yaml.Node{Kind: yaml.ScalarNode, Value: "1"})) + core.AssertNoError(t, err) + core.AssertEqual(t, ViewVersion("1"), version) +} + +func TestManifest_ViewVersion_UnmarshalYAML_Bad(t *core.T) { + var version ViewVersion + err := resultError(version.UnmarshalYAML(&yaml.Node{Kind: yaml.SequenceNode})) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "invalid view manifest version") +} + +func TestManifest_ViewVersion_UnmarshalYAML_Ugly(t *core.T) { + base := &yaml.Node{Kind: yaml.ScalarNode, Value: "2"} + alias := &yaml.Node{Kind: yaml.AliasNode, Alias: base} + var version ViewVersion + err := resultError(version.UnmarshalYAML(alias)) + core.AssertNoError(t, err) + core.AssertEqual(t, ViewVersion("2"), version) +} + +func TestManifest_buildmanifestldflags_UnmarshalYAML_Good(t *core.T) { + var flags buildmanifestldflags + err := resultError(flags.UnmarshalYAML(&yaml.Node{Kind: yaml.ScalarNode, Value: "-s -w"})) + core.AssertNoError(t, err) + core.AssertEqual(t, buildmanifestldflags{"-s -w"}, flags) +} + +func TestManifest_buildmanifestldflags_UnmarshalYAML_Bad(t *core.T) { + var flags buildmanifestldflags + err := resultError(flags.UnmarshalYAML(&yaml.Node{Kind: yaml.MappingNode})) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "unsupported ldflags mapping") +} + +func TestManifest_buildmanifestldflags_UnmarshalYAML_Ugly(t *core.T) { + var flags buildmanifestldflags + err := resultError(flags.UnmarshalYAML(&yaml.Node{ + Kind: yaml.SequenceNode, + Content: []*yaml.Node{ + {Kind: yaml.ScalarNode, Value: "-s"}, + {Kind: yaml.ScalarNode, Value: "-w"}, + }, + })) + core.AssertNoError(t, err) + core.AssertEqual(t, buildmanifestldflags{"-s", "-w"}, flags) +} + +func TestManifest_buildmanifestldflags_String_Good(t *core.T) { + flags := buildmanifestldflags{"-s", "-w"} + got := flags.String() + core.AssertEqual(t, "-s -w", got) + core.AssertNotEmpty(t, got) +} + +func TestManifest_buildmanifestldflags_String_Bad(t *core.T) { + var flags buildmanifestldflags + got := flags.String() + core.AssertEqual(t, "", got) +} + +func TestManifest_buildmanifestldflags_String_Ugly(t *core.T) { + flags := buildmanifestldflags{"-X", "main.version=0.9.0"} + got := flags.String() + core.AssertEqual(t, "-X main.version=0.9.0", got) +} + +func TestManifest_BuildManifest_UnmarshalYAML_Good(t *core.T) { + var build BuildManifest + body := "version: 1\nproject:\n name: core\n main: ./cmd/core\nbuild:\n flags: [-trimpath]\n ldflags: [-s, -w]\ntargets:\n - linux/amd64\n" + err := resultError(build.UnmarshalYAML(testYAMLRoot(t, body))) + core.AssertNoError(t, err) + core.AssertEqual(t, "core", build.Name) +} + +func TestManifest_BuildManifest_UnmarshalYAML_Bad(t *core.T) { + var build BuildManifest + body := "version: 1\ntargets:\n - invalid-target\n" + err := resultError(build.UnmarshalYAML(testYAMLRoot(t, body))) + core.AssertError(t, err) +} + +func TestManifest_BuildManifest_UnmarshalYAML_Ugly(t *core.T) { + var build BuildManifest + body := "version: 1\nname: legacy\nmain: ./main.go\nbinary: app\noutput: dist\nldflags: -s -w\ncgo: true\n" + err := resultError(build.UnmarshalYAML(testYAMLRoot(t, body))) + core.AssertNoError(t, err) + core.AssertEqual(t, "legacy", build.Project.Name) +} + +func TestManifest_CanonicalViewManifestBytes_Good(t *core.T) { + view := axManifestView() + view.Sign = "signature" + body, err := bytesResult(CanonicalViewManifestBytes(&view)) + core.AssertNoError(t, err) + core.AssertNotContains(t, string(body), "signature") +} + +func TestManifest_CanonicalViewManifestBytes_Bad(t *core.T) { + body, err := bytesResult(CanonicalViewManifestBytes(nil)) + core.AssertNoError(t, err) + core.AssertContains(t, string(body), "null") +} + +func TestManifest_CanonicalViewManifestBytes_Ugly(t *core.T) { + view := axManifestView() + view.Sign = manifestTestKeepMe + _, err := bytesResult(CanonicalViewManifestBytes(&view)) + core.AssertNoError(t, err) + core.AssertEqual(t, manifestTestKeepMe, view.Sign) +} + +func TestManifest_ValidateViewManifestSignature_Good(t *core.T) { + view, _ := axSignedView(t) + err := resultError(ValidateViewManifestSignature(&view)) + core.AssertNoError(t, err) +} + +func TestManifest_ValidateViewManifestSignature_Bad(t *core.T) { + view := axManifestView() + err := resultError(ValidateViewManifestSignature(&view)) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "unsigned") +} + +func TestManifest_ValidateViewManifestSignature_Ugly(t *core.T) { + view := axManifestView() + view.Sign = base64.StdEncoding.EncodeToString([]byte("short")) + err := resultError(ValidateViewManifestSignature(&view)) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "not ed25519-sized") +} + +func TestManifest_VerifyViewManifestSignature_Good(t *core.T) { + view, pub := axSignedView(t) + err := resultError(VerifyViewManifestSignature(&view, pub)) + core.AssertNoError(t, err) +} + +func TestManifest_VerifyViewManifestSignature_Bad(t *core.T) { + view, _ := axSignedView(t) + wrong, _, err := ed25519.GenerateKey(nil) + core.RequireNoError(t, err) + err = resultError(VerifyViewManifestSignature(&view, wrong)) + core.AssertError(t, err) +} + +func TestManifest_VerifyViewManifestSignature_Ugly(t *core.T) { + view, _ := axSignedView(t) + err := resultError(VerifyViewManifestSignature(&view, ed25519.PublicKey("short"))) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "not an ed25519 public key") +} + +func TestManifest_SignViewManifest_Good(t *core.T) { + _, priv, err := ed25519.GenerateKey(nil) + core.RequireNoError(t, err) + view := axManifestView() + err = resultError(SignViewManifest(&view, priv)) + core.AssertNoError(t, err) + core.AssertNotEmpty(t, view.Sign) +} + +func TestManifest_SignViewManifest_Bad(t *core.T) { + _, priv, err := ed25519.GenerateKey(nil) + core.RequireNoError(t, err) + err = resultError(SignViewManifest(nil, priv)) + core.AssertError(t, err) +} + +func TestManifest_SignViewManifest_Ugly(t *core.T) { + view := axManifestView() + err := resultError(SignViewManifest(&view, ed25519.PrivateKey("short"))) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "private key") +} + +func TestManifest_CanonicalPackageManifestBytes_Good(t *core.T) { + pkg := axManifestPackage() + pkg.Sign = "signature" + body, err := bytesResult(CanonicalPackageManifestBytes(&pkg)) + core.AssertNoError(t, err) + core.AssertNotContains(t, string(body), "signature") +} + +func TestManifest_CanonicalPackageManifestBytes_Bad(t *core.T) { + body, err := bytesResult(CanonicalPackageManifestBytes(nil)) + core.AssertNoError(t, err) + core.AssertContains(t, string(body), "null") +} + +func TestManifest_CanonicalPackageManifestBytes_Ugly(t *core.T) { + pkg := axManifestPackage() + pkg.Sign = manifestTestKeepMe + _, err := bytesResult(CanonicalPackageManifestBytes(&pkg)) + core.AssertNoError(t, err) + core.AssertEqual(t, manifestTestKeepMe, pkg.Sign) +} + +func TestManifest_SignPackageManifest_Good(t *core.T) { + _, priv, err := ed25519.GenerateKey(nil) + core.RequireNoError(t, err) + pkg := axManifestPackage() + err = resultError(SignPackageManifest(&pkg, priv)) + core.AssertNoError(t, err) + core.AssertNotEmpty(t, pkg.SignKey) +} + +func TestManifest_SignPackageManifest_Bad(t *core.T) { + _, priv, err := ed25519.GenerateKey(nil) + core.RequireNoError(t, err) + err = resultError(SignPackageManifest(nil, priv)) + core.AssertError(t, err) +} + +func TestManifest_SignPackageManifest_Ugly(t *core.T) { + pkg := axManifestPackage() + err := resultError(SignPackageManifest(&pkg, ed25519.PrivateKey("short"))) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "private key") +} + +func TestManifest_VerifyPackageManifest_Good(t *core.T) { + pkg, pub := axSignedPackage(t) + setManifestTrustKeys(t, hex.EncodeToString(pub)) + err := resultError(VerifyPackageManifest(&pkg)) + core.AssertNoError(t, err) +} + +func TestManifest_VerifyPackageManifest_Bad(t *core.T) { + pkg := axManifestPackage() + err := resultError(VerifyPackageManifest(&pkg)) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "unsigned") +} + +func TestManifest_VerifyPackageManifest_Ugly(t *core.T) { + pkg := axManifestPackage() + pkg.Sign = base64.StdEncoding.EncodeToString(make([]byte, ed25519.SignatureSize)) + pkg.SignKey = manifestTestNotHex + err := resultError(VerifyPackageManifest(&pkg)) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), manifestTestDecodePackageSignKeyFailed) +} diff --git a/go/paths.go b/go/paths.go new file mode 100644 index 0000000..821e65f --- /dev/null +++ b/go/paths.go @@ -0,0 +1,74 @@ +package config + +import ( + core "dappco.re/go" + coreio "dappco.re/go/io" +) + +type symlinkReporter interface { + IsSymlink(string) bool +} + +var localLstat = func(path string) (core.FsFileInfo, error) { + r := core.Lstat(path) + if !r.OK { + if err, ok := r.Value.(error); ok { + return nil, err + } + return nil, core.NewError("lstat failed") + } + return r.Value.(core.FsFileInfo), nil +} + +// isSafePathElement reports whether part is a single relative path element. +// It rejects absolute paths, traversal segments, and separators so public +// helpers cannot be tricked into leaving their intended directory roots. +func isSafePathElement(part string) bool { + if part == "" { + return false + } + if core.PathIsAbs(part) { + return false + } + if core.Contains(part, "/") || core.Contains(part, `\`) { + return false + } + clean := core.CleanPath(part, string(core.PathSeparator)) + if clean != part { + return false + } + switch clean { + case ".", "..": + return false + default: + return true + } +} + +// isSymlinkedCoreDir reports whether path points at a symlinked .core +// directory on the local filesystem. Discovery helpers use this to reject +// unsafe repository roots before they are traversed. +func isSymlinkedCoreDir(medium coreio.Medium, path string) bool { + if reporter, ok := medium.(symlinkReporter); ok { + return reporter.IsSymlink(path) + } + if medium != coreio.Local { + return false + } + return isSymlinkedByLstat(localLstat, path) +} + +// isSymlinkedLocalPath reports whether path is a symlink on the local +// filesystem. It is used for sensitive user-global registries that must not +// escape their expected on-disk roots via indirection. +func isSymlinkedLocalPath(path string) bool { + return isSymlinkedByLstat(localLstat, path) +} + +func isSymlinkedByLstat(lstat func(string) (core.FsFileInfo, error), path string) bool { + info, err := lstat(path) + if err != nil { + return false + } + return info.Mode()&core.ModeSymlink != 0 +} diff --git a/go/paths_test.go b/go/paths_test.go new file mode 100644 index 0000000..0f671de --- /dev/null +++ b/go/paths_test.go @@ -0,0 +1,90 @@ +package config + +import ( + "io/fs" + "time" + + core "dappco.re/go" + coreio "dappco.re/go/io" +) + +type symlinkMockMedium struct { + *coreio.MockMedium + symlinks map[string]bool +} + +func (m symlinkMockMedium) IsSymlink(path string) bool { + return m.symlinks[path] +} + +func TestPaths_isSafePathElement_Good(t *core.T) { + for _, part := range []string{ + "repo", + "config.yaml", + "core-dev", + "manifest_1", + } { + t.Run(part, func(t *core.T) { + core.AssertTrue(t, isSafePathElement(part)) + }) + } +} + +func TestPaths_isSafePathElement_Bad(t *core.T) { + for _, part := range []string{ + "", + ".", + "..", + } { + t.Run(part, func(t *core.T) { + core.AssertFalse(t, isSafePathElement(part)) + }) + } +} + +func TestPaths_isSafePathElement_Ugly(t *core.T) { + for _, part := range []string{ + "./repo", + "repo/../repo", + "repo//service", + "repo/./service", + "repo\\service", + } { + t.Run(part, func(t *core.T) { + core.AssertFalse(t, isSafePathElement(part)) + }) + } +} + +func TestPaths_isSymlinkedCoreDir_Good(t *core.T) { + coreDir := core.Path("repo", ".core") + m := symlinkMockMedium{ + MockMedium: coreio.NewMockMedium(), + symlinks: map[string]bool{coreDir: true}, + } + core.RequireNoError(t, m.EnsureDir(coreDir)) + + core.AssertTrue(t, isSymlinkedCoreDir(m, coreDir)) +} + +func TestPaths_isSymlinkedCoreDir_Bad(t *core.T) { + m := coreio.NewMockMedium() + coreDir := core.Path("repo", ".core") + + core.RequireNoError(t, m.EnsureDir(coreDir)) + + core.AssertFalse(t, isSymlinkedCoreDir(m, coreDir)) +} + +func TestPaths_isSymlinkedCoreDir_Ugly(t *core.T) { + previous := localLstat + localLstat = func(string) (fs.FileInfo, error) { + return coreio.NewFileInfo(".core", 0, fs.ModeSymlink, time.Now(), false), nil + } + t.Cleanup(func() { + localLstat = previous + }) + + core.AssertTrue(t, isSymlinkedCoreDir(coreio.Local, core.Path("local", ".core"))) + core.AssertFalse(t, isSymlinkedCoreDir(coreio.NewMockMedium(), core.Path("local", ".core"))) +} diff --git a/go/resolve.go b/go/resolve.go new file mode 100644 index 0000000..f948d73 --- /dev/null +++ b/go/resolve.go @@ -0,0 +1,588 @@ +package config + +import ( + core "dappco.re/go" + coreio "dappco.re/go/io" + coreerr "dappco.re/go/log" +) + +// FindProjectManifest searches upward from start for the nearest project-local +// .core manifest file with the given name. The walk stops at the filesystem +// root or a repository boundary and does not consult ~/.core/. +func FindProjectManifest(medium coreio.Medium, start string, name string) string { + if medium == nil { + medium = coreio.Local + } + for _, dir := range projectCoreDirs(medium, start) { + candidate := core.Path(dir, name) + if medium.Exists(candidate) { + return candidate + } + } + return "" +} + +// FindUserManifest returns the user-global ~/.core/{name} path when it exists. +// +// path := config.FindUserManifest(io.Local, config.FileAgent) +func FindUserManifest(medium coreio.Medium, name string) string { + return FindUserPath(medium, name) +} + +// FindUserPath returns the user-global ~/.core/... path when it exists. +// +// path := config.FindUserPath(io.Local, config.DirectoryImages, config.FileImagesManifest) +func FindUserPath(medium coreio.Medium, parts ...string) string { + if medium == nil { + medium = coreio.Local + } + if home := core.Env("DIR_HOME"); home != "" && isSymlinkedCoreDir(medium, core.Path(home, Directory)) { + return "" + } + candidate := userCorePath(parts...) + if candidate == "" { + return "" + } + if medium.Exists(candidate) { + return candidate + } + return "" +} + +// FindUserDirectory returns the user-global ~/.core// directory when it exists. +// +// dir := config.FindUserDirectory(io.Local, config.DirectoryImages) +func FindUserDirectory(medium coreio.Medium, name string) string { + return FindUserPath(medium, name) +} + +// FindUserImagesManifest returns the user-global ~/.core/images/manifest.json path when it exists. +// +// path := config.FindUserImagesManifest(io.Local) +func FindUserImagesManifest(medium coreio.Medium) string { + return FindUserPath(medium, DirectoryImages, FileImagesManifest) +} + +// FindUserImagesDirectory returns the user-global ~/.core/images/ directory +// when it exists. +// +// dir := config.FindUserImagesDirectory(io.Local) +func FindUserImagesDirectory(medium coreio.Medium) string { + return FindUserPath(medium, DirectoryImages) +} + +// FindUserSecretsDirectory returns the user-global ~/.core/secrets/ directory +// when it exists. +// +// dir := config.FindUserSecretsDirectory(io.Local) +func FindUserSecretsDirectory(medium coreio.Medium) string { + return FindUserPath(medium, DirectorySecrets) +} + +// FindUserDaemonsDirectory returns the user-global ~/.core/daemons/ directory +// when it exists. +// +// dir := config.FindUserDaemonsDirectory(io.Local) +func FindUserDaemonsDirectory(medium coreio.Medium) string { + return FindUserPath(medium, DirectoryDaemons) +} + +// FindUserWorkspacesDirectory returns the user-global ~/.core/workspaces/ +// directory when it exists. +// +// dir := config.FindUserWorkspacesDirectory(io.Local) +func FindUserWorkspacesDirectory(medium coreio.Medium) string { + return FindUserPath(medium, DirectoryWorkspaces) +} + +func projectCoreDirs(medium coreio.Medium, start string) []string { + if medium == nil { + medium = coreio.Local + } + + var dirs []string + dir := normalizeUpwardStart(medium, start) + for { + coreDir := core.Path(dir, Directory) + if medium.Exists(coreDir) && !isSymlinkedCoreDir(medium, coreDir) { + dirs = append(dirs, coreDir) + } + if medium.Exists(core.Path(dir, ".git")) { + break + } + parent := core.PathDir(dir) + if parent == dir { + break + } + dir = parent + } + return dirs +} + +// normalizeUpwardStart converts a file path to its containing directory before +// performing an upward discovery walk. Directory inputs are returned unchanged. +func normalizeUpwardStart(medium coreio.Medium, start string) string { + if medium == nil { + medium = coreio.Local + } + if start == "" { + return "." + } + if info, err := medium.Stat(start); err == nil && !info.IsDir() { + return core.PathDir(start) + } + return start +} + +// userCorePath joins a path under ~/.core/ using DIR_HOME as the home root. +// Empty parts are ignored so callers can build declarative registry paths. +func userCorePath(parts ...string) string { + home := core.Env("DIR_HOME") + if home == "" { + return "" + } + elems := make([]string, 0, 2+len(parts)) + elems = append(elems, home, Directory) + for _, part := range parts { + if part == "" { + continue + } + if !isSafePathElement(part) { + return "" + } + elems = append(elems, part) + } + return core.Path(elems...) +} + +// ResolveConfigManifest loads the nearest .core/config.yaml found while +// walking upward from start. Unlike the other manifest helpers, config.yaml is +// also valid at ~/.core/config.yaml, so it uses the broader manifest search. +func ResolveConfigManifest(medium coreio.Medium, start string) core.Result { + path := FindManifest(medium, start, FileConfig) + if path == "" { + return core.Fail(coreerr.E("config.ResolveConfigManifest", "no config manifest could be detected", nil)) + } + return New(WithMedium(medium), WithPath(path)) +} + +// FindConfigManifest returns the nearest config.yaml, including the user-global +// ~/.core/config.yaml fallback. +func FindConfigManifest(medium coreio.Medium, start string) string { + return FindManifest(medium, start, FileConfig) +} + +// FindBuildManifest returns the nearest project-local .core/build.yaml. +func FindBuildManifest(medium coreio.Medium, start string) string { + return FindProjectManifest(medium, start, FileBuild) +} + +// ResolveBuildManifest loads the nearest project-local .core/build.yaml. +func ResolveBuildManifest(medium coreio.Medium, start string) core.Result { + path := FindBuildManifest(medium, start) + if path == "" { + return core.Fail(coreerr.E("config.ResolveBuildManifest", "no build manifest could be detected", nil)) + } + + var build BuildManifest + if r := LoadManifest(medium, path, &build); !r.OK { + return r + } + return core.Ok(&build) +} + +// FindTestManifest returns the nearest project-local .core/test.yaml. +func FindTestManifest(medium coreio.Medium, start string) string { + return FindProjectManifest(medium, start, FileTest) +} + +// ResolveTestManifest loads the nearest project-local .core/test.yaml or falls +// back to auto-detecting a test command from the repository contents. +func ResolveTestManifest(medium coreio.Medium, start string) core.Result { + if path := FindTestManifest(medium, start); path != "" { + var test TestManifest + if r := LoadManifest(medium, path, &test); !r.OK { + return r + } + return core.Ok(&test) + } + + detectedResult := detectTestCommand(medium, start) + if !detectedResult.OK { + return detectedResult + } + detected := detectedResult.Value.(detectedTestCommand) + if detected.Found { + return core.Ok(&TestManifest{ + Version: 1, + Commands: []TestCommand{ + {Name: "test", Run: detected.Command}, + }, + }) + } + + return core.Fail(coreerr.E(callerResolveTestManifest, "no test command could be detected", nil)) +} + +// FindReleaseManifest returns the nearest project-local .core/release.yaml. +func FindReleaseManifest(medium coreio.Medium, start string) string { + return FindProjectManifest(medium, start, FileRelease) +} + +// ResolveReleaseManifest loads the nearest project-local .core/release.yaml. +func ResolveReleaseManifest(medium coreio.Medium, start string) core.Result { + path := FindReleaseManifest(medium, start) + if path == "" { + return core.Fail(coreerr.E("config.ResolveReleaseManifest", "no release manifest could be detected", nil)) + } + + var release ReleaseManifest + if r := LoadManifest(medium, path, &release); !r.OK { + return r + } + return core.Ok(&release) +} + +// FindRunManifest returns the nearest project-local .core/run.yaml. +func FindRunManifest(medium coreio.Medium, start string) string { + return FindProjectManifest(medium, start, FileRun) +} + +// ResolveRunManifest loads the nearest project-local .core/run.yaml. +func ResolveRunManifest(medium coreio.Medium, start string) core.Result { + path := FindRunManifest(medium, start) + if path == "" { + return core.Fail(coreerr.E("config.ResolveRunManifest", "no run manifest could be detected", nil)) + } + + var run RunManifest + if r := LoadManifest(medium, path, &run); !r.OK { + return r + } + return core.Ok(&run) +} + +// FindViewManifest returns the nearest project-local .core/view.yaml. +func FindViewManifest(medium coreio.Medium, start string) string { + return FindProjectManifest(medium, start, FileView) +} + +// ResolveViewManifest loads the nearest project-local .core/view.yaml. +func ResolveViewManifest(medium coreio.Medium, start string) core.Result { + path := FindViewManifest(medium, start) + if path == "" { + return core.Fail(coreerr.E("config.ResolveViewManifest", "no view manifest could be detected", nil)) + } + + var view ViewManifest + if r := LoadManifest(medium, path, &view); !r.OK { + return r + } + return core.Ok(&view) +} + +// FindPackageManifest returns the nearest project-local .core/manifest.yaml. +func FindPackageManifest(medium coreio.Medium, start string) string { + return FindProjectManifest(medium, start, FileManifest) +} + +// ResolvePackageManifest loads the nearest project-local .core/manifest.yaml. +func ResolvePackageManifest(medium coreio.Medium, start string) core.Result { + path := FindPackageManifest(medium, start) + if path == "" { + return core.Fail(coreerr.E("config.ResolvePackageManifest", "no package manifest could be detected", nil)) + } + + var pkg PackageManifest + if r := LoadManifest(medium, path, &pkg); !r.OK { + return r + } + return core.Ok(&pkg) +} + +// FindAgentManifest returns the user-global ~/.core/agent.yaml when it exists. +// +// path := config.FindAgentManifest(io.Local) +func FindAgentManifest(medium coreio.Medium) string { + return FindUserManifest(medium, FileAgent) +} + +// ResolveAgentManifest loads the user-global ~/.core/agent.yaml. +// +// agent, err := config.ResolveAgentManifest(io.Local) +func ResolveAgentManifest(medium coreio.Medium) core.Result { + path := FindAgentManifest(medium) + if path == "" { + return core.Fail(coreerr.E("config.ResolveAgentManifest", "no agent manifest could be detected", nil)) + } + + var agent AgentManifest + if r := LoadManifest(medium, path, &agent); !r.OK { + return r + } + return core.Ok(&agent) +} + +// FindZoneManifest returns the user-global ~/.core/zone.yaml when it exists. +// +// path := config.FindZoneManifest(io.Local) +func FindZoneManifest(medium coreio.Medium) string { + return FindUserManifest(medium, FileZone) +} + +// ResolveZoneManifest loads the user-global ~/.core/zone.yaml. +// +// zone, err := config.ResolveZoneManifest(io.Local) +func ResolveZoneManifest(medium coreio.Medium) core.Result { + path := FindZoneManifest(medium) + if path == "" { + return core.Fail(coreerr.E("config.ResolveZoneManifest", "no zone manifest could be detected", nil)) + } + + var zone ZoneManifest + if r := LoadManifest(medium, path, &zone); !r.OK { + return r + } + return core.Ok(&zone) +} + +// FindWorkspaceManifest returns the nearest project-local .core/workspace.yaml. +func FindWorkspaceManifest(medium coreio.Medium, start string) string { + return FindProjectManifest(medium, start, FileWorkspace) +} + +// ResolveWorkspaceManifest loads the nearest project-local .core/workspace.yaml. +func ResolveWorkspaceManifest(medium coreio.Medium, start string) core.Result { + path := FindWorkspaceManifest(medium, start) + if path == "" { + return core.Fail(coreerr.E("config.ResolveWorkspaceManifest", "no workspace manifest could be detected", nil)) + } + + var ws WorkspaceManifest + if r := LoadManifest(medium, path, &ws); !r.OK { + return r + } + return core.Ok(&ws) +} + +// FindIDEManifest returns the nearest project-local .core/ide.yaml. +func FindIDEManifest(medium coreio.Medium, start string) string { + return FindProjectManifest(medium, start, FileIDE) +} + +// ResolveIDEManifest loads the nearest project-local .core/ide.yaml. +func ResolveIDEManifest(medium coreio.Medium, start string) core.Result { + path := FindIDEManifest(medium, start) + if path == "" { + return core.Fail(coreerr.E("config.ResolveIDEManifest", "no ide manifest could be detected", nil)) + } + + var ide IDEManifest + if r := LoadManifest(medium, path, &ide); !r.OK { + return r + } + return core.Ok(&ide) +} + +// FindLinuxKitDirectory returns the nearest project-local .core/linuxkit/ +// directory. +// +// dir := config.FindLinuxKitDirectory(io.Local, cwd) +func FindLinuxKitDirectory(medium coreio.Medium, start string) string { + return findProjectDirectory(medium, start, LinuxKitDirectory) +} + +// FindLinuxKitManifest returns the nearest project-local .core/linuxkit/core-dev.yml +// path when it exists. +// +// path := config.FindLinuxKitManifest(io.Local, cwd) +func FindLinuxKitManifest(medium coreio.Medium, start string) string { + if medium == nil { + medium = coreio.Local + } + for _, dir := range projectCoreDirs(medium, start) { + candidate := core.Path(dir, LinuxKitDirectory, FileLinuxKit) + if medium.Exists(candidate) { + return candidate + } + } + return "" +} + +// ResolveLinuxKitManifest loads the nearest project-local +// .core/linuxkit/core-dev.yml into a generic map. LinuxKit files are part of +// the .core registry but intentionally stay schema-light in this package. +// +// lk, err := config.ResolveLinuxKitManifest(io.Local, cwd) +func ResolveLinuxKitManifest(medium coreio.Medium, start string) core.Result { + path := FindLinuxKitManifest(medium, start) + if path == "" { + return core.Fail(coreerr.E("config.ResolveLinuxKitManifest", "no linuxkit manifest could be detected", nil)) + } + + manifestResult := Load(medium, path) + if !manifestResult.OK { + return manifestResult + } + return manifestResult +} + +// findProjectDirectory returns the nearest project-local .core/{name}/ +// directory while walking upward from start. +func findProjectDirectory(medium coreio.Medium, start string, name string) string { + if medium == nil { + medium = coreio.Local + } + if !isSafePathElement(name) { + return "" + } + for _, dir := range projectCoreDirs(medium, start) { + candidate := core.Path(dir, name) + if medium.Exists(candidate) { + return candidate + } + } + return "" +} + +// WorkspaceSandboxRoot returns the project-local sandbox root used for agent workspaces. +// +// root := config.WorkspaceSandboxRoot("my-repo", "dev") +func WorkspaceSandboxRoot(repo, branch string) string { + return WorkspaceSandboxPath(repo, branch) +} + +// WorkspaceSandboxSourcePath returns a path inside the checked-out source tree +// for a sandboxed workspace. +// +// src := config.WorkspaceSandboxSourcePath("my-repo", "dev", "app", "main.go") +func WorkspaceSandboxSourcePath(repo, branch string, parts ...string) string { + return WorkspaceSandboxPath(repo, branch, append([]string{WorkspaceSourceDirectory}, parts...)...) +} + +// WorkspaceSandboxMetaPath returns a path inside the sandbox metadata +// directory. +// +// meta := config.WorkspaceSandboxMetaPath("my-repo", "dev", "status.json") +func WorkspaceSandboxMetaPath(repo, branch string, parts ...string) string { + return WorkspaceSandboxPath(repo, branch, append([]string{WorkspaceMetaDirectory}, parts...)...) +} + +// WorkspaceSandboxInstructionsPath returns the agent instruction file for a +// sandboxed workspace. +// +// path := config.WorkspaceSandboxInstructionsPath("my-repo", "dev") +func WorkspaceSandboxInstructionsPath(repo, branch string) string { + return WorkspaceSandboxPath(repo, branch, WorkspaceInstructionsFile) +} + +// WorkspaceSandboxPath returns a path inside the project-local sandbox workspace tree. +// +// meta := config.WorkspaceSandboxPath("my-repo", "dev", ".meta", "status") +func WorkspaceSandboxPath(repo, branch string, parts ...string) string { + elems := []string{Directory, WorkspaceDirectory} + for _, part := range []string{repo, branch} { + if part == "" { + continue + } + if !isSafePathElement(part) { + return "" + } + elems = append(elems, part) + } + for _, part := range parts { + if part == "" { + continue + } + if !isSafePathElement(part) { + return "" + } + elems = append(elems, part) + } + return core.Path(elems...) +} + +// FindReposManifest returns the nearest workspace-root .core/repos.yaml while +// walking upward from start without stopping at repository boundaries. This +// keeps repos.yaml at the shared workspace root rather than under ~/.core/. +func FindReposManifest(medium coreio.Medium, start string) string { + if medium == nil { + medium = coreio.Local + } + + dir := core.CleanPath(normalizeUpwardStart(medium, start), string(core.PathSeparator)) + if dir == "" { + dir = "." + } + for { + candidate := core.Path(dir, Directory, FileRepos) + if medium.Exists(candidate) && !isSymlinkedCoreDir(medium, core.Path(dir, Directory)) { + return candidate + } + parent := core.PathDir(dir) + if parent == dir { + break + } + dir = parent + } + + if home := core.Env("DIR_HOME"); home != "" { + // Fallback to the conventional ~/Code/.core/repos.yaml location used by + // the federated monorepo workspace convention when no repos.yaml is found + // in the upward walk. + coreDir := core.Path(home, "Code", Directory) + candidate := core.Path(coreDir, FileRepos) + if medium.Exists(candidate) && !isSymlinkedCoreDir(medium, coreDir) { + return candidate + } + } + return "" +} + +// FindWorkspaceRegistryManifest returns the nearest workspace-root +// .core/repos.yaml. It is an alias for FindReposManifest and exists so callers +// can use the workspace-registry naming from the RFC without changing the +// underlying storage layout. +func FindWorkspaceRegistryManifest(medium coreio.Medium, start string) string { + return FindReposManifest(medium, start) +} + +// ResolveReposManifest loads the workspace-root .core/repos.yaml. +func ResolveReposManifest(medium coreio.Medium, start string) core.Result { + path := FindReposManifest(medium, start) + if path == "" { + return core.Fail(coreerr.E("config.ResolveReposManifest", "no repos manifest could be detected", nil)) + } + + var repos ReposManifest + if r := LoadManifest(medium, path, &repos); !r.OK { + return r + } + return core.Ok(&repos) +} + +// ResolveWorkspaceRegistryManifest loads the workspace-root .core/repos.yaml. +// It mirrors ResolveReposManifest for callers that prefer the RFC-aligned +// workspace-registry naming. +func ResolveWorkspaceRegistryManifest(medium coreio.Medium, start string) core.Result { + return ResolveReposManifest(medium, start) +} + +// FindPHPManifest returns the nearest project-local .core/php.yaml. +func FindPHPManifest(medium coreio.Medium, start string) string { + return FindProjectManifest(medium, start, FilePHP) +} + +// ResolvePHPManifest loads the nearest project-local .core/php.yaml. +func ResolvePHPManifest(medium coreio.Medium, start string) core.Result { + path := FindPHPManifest(medium, start) + if path == "" { + return core.Fail(coreerr.E("config.ResolvePHPManifest", "no php manifest could be detected", nil)) + } + + var php PHPManifest + if r := LoadManifest(medium, path, &php); !r.OK { + return r + } + return core.Ok(&php) +} diff --git a/go/resolve_example_test.go b/go/resolve_example_test.go new file mode 100644 index 0000000..f38d1d2 --- /dev/null +++ b/go/resolve_example_test.go @@ -0,0 +1,377 @@ +package config + +import ( + core "dappco.re/go" + coreio "dappco.re/go/io" + "gopkg.in/yaml.v3" +) + +func exampleResolveMedium() (*coreio.MockMedium, string, func()) { + m := coreio.NewMockMedium() + workspace := core.PathJoin("/", "workspace") + root := core.PathJoin(workspace, "repo") + child := core.PathJoin(root, "service") + home := core.Env("DIR_HOME") + var cleanup func() + + for _, dir := range []string{ + core.PathJoin(workspace, Directory), + core.PathJoin(root, Directory), + core.PathJoin(root, Directory, LinuxKitDirectory), + core.PathJoin(root, ".git"), + child, + core.PathJoin(home, Directory), + core.PathJoin(home, Directory, DirectoryImages), + core.PathJoin(home, Directory, DirectorySecrets), + core.PathJoin(home, Directory, DirectoryDaemons), + core.PathJoin(home, Directory, DirectoryWorkspaces), + } { + _ = m.EnsureDir(dir) + } + + view, _ := exampleSignedViewManifest() + viewBody, _ := yaml.Marshal(&view) + pkg, trustCleanup := exampleSignedPackageManifest() + cleanup = trustCleanup + pkgBody, _ := yaml.Marshal(&pkg) + + _ = m.Write(core.PathJoin(root, Directory, FileConfig), "app:\n name: project\n") + _ = m.Write(core.PathJoin(root, Directory, FileBuild), "version: 1\nproject:\n name: app\n main: ./cmd/app\nbuild:\n flags: [-trimpath]\ntargets:\n - linux/amd64\n") + _ = m.Write(core.PathJoin(root, Directory, FileTest), "version: 1\ncommands:\n - name: unit\n run: go test ./...\n") + _ = m.Write(core.PathJoin(root, Directory, FileRelease), "version: 1\narchive:\n format: tar.gz\n include:\n - README.md\nchecksums: true\ngithub:\n draft: false\n prerelease: false\nchangelog:\n include:\n - feat\n") + _ = m.Write(core.PathJoin(root, Directory, FileRun), "version: 1\nservices:\n - name: db\n image: postgres:16\n port: 5432\ndev:\n command: go run ./cmd/app\n port: 8080\n") + _ = m.Write(core.PathJoin(root, Directory, FileView), string(viewBody)) + _ = m.Write(core.PathJoin(root, Directory, FileManifest), string(pkgBody)) + _ = m.Write(core.PathJoin(root, Directory, FileWorkspace), "version: 1\ndependencies:\n - core-go\nactive: core-go\npackages_dir: ./packages\n") + _ = m.Write(core.PathJoin(root, Directory, FileIDE), "version: 1\neditor: nvim\n") + _ = m.Write(core.PathJoin(root, Directory, FilePHP), "version: 1\nserver:\n type: php-fpm\n") + _ = m.Write(core.PathJoin(root, Directory, LinuxKitDirectory, FileLinuxKit), "kernel:\n image: linuxkit/kernel:6.6.0\n") + _ = m.Write(core.PathJoin(workspace, Directory, FileRepos), "version: 1\norg: core\nrepos:\n - path: core/config\n remote: ssh://example/core/config.git\n") + _ = m.Write(core.PathJoin(home, Directory, FileAgent), "daemon:\n enabled: true\nagents:\n codex:\n total: 1\n") + _ = m.Write(core.PathJoin(home, Directory, FileZone), "zone:\n name: alpha\n identity: alpha-id\n") + _ = SaveImagesManifest(m, core.PathJoin(home, Directory, DirectoryImages, FileImagesManifest), &ImagesManifest{Images: map[string]ImageInfo{}}) + + return m, child, cleanup +} + +func ExampleFindProjectManifest() { + m, child, cleanup := exampleResolveMedium() + defer cleanup() + core.Println(core.PathBase(FindProjectManifest(m, child, FileBuild))) + // Output: build.yaml +} + +func ExampleFindUserManifest() { + m, _, cleanup := exampleResolveMedium() + defer cleanup() + core.Println(core.PathBase(FindUserManifest(m, FileAgent))) + // Output: agent.yaml +} + +func ExampleFindUserPath() { + m, _, cleanup := exampleResolveMedium() + defer cleanup() + core.Println(core.PathBase(FindUserPath(m, FileAgent))) + // Output: agent.yaml +} + +func ExampleFindUserDirectory() { + m, _, cleanup := exampleResolveMedium() + defer cleanup() + core.Println(core.PathBase(FindUserDirectory(m, DirectoryImages))) + // Output: images +} + +func ExampleFindUserImagesManifest() { + m, _, cleanup := exampleResolveMedium() + defer cleanup() + core.Println(core.PathBase(FindUserImagesManifest(m))) + // Output: manifest.json +} + +func ExampleFindUserImagesDirectory() { + m, _, cleanup := exampleResolveMedium() + defer cleanup() + core.Println(core.PathBase(FindUserImagesDirectory(m))) + // Output: images +} + +func ExampleFindUserSecretsDirectory() { + m, _, cleanup := exampleResolveMedium() + defer cleanup() + core.Println(core.PathBase(FindUserSecretsDirectory(m))) + // Output: secrets +} + +func ExampleFindUserDaemonsDirectory() { + m, _, cleanup := exampleResolveMedium() + defer cleanup() + core.Println(core.PathBase(FindUserDaemonsDirectory(m))) + // Output: daemons +} + +func ExampleFindUserWorkspacesDirectory() { + m, _, cleanup := exampleResolveMedium() + defer cleanup() + core.Println(core.PathBase(FindUserWorkspacesDirectory(m))) + // Output: workspaces +} + +func ExampleResolveConfigManifest() { + m, child, cleanup := exampleResolveMedium() + defer cleanup() + cfg, err := configResult(ResolveConfigManifest(m, child)) + var name string + _ = cfg.Get("app.name", &name) + core.Println(err == nil, name) + // Output: true project +} + +func ExampleFindConfigManifest() { + m, child, cleanup := exampleResolveMedium() + defer cleanup() + core.Println(core.PathBase(FindConfigManifest(m, child))) + // Output: config.yaml +} + +func ExampleFindBuildManifest() { + m, child, cleanup := exampleResolveMedium() + defer cleanup() + core.Println(core.PathBase(FindBuildManifest(m, child))) + // Output: build.yaml +} + +func ExampleResolveBuildManifest() { + m, child, cleanup := exampleResolveMedium() + defer cleanup() + build, err := buildManifestResult(ResolveBuildManifest(m, child)) + core.Println(err == nil, build.Project.Name) + // Output: true app +} + +func ExampleFindTestManifest() { + m, child, cleanup := exampleResolveMedium() + defer cleanup() + core.Println(core.PathBase(FindTestManifest(m, child))) + // Output: test.yaml +} + +func ExampleResolveTestManifest() { + m, child, cleanup := exampleResolveMedium() + defer cleanup() + test, err := testManifestResult(ResolveTestManifest(m, child)) + core.Println(err == nil, test.Commands[0].Name) + // Output: true unit +} + +func ExampleFindReleaseManifest() { + m, child, cleanup := exampleResolveMedium() + defer cleanup() + core.Println(core.PathBase(FindReleaseManifest(m, child))) + // Output: release.yaml +} + +func ExampleResolveReleaseManifest() { + m, child, cleanup := exampleResolveMedium() + defer cleanup() + release, err := releaseManifestResult(ResolveReleaseManifest(m, child)) + core.Println(err == nil, release.Archive.Format) + // Output: true tar.gz +} + +func ExampleFindRunManifest() { + m, child, cleanup := exampleResolveMedium() + defer cleanup() + core.Println(core.PathBase(FindRunManifest(m, child))) + // Output: run.yaml +} + +func ExampleResolveRunManifest() { + m, child, cleanup := exampleResolveMedium() + defer cleanup() + run, err := runManifestResult(ResolveRunManifest(m, child)) + core.Println(err == nil, run.Dev.Port) + // Output: true 8080 +} + +func ExampleFindViewManifest() { + m, child, cleanup := exampleResolveMedium() + defer cleanup() + core.Println(core.PathBase(FindViewManifest(m, child))) + // Output: view.yaml +} + +func ExampleResolveViewManifest() { + m, child, cleanup := exampleResolveMedium() + defer cleanup() + view, err := viewManifestResult(ResolveViewManifest(m, child)) + core.Println(err == nil, view.Code) + // Output: true app +} + +func ExampleFindPackageManifest() { + m, child, cleanup := exampleResolveMedium() + defer cleanup() + core.Println(core.PathBase(FindPackageManifest(m, child))) + // Output: manifest.yaml +} + +func ExampleResolvePackageManifest() { + m, child, cleanup := exampleResolveMedium() + defer cleanup() + pkg, err := packageManifestResult(ResolvePackageManifest(m, child)) + core.Println(err == nil, pkg.Code) + // Output: true go-config +} + +func ExampleFindAgentManifest() { + m, _, cleanup := exampleResolveMedium() + defer cleanup() + core.Println(core.PathBase(FindAgentManifest(m))) + // Output: agent.yaml +} + +func ExampleResolveAgentManifest() { + m, _, cleanup := exampleResolveMedium() + defer cleanup() + agent, err := agentManifestResult(ResolveAgentManifest(m)) + core.Println(err == nil, agent.Daemon.Enabled) + // Output: true true +} + +func ExampleFindZoneManifest() { + m, _, cleanup := exampleResolveMedium() + defer cleanup() + core.Println(core.PathBase(FindZoneManifest(m))) + // Output: zone.yaml +} + +func ExampleResolveZoneManifest() { + m, _, cleanup := exampleResolveMedium() + defer cleanup() + zone, err := zoneManifestResult(ResolveZoneManifest(m)) + core.Println(err == nil, zone.Zone.Name) + // Output: true alpha +} + +func ExampleFindWorkspaceManifest() { + m, child, cleanup := exampleResolveMedium() + defer cleanup() + core.Println(core.PathBase(FindWorkspaceManifest(m, child))) + // Output: workspace.yaml +} + +func ExampleResolveWorkspaceManifest() { + m, child, cleanup := exampleResolveMedium() + defer cleanup() + workspace, err := workspaceManifestResult(ResolveWorkspaceManifest(m, child)) + core.Println(err == nil, workspace.Active) + // Output: true core-go +} + +func ExampleFindIDEManifest() { + m, child, cleanup := exampleResolveMedium() + defer cleanup() + core.Println(core.PathBase(FindIDEManifest(m, child))) + // Output: ide.yaml +} + +func ExampleResolveIDEManifest() { + m, child, cleanup := exampleResolveMedium() + defer cleanup() + ide, err := ideManifestResult(ResolveIDEManifest(m, child)) + core.Println(err == nil, ide.Editor) + // Output: true nvim +} + +func ExampleFindLinuxKitDirectory() { + m, child, cleanup := exampleResolveMedium() + defer cleanup() + core.Println(core.PathBase(FindLinuxKitDirectory(m, child))) + // Output: linuxkit +} + +func ExampleFindLinuxKitManifest() { + m, child, cleanup := exampleResolveMedium() + defer cleanup() + core.Println(core.PathBase(FindLinuxKitManifest(m, child))) + // Output: core-dev.yml +} + +func ExampleResolveLinuxKitManifest() { + m, child, cleanup := exampleResolveMedium() + defer cleanup() + manifest, err := linuxKitManifestResult(ResolveLinuxKitManifest(m, child)) + core.Println(err == nil, manifest["kernel"].(map[string]any)["image"]) + // Output: true linuxkit/kernel:6.6.0 +} + +func ExampleWorkspaceSandboxRoot() { + core.Println(core.PathBase(WorkspaceSandboxRoot("repo", "dev"))) + // Output: dev +} + +func ExampleWorkspaceSandboxSourcePath() { + core.Println(core.PathBase(WorkspaceSandboxSourcePath("repo", "dev", "app", "main.go"))) + // Output: main.go +} + +func ExampleWorkspaceSandboxMetaPath() { + core.Println(core.PathBase(WorkspaceSandboxMetaPath("repo", "dev", "status.json"))) + // Output: status.json +} + +func ExampleWorkspaceSandboxInstructionsPath() { + core.Println(core.PathBase(WorkspaceSandboxInstructionsPath("repo", "dev"))) + // Output: CODEX.md +} + +func ExampleWorkspaceSandboxPath() { + core.Println(core.PathBase(WorkspaceSandboxPath("repo", "dev", ".meta", "status"))) + // Output: status +} + +func ExampleFindReposManifest() { + m, child, cleanup := exampleResolveMedium() + defer cleanup() + core.Println(core.PathBase(FindReposManifest(m, child))) + // Output: repos.yaml +} + +func ExampleFindWorkspaceRegistryManifest() { + m, child, cleanup := exampleResolveMedium() + defer cleanup() + core.Println(core.PathBase(FindWorkspaceRegistryManifest(m, child))) + // Output: repos.yaml +} + +func ExampleResolveReposManifest() { + m, child, cleanup := exampleResolveMedium() + defer cleanup() + repos, err := reposManifestResult(ResolveReposManifest(m, child)) + core.Println(err == nil, repos.Org) + // Output: true core +} + +func ExampleResolveWorkspaceRegistryManifest() { + m, child, cleanup := exampleResolveMedium() + defer cleanup() + repos, err := reposManifestResult(ResolveWorkspaceRegistryManifest(m, child)) + core.Println(err == nil, repos.Org) + // Output: true core +} + +func ExampleFindPHPManifest() { + m, child, cleanup := exampleResolveMedium() + defer cleanup() + core.Println(core.PathBase(FindPHPManifest(m, child))) + // Output: php.yaml +} + +func ExampleResolvePHPManifest() { + m, child, cleanup := exampleResolveMedium() + defer cleanup() + php, err := phpManifestResult(ResolvePHPManifest(m, child)) + core.Println(err == nil, php.Server.Type) + // Output: true php-fpm +} diff --git a/go/resolve_test.go b/go/resolve_test.go new file mode 100644 index 0000000..d1f6061 --- /dev/null +++ b/go/resolve_test.go @@ -0,0 +1,1603 @@ +package config + +import ( + "crypto/ed25519" + "encoding/base64" + "encoding/hex" + + core "dappco.re/go" + coreio "dappco.re/go/io" + "gopkg.in/yaml.v3" +) + +const ( + resolveTestWorkspaceReposYAML = "version: 1\norg: host-uk\nrepos:\n - path: core/go\n remote: ssh://forge.example/core/go.git\n" + resolveTestFailedToParseManifest = "failed to parse manifest" + resolveTestBrokenVersionYAML = "version: [broken" + resolveTestEmptyReposYAML = "version: 1\norg: host-uk\nrepos: []\n" + resolveTestMainGo = "main.go" + resolveTestStatusJSON = "status.json" + resolveTestParentRepoPath = "../repo" +) + +type falseExistsMedium struct { + *coreio.MockMedium +} + +func (m falseExistsMedium) Exists(string) bool { + return false +} + +func TestResolve_FindConfigManifest_Good(t *core.T) { + m := coreio.NewMockMedium() + tmp := t.TempDir() + base := core.PathJoin(tmp, "workspace") + repo := core.PathJoin(base, "repo") + child := core.PathJoin(repo, "service") + + for _, dir := range []string{ + core.PathJoin(base, ".core"), + core.PathJoin(repo, ".core"), + core.PathJoin(repo, ".git"), + child, + } { + core.AssertNoError(t, m.EnsureDir(dir)) + } + + globalConfig := core.PathJoin(core.Env("DIR_HOME"), ".core", FileConfig) + projectConfig := core.PathJoin(repo, ".core", FileConfig) + workspaceRepos := core.PathJoin(base, ".core", FileRepos) + core.AssertNoError(t, m.Write(globalConfig, "app:\n name: global\n")) + core.AssertNoError(t, m.Write(projectConfig, "app:\n name: project\n")) + core.AssertNoError(t, m.Write(workspaceRepos, resolveTestWorkspaceReposYAML)) + + core.AssertEqual(t, projectConfig, FindConfigManifest(m, child)) +} + +func TestResolve_FindConfigManifest_Bad(t *core.T) { + m := falseExistsMedium{coreio.NewMockMedium()} + start := t.TempDir() + got := FindConfigManifest(m, start) + core.AssertEmpty(t, got) +} + +func TestResolve_FindConfigManifest_Ugly(t *core.T) { + m := falseExistsMedium{coreio.NewMockMedium()} + start := core.PathJoin(t.TempDir(), "missing", "service") + + core.AssertEmpty(t, FindConfigManifest(m, start)) +} + +func TestResolve_FindProjectManifest_Good(t *core.T) { + m := coreio.NewMockMedium() + tmp := t.TempDir() + base := core.PathJoin(tmp, "workspace") + repo := core.PathJoin(base, "repo") + child := core.PathJoin(repo, "service") + + for _, dir := range []string{ + core.PathJoin(base, ".core"), + core.PathJoin(repo, ".core"), + core.PathJoin(repo, ".git"), + child, + } { + core.AssertNoError(t, m.EnsureDir(dir)) + } + core.AssertNoError(t, m.EnsureDir(core.PathJoin(base, ".core"))) + + files := map[string]string{ + FileBuild: "name: core\noutput: dist\ntargets:\n - linux/amd64\n", + FileRelease: "version: 1\narchive:\n format: tar.gz\n include:\n - README.md\nchecksums: true\ngithub:\n draft: false\n prerelease: false\nchangelog:\n include:\n - feat\n", + FileRun: "version: 1\nservices:\n - name: db\n image: postgres:16\n port: 5432\ndev:\n command: php artisan serve\n port: 8000\n watch:\n - app/\n", + FileView: "code: photo-browser\nname: Photo Browser\nsign: " + base64.StdEncoding.EncodeToString(make([]byte, ed25519.SignatureSize)) + "\npermissions:\n clipboard: true\n", + FileManifest: packageManifestFixture(t), + FileWorkspace: "version: 1\ndependencies:\n - core-php\nactive: core-php\npackages_dir: ./packages\n", + FileIDE: "version: 1\neditor: nvim\n", + } + + for name, content := range files { + core.AssertNoError(t, m.Write(core.PathJoin(repo, ".core", name), content)) + } + core.AssertNoError(t, m.Write(core.PathJoin(base, ".core", FileBuild), "name: external\noutput: ext\n")) + core.AssertNoError(t, m.Write(core.PathJoin(base, ".core", FileRepos), resolveTestWorkspaceReposYAML)) + core.AssertEqual(t, core.PathJoin(repo, ".core", FileBuild), FindProjectManifest(m, child, FileBuild)) + + cases := []struct { + name string + path string + got string + }{ + {name: "build", path: core.PathJoin(repo, ".core", FileBuild), got: FindBuildManifest(m, child)}, + {name: "release", path: core.PathJoin(repo, ".core", FileRelease), got: FindReleaseManifest(m, child)}, + {name: "run", path: core.PathJoin(repo, ".core", FileRun), got: FindRunManifest(m, child)}, + {name: "view", path: core.PathJoin(repo, ".core", FileView), got: FindViewManifest(m, child)}, + {name: "manifest", path: core.PathJoin(repo, ".core", FileManifest), got: FindPackageManifest(m, child)}, + {name: "workspace", path: core.PathJoin(repo, ".core", FileWorkspace), got: FindWorkspaceManifest(m, child)}, + {name: "ide", path: core.PathJoin(repo, ".core", FileIDE), got: FindIDEManifest(m, child)}, + {name: "repos", path: core.PathJoin(base, ".core", FileRepos), got: FindReposManifest(m, child)}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *core.T) { + core.AssertEqual(t, tc.path, tc.got) + }) + } +} + +func TestResolve_FindLinuxKitManifest_Good(t *core.T) { + m := coreio.NewMockMedium() + tmp := t.TempDir() + repo := core.PathJoin(tmp, "repo") + child := core.PathJoin(repo, "service") + + for _, dir := range []string{ + core.PathJoin(repo, ".core"), + core.PathJoin(repo, ".core", LinuxKitDirectory), + core.PathJoin(repo, ".git"), + child, + } { + core.AssertNoError(t, m.EnsureDir(dir)) + } + lkPath := core.PathJoin(repo, ".core", LinuxKitDirectory, FileLinuxKit) + core.AssertNoError(t, m.Write(lkPath, "version: 1\n")) + + core.AssertEqual(t, lkPath, FindLinuxKitManifest(m, child)) +} + +func TestResolve_ResolveLinuxKitManifest_Good(t *core.T) { + m := coreio.NewMockMedium() + tmp := t.TempDir() + repo := core.PathJoin(tmp, "repo") + child := core.PathJoin(repo, "service") + + for _, dir := range []string{ + core.PathJoin(repo, ".core"), + core.PathJoin(repo, ".core", LinuxKitDirectory), + core.PathJoin(repo, ".git"), + child, + } { + core.AssertNoError(t, m.EnsureDir(dir)) + } + core.AssertNoError(t, m.Write(core.PathJoin(repo, ".core", LinuxKitDirectory, FileLinuxKit), "kernel:\n image: linuxkit/kernel:6.6.0\n")) + + manifest, err := linuxKitManifestResult(ResolveLinuxKitManifest(m, child)) + core.RequireNoError(t, err) + core.AssertEqual(t, "linuxkit/kernel:6.6.0", manifest["kernel"].(map[string]any)["image"]) +} + +func TestResolve_ResolveLinuxKitManifest_Bad(t *core.T) { + m := coreio.NewMockMedium() + + manifest, err := linuxKitManifestResult(ResolveLinuxKitManifest(m, t.TempDir())) + core.AssertNil(t, manifest) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "no linuxkit manifest could be detected") +} + +func TestResolve_ResolveTestManifest_Good(t *core.T) { + tests := []struct { + name string + filename string + content string + expected string + }{ + { + name: "core manifest", + filename: FileTest, + content: "version: 1\ncommands:\n - name: unit\n run: vendor/bin/pest --parallel\n", + expected: "vendor/bin/pest --parallel", + }, + { + name: "composer fallback", + filename: "composer.json", + content: `{"scripts":{}}`, + expected: "composer test", + }, + { + name: "package script", + filename: "package.json", + content: `{"scripts":{"test":"npm run test:unit"}}`, + expected: "npm run test:unit", + }, + { + name: "go module", + filename: "go.mod", + content: "module example.com/repo\n", + expected: "core go qa", + }, + { + name: "pytest ini", + filename: "pytest.ini", + content: "[pytest]\n", + expected: "pytest", + }, + { + name: "pyproject", + filename: "pyproject.toml", + content: "[tool.pytest.ini_options]\n", + expected: "pytest", + }, + { + name: "taskfile", + filename: "Taskfile.yaml", + content: "version: '3'\n", + expected: "task test", + }, + { + name: "taskfile-yml", + filename: "Taskfile.yml", + content: "version: '3'\n", + expected: "task test", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *core.T) { + m := coreio.NewMockMedium() + root := core.PathJoin("/", "repo", tc.name) + child := core.PathJoin(root, "service") + + core.AssertNoError(t, m.EnsureDir(core.PathJoin(root, ".core"))) + core.AssertNoError(t, m.EnsureDir(child)) + core.AssertNoError(t, m.EnsureDir(core.PathJoin(root, ".git"))) + + path := core.PathJoin(root, tc.filename) + if tc.filename == FileTest { + path = core.PathJoin(root, ".core", FileTest) + } + core.AssertNoError(t, m.Write(path, tc.content)) + + manifest, err := testManifestResult(ResolveTestManifest(m, child)) + core.AssertNoError(t, err) + core.AssertNotNil(t, manifest) + core.AssertNotEmpty(t, manifest.Commands) + core.AssertEqual(t, tc.expected, manifest.Commands[0].Run) + }) + } +} + +func TestResolve_ResolveTestManifest_Bad(t *core.T) { + m := coreio.NewMockMedium() + root := core.PathJoin("/", "repo", "bad") + child := core.PathJoin(root, "service") + + core.AssertNoError(t, m.EnsureDir(core.PathJoin(root, ".git"))) + core.AssertNoError(t, m.EnsureDir(child)) + core.AssertNoError(t, m.Write(core.PathJoin(root, "package.json"), `{"scripts":{"test":123}}`)) + + manifest, err := testManifestResult(ResolveTestManifest(m, child)) + core.AssertNil(t, manifest) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "invalid npm test script") +} + +func TestResolve_ResolveTestManifest_Ugly(t *core.T) { + m := coreio.NewMockMedium() + root := core.PathJoin("/", "repo", "ugly") + + core.AssertNoError(t, m.EnsureDir(core.PathJoin(root, ".git"))) + + manifest, err := testManifestResult(ResolveTestManifest(m, root)) + core.AssertNil(t, manifest) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "no test command could be detected") +} + +func TestResolve_FindProjectManifest_Bad(t *core.T) { + m := coreio.NewMockMedium() + tmp := t.TempDir() + base := core.PathJoin(tmp, "workspace") + repo := core.PathJoin(base, "repo") + child := core.PathJoin(repo, "service") + + for _, dir := range []string{ + core.PathJoin(base, ".core"), + core.PathJoin(repo, ".core"), + core.PathJoin(repo, ".git"), + child, + } { + core.AssertNoError(t, m.EnsureDir(dir)) + } + + core.AssertEmpty(t, FindProjectManifest(m, child, FileBuild)) + core.AssertEmpty(t, FindBuildManifest(m, child)) + core.AssertEmpty(t, FindReleaseManifest(m, child)) + core.AssertEmpty(t, FindRunManifest(m, child)) + core.AssertEmpty(t, FindViewManifest(m, child)) + core.AssertEmpty(t, FindPackageManifest(m, child)) + core.AssertEmpty(t, FindReposManifest(m, child)) + core.AssertEmpty(t, FindWorkspaceManifest(m, child)) + core.AssertEmpty(t, FindIDEManifest(m, child)) +} + +func TestResolve_FindProjectManifest_Ugly(t *core.T) { + // Nil medium falls back to the local filesystem; with no .core tree the + // project-local wrappers should still return an empty path instead of panicking. + // Repos are resolved from the shared workspace root and may legitimately + // exist on the host machine, so this test does not assert on repos.yaml. + start := core.PathJoin(t.TempDir(), "missing", "service") + core.AssertEmpty(t, FindProjectManifest(nil, start, FileBuild)) + core.AssertEmpty(t, FindBuildManifest(nil, start)) + core.AssertEmpty(t, FindReleaseManifest(nil, start)) + core.AssertEmpty(t, FindRunManifest(nil, start)) + core.AssertEmpty(t, FindViewManifest(nil, start)) + core.AssertEmpty(t, FindPackageManifest(nil, start)) + core.AssertEmpty(t, FindIDEManifest(nil, start)) +} + +func TestResolve_FindUserPath_Good(t *core.T) { + m := coreio.NewMockMedium() + home := core.Env("DIR_HOME") + + for _, dir := range []string{ + core.PathJoin(home, ".core"), + core.PathJoin(home, ".core", DirectoryImages), + core.PathJoin(home, ".core", DirectorySecrets), + core.PathJoin(home, ".core", DirectoryDaemons), + core.PathJoin(home, ".core", DirectoryWorkspaces), + } { + core.RequireNoError(t, m.EnsureDir(dir)) + } + + core.RequireNoError(t, m.Write(core.PathJoin(home, ".core", FileAgent), "daemon:\n enabled: true\n")) + core.RequireNoError(t, m.Write(core.PathJoin(home, ".core", FileZone), "zone:\n name: alpha\n identity: '@alpha@lthn'\n")) + core.RequireNoError(t, m.Write(core.PathJoin(home, ".core", DirectoryImages, FileImagesManifest), "{\"images\":{}}")) + + core.AssertEqual(t, core.PathJoin(home, ".core", FileAgent), FindUserManifest(m, FileAgent)) + core.AssertEqual(t, core.PathJoin(home, ".core", FileAgent), FindAgentManifest(m)) + core.AssertEqual(t, core.PathJoin(home, ".core", FileAgent), FindUserPath(m, "", FileAgent)) + core.AssertEqual(t, core.PathJoin(home, ".core", FileZone), FindZoneManifest(m)) + core.AssertEqual(t, core.PathJoin(home, ".core", DirectoryImages), FindUserImagesDirectory(m)) + core.AssertEqual(t, core.PathJoin(home, ".core", DirectoryImages, FileImagesManifest), FindUserImagesManifest(m)) + core.AssertEqual(t, core.PathJoin(home, ".core", DirectoryImages, FileImagesManifest), FindUserPath(m, DirectoryImages, "", FileImagesManifest)) + core.AssertEqual(t, core.PathJoin(home, ".core", DirectorySecrets), FindUserSecretsDirectory(m)) + core.AssertEqual(t, core.PathJoin(home, ".core", DirectoryDaemons), FindUserDaemonsDirectory(m)) + core.AssertEqual(t, core.PathJoin(home, ".core", DirectoryWorkspaces), FindUserWorkspacesDirectory(m)) + core.AssertEqual(t, core.PathJoin(home, ".core", DirectoryImages), FindUserDirectory(m, DirectoryImages)) +} + +func TestResolve_FindUserPath_Bad(t *core.T) { + m := coreio.NewMockMedium() + + core.AssertEmpty(t, FindUserManifest(m, FileAgent)) + core.AssertEmpty(t, FindUserDirectory(m, DirectoryImages)) + core.AssertEmpty(t, FindUserImagesManifest(m)) + core.AssertEmpty(t, FindUserImagesDirectory(m)) + core.AssertEmpty(t, FindUserSecretsDirectory(m)) + core.AssertEmpty(t, FindUserDaemonsDirectory(m)) + core.AssertEmpty(t, FindUserWorkspacesDirectory(m)) + core.AssertEmpty(t, FindUserPath(m, "..", FileAgent)) + core.AssertEmpty(t, FindUserPath(m, DirectoryImages, "../escape")) + core.AssertEmpty(t, FindManifest(m, t.TempDir(), "../config.yaml")) +} + +func TestResolve_FindUserPath_Ugly(t *core.T) { + home := core.Env("DIR_HOME") + coreDir := core.PathJoin(home, ".core") + m := symlinkMockMedium{ + MockMedium: coreio.NewMockMedium(), + symlinks: map[string]bool{coreDir: true}, + } + core.RequireNoError(t, m.EnsureDir(coreDir)) + core.RequireNoError(t, m.Write(core.PathJoin(coreDir, FileAgent), "daemon:\n enabled: true\n")) + + core.AssertEmpty(t, FindUserPath(m, FileAgent)) + core.AssertEmpty(t, FindUserManifest(m, FileAgent)) +} + +func TestResolve_ResolveAgentManifest_UserManifests_Good(t *core.T) { + m := coreio.NewMockMedium() + home := core.Env("DIR_HOME") + + core.RequireNoError(t, m.EnsureDir(core.PathJoin(home, ".core"))) + core.RequireNoError(t, m.Write(core.PathJoin(home, ".core", FileAgent), "daemon:\n enabled: true\nagents:\n worker:\n total: 2\n")) + core.RequireNoError(t, m.Write(core.PathJoin(home, ".core", FileZone), "zone:\n name: alpha\n identity: '@alpha@lthn'\n services:\n vpn:\n enabled: true\n")) + + agent, err := agentManifestResult(ResolveAgentManifest(m)) + core.RequireNoError(t, err) + core.RequireTrue(t, agent != nil) + core.AssertTrue(t, agent.Daemon.Enabled) + core.AssertEqual(t, 2, agent.Agents["worker"].Total) + + zone, err := zoneManifestResult(ResolveZoneManifest(m)) + core.RequireNoError(t, err) + core.RequireTrue(t, zone != nil) + core.AssertEqual(t, "alpha", zone.Zone.Name) + core.AssertTrue(t, zone.Zone.Services.VPN.Enabled) +} + +func TestResolve_ResolveAgentManifest_UserManifests_Bad(t *core.T) { + m := coreio.NewMockMedium() + + agent, err := agentManifestResult(ResolveAgentManifest(m)) + core.AssertNil(t, agent) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "no agent manifest could be detected") + + zone, err := zoneManifestResult(ResolveZoneManifest(m)) + core.AssertNil(t, zone) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "no zone manifest could be detected") +} + +func TestResolve_ResolveAgentManifest_UserManifests_Ugly(t *core.T) { + m := coreio.NewMockMedium() + home := core.Env("DIR_HOME") + + core.RequireNoError(t, m.EnsureDir(core.PathJoin(home, ".core"))) + core.RequireNoError(t, m.Write(core.PathJoin(home, ".core", FileAgent), "daemon:\n enabled: [broken")) + core.RequireNoError(t, m.Write(core.PathJoin(home, ".core", FileZone), "zone:\n name: [broken")) + + agent, err := agentManifestResult(ResolveAgentManifest(m)) + core.AssertNil(t, agent) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), resolveTestFailedToParseManifest) + + zone, err := zoneManifestResult(ResolveZoneManifest(m)) + core.AssertNil(t, zone) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), resolveTestFailedToParseManifest) +} + +func TestResolve_FindPHPManifest_Good(t *core.T) { + m := coreio.NewMockMedium() + tmp := t.TempDir() + repo := core.PathJoin(tmp, "repo") + child := core.PathJoin(repo, "service") + + core.RequireNoError(t, m.EnsureDir(core.PathJoin(repo, ".core"))) + core.RequireNoError(t, m.EnsureDir(core.PathJoin(repo, ".git"))) + core.RequireNoError(t, m.EnsureDir(child)) + core.RequireNoError(t, m.Write(core.PathJoin(repo, ".core", FilePHP), "version: 1\n")) + core.RequireNoError(t, m.EnsureDir(core.PathJoin(repo, ".core", LinuxKitDirectory))) + + core.AssertEqual(t, core.PathJoin(repo, ".core", FilePHP), FindPHPManifest(m, child)) + core.AssertEqual(t, core.PathJoin(repo, ".core", LinuxKitDirectory), FindLinuxKitDirectory(m, child)) +} + +func TestResolve_ResolvePHPManifest_Bad(t *core.T) { + m := coreio.NewMockMedium() + + php, err := phpManifestResult(ResolvePHPManifest(m, t.TempDir())) + core.AssertNil(t, php) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "no php manifest could be detected") +} + +func TestResolve_ResolvePHPManifest_Ugly(t *core.T) { + m := coreio.NewMockMedium() + tmp := t.TempDir() + repo := core.PathJoin(tmp, "repo") + + core.RequireNoError(t, m.EnsureDir(core.PathJoin(repo, ".core"))) + core.RequireNoError(t, m.Write(core.PathJoin(repo, ".core", FilePHP), resolveTestBrokenVersionYAML)) + + php, err := phpManifestResult(ResolvePHPManifest(m, repo)) + core.AssertNil(t, php) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), resolveTestFailedToParseManifest) +} + +func TestResolve_ResolvePHPManifest_Good(t *core.T) { + m := coreio.NewMockMedium() + tmp := t.TempDir() + repo := core.PathJoin(tmp, "repo") + + core.RequireNoError(t, m.EnsureDir(core.PathJoin(repo, ".core"))) + core.RequireNoError(t, m.Write(core.PathJoin(repo, ".core", FilePHP), "version: 1\nserver:\n type: php-fpm\n")) + + php, err := phpManifestResult(ResolvePHPManifest(m, repo)) + core.RequireNoError(t, err) + core.RequireTrue(t, php != nil) + core.AssertEqual(t, 1, php.Version) + core.AssertEqual(t, "php-fpm", php.Server.Type) +} + +func TestResolve_FindReposManifest_Good(t *core.T) { + m := coreio.NewMockMedium() + tmp := t.TempDir() + start := core.PathJoin(tmp, "workspace", "repo", "service") + home := core.Env("DIR_HOME") + + core.RequireNoError(t, m.EnsureDir(start)) + core.RequireNoError(t, m.EnsureDir(core.PathJoin(home, "Code", Directory))) + core.RequireNoError(t, m.Write(core.PathJoin(home, "Code", Directory, FileRepos), resolveTestEmptyReposYAML)) + + core.AssertEqual(t, core.PathJoin(home, "Code", Directory, FileRepos), FindReposManifest(m, start)) +} + +func TestResolve_FindWorkspaceRegistryManifest_Good(t *core.T) { + m := coreio.NewMockMedium() + tmp := t.TempDir() + start := core.PathJoin(tmp, "workspace", "repo", "service") + home := core.Env("DIR_HOME") + + core.RequireNoError(t, m.EnsureDir(core.PathDir(core.PathJoin(home, "Code", Directory, FileRepos)))) + core.RequireNoError(t, m.Write(core.PathJoin(home, "Code", Directory, FileRepos), resolveTestEmptyReposYAML)) + + core.AssertEqual(t, FindReposManifest(m, start), FindWorkspaceRegistryManifest(m, start)) +} + +func TestResolve_FindWorkspaceRegistryManifest_Bad(t *core.T) { + m := coreio.NewMockMedium() + start := t.TempDir() + got := FindWorkspaceRegistryManifest(m, start) + core.AssertEmpty(t, got) +} + +func TestResolve_FindWorkspaceRegistryManifest_Ugly(t *core.T) { + home := core.Env("DIR_HOME") + coreDir := core.PathJoin(home, "Code", Directory) + start := core.PathJoin("workspace", "repo", "service") + m := symlinkMockMedium{ + MockMedium: coreio.NewMockMedium(), + symlinks: map[string]bool{coreDir: true}, + } + core.RequireNoError(t, m.EnsureDir(coreDir)) + core.RequireNoError(t, m.Write(core.PathJoin(coreDir, FileRepos), resolveTestEmptyReposYAML)) + + core.AssertEmpty(t, FindWorkspaceRegistryManifest(m, start)) +} + +func TestResolve_ResolveWorkspaceRegistryManifest_Good(t *core.T) { + m := coreio.NewMockMedium() + tmp := t.TempDir() + start := core.PathJoin(tmp, "workspace", "repo", "service") + home := core.Env("DIR_HOME") + + core.RequireNoError(t, m.EnsureDir(core.PathJoin(home, "Code", Directory))) + core.RequireNoError(t, m.Write(core.PathJoin(home, "Code", Directory, FileRepos), resolveTestWorkspaceReposYAML)) + + repos, err := reposManifestResult(ResolveWorkspaceRegistryManifest(m, start)) + core.RequireNoError(t, err) + core.RequireTrue(t, repos != nil) + core.AssertEqual(t, "host-uk", repos.Org) + core.AssertLen(t, repos.Repos, 1) +} + +func TestResolve_ResolveWorkspaceRegistryManifest_Bad(t *core.T) { + m := coreio.NewMockMedium() + + repos, err := reposManifestResult(ResolveWorkspaceRegistryManifest(m, t.TempDir())) + core.AssertNil(t, repos) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "no repos manifest could be detected") +} + +func TestResolve_ResolveWorkspaceRegistryManifest_Ugly(t *core.T) { + m := coreio.NewMockMedium() + tmp := t.TempDir() + home := core.Env("DIR_HOME") + start := core.PathJoin(tmp, "workspace", "repo", "service") + + core.RequireNoError(t, m.EnsureDir(core.PathJoin(home, "Code", Directory))) + core.RequireNoError(t, m.Write(core.PathJoin(home, "Code", Directory, FileRepos), resolveTestBrokenVersionYAML)) + + repos, err := reposManifestResult(ResolveWorkspaceRegistryManifest(m, start)) + core.AssertNil(t, repos) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), resolveTestFailedToParseManifest) +} + +func TestResolve_ResolveImagesManifest_Good(t *core.T) { + m := coreio.NewMockMedium() + home := core.Env("DIR_HOME") + + core.RequireNoError(t, m.EnsureDir(core.PathJoin(home, ".core", DirectoryImages))) + core.RequireNoError(t, m.Write(core.PathJoin(home, ".core", DirectoryImages, FileImagesManifest), `{"images":{"core-dev":{"version":"1.2.3","downloaded":"2026-04-15T12:00:00Z","source":"github"}}}`)) + + manifest, err := imagesManifestResult(ResolveImagesManifest(m)) + core.RequireNoError(t, err) + core.RequireTrue(t, manifest != nil) + core.AssertLen(t, manifest.Images, 1) + core.AssertEqual(t, "1.2.3", manifest.Images["core-dev"].Version) +} + +func TestResolve_WorkspaceSandboxPath_Good(t *core.T) { + home := core.Env("DIR_HOME") + core.AssertEqual(t, core.PathJoin(home, Directory, WorkspaceDirectory, "repo", "dev", "src"), WorkspaceSandboxPath("repo", "dev", "src")) + core.AssertEqual(t, core.PathJoin(home, Directory, WorkspaceDirectory, "repo", "dev"), WorkspaceSandboxRoot("repo", "dev")) + core.AssertEqual(t, core.PathJoin(home, Directory, WorkspaceDirectory, "repo", "dev", WorkspaceSourceDirectory, "app", resolveTestMainGo), WorkspaceSandboxSourcePath("repo", "dev", "app", resolveTestMainGo)) + core.AssertEqual(t, core.PathJoin(home, Directory, WorkspaceDirectory, "repo", "dev", WorkspaceMetaDirectory, resolveTestStatusJSON), WorkspaceSandboxMetaPath("repo", "dev", resolveTestStatusJSON)) + core.AssertEqual(t, core.PathJoin(home, Directory, WorkspaceDirectory, "repo", "dev", WorkspaceInstructionsFile), WorkspaceSandboxInstructionsPath("repo", "dev")) +} + +func TestResolve_WorkspaceSandboxPath_Ugly(t *core.T) { + home := core.Env("DIR_HOME") + core.AssertEqual(t, core.PathJoin(home, Directory, WorkspaceDirectory, "src"), WorkspaceSandboxPath("", "", "", "src", "")) + core.AssertEmpty(t, WorkspaceSandboxPath(resolveTestParentRepoPath, "dev")) + core.AssertEmpty(t, WorkspaceSandboxPath("repo", "../dev")) + core.AssertEmpty(t, WorkspaceSandboxPath("repo", "dev", "../secret")) +} + +func TestResolve_ResolveConfigManifest_Good(t *core.T) { + m := coreio.NewMockMedium() + tmp := t.TempDir() + home := core.Env("DIR_HOME") + start := core.PathJoin(tmp, "workspace", "repo", "service") + + for _, dir := range []string{ + core.PathJoin(home, ".core"), + start, + } { + core.AssertNoError(t, m.EnsureDir(dir)) + } + + configPath := core.PathJoin(home, ".core", FileConfig) + core.AssertNoError(t, m.Write(configPath, "app:\n name: global\n")) + + cfg, err := configResult(ResolveConfigManifest(m, start)) + core.AssertNoError(t, err) + core.AssertNotNil(t, cfg) + core.AssertEqual(t, configPath, cfg.Path()) + + var name string + core.AssertNoError(t, resultError(cfg.Get("app.name", &name))) + core.AssertEqual(t, "global", name) +} + +func TestResolve_ResolveConfigManifest_Bad(t *core.T) { + m := coreio.NewMockMedium() + start := core.PathJoin(t.TempDir(), "workspace", "repo", "service") + + _, err := configResult(ResolveConfigManifest(m, start)) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "no config manifest could be detected") +} + +func TestResolve_ResolveConfigManifest_Ugly(t *core.T) { + m := coreio.NewMockMedium() + tmp := t.TempDir() + home := core.Env("DIR_HOME") + start := core.PathJoin(tmp, "workspace", "repo", "service") + + for _, dir := range []string{ + core.PathJoin(home, ".core"), + start, + } { + core.AssertNoError(t, m.EnsureDir(dir)) + } + + configPath := core.PathJoin(home, ".core", FileConfig) + core.AssertNoError(t, m.Write(configPath, "app:\n name: [broken yaml")) + + _, err := configResult(ResolveConfigManifest(m, start)) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "failed to load config file") +} + +func TestResolve_ResolveBuildManifest_ProjectManifests_Good(t *core.T) { + t.Setenv("CORE_MANIFEST_TRUST_KEYS", "") + m := coreio.NewMockMedium() + tmp := t.TempDir() + repo := core.PathJoin(tmp, "workspace", "repo") + workspace := core.PathJoin(tmp, "workspace") + child := core.PathJoin(repo, "service") + + for _, dir := range []string{ + core.PathJoin(repo, ".core"), + core.PathJoin(repo, ".git"), + child, + core.PathJoin(workspace, ".core"), + } { + core.AssertNoError(t, m.EnsureDir(dir)) + } + + buildPath := core.PathJoin(repo, ".core", FileBuild) + releasePath := core.PathJoin(repo, ".core", FileRelease) + runPath := core.PathJoin(repo, ".core", FileRun) + viewPath := core.PathJoin(repo, ".core", FileView) + packagePath := core.PathJoin(repo, ".core", FileManifest) + idePath := core.PathJoin(repo, ".core", FileIDE) + + core.AssertNoError(t, m.Write(buildPath, "name: core\noutput: dist\ntargets:\n - linux/amd64\n")) + core.AssertNoError(t, m.Write(releasePath, "version: 1\narchive:\n format: tar.gz\n include:\n - README.md\nchecksums: true\ngithub:\n draft: false\n prerelease: false\nchangelog:\n include:\n - feat\n")) + core.AssertNoError(t, m.Write(runPath, "version: 1\nservices:\n - name: db\n image: postgres:16\n port: 5432\ndev:\n command: php artisan serve\n port: 8000\n watch:\n - app/\n")) + core.AssertNoError(t, m.Write(viewPath, "code: photo-browser\nname: Photo Browser\nsign: "+base64.StdEncoding.EncodeToString(make([]byte, ed25519.SignatureSize))+"\npermissions:\n clipboard: true\n")) + core.AssertNoError(t, m.Write(packagePath, packageManifestFixture(t))) + core.AssertNoError(t, m.Write(idePath, "version: 1\neditor: nvim\n")) + core.AssertNoError(t, m.Write(core.PathJoin(workspace, ".core", FileRepos), resolveTestWorkspaceReposYAML)) + + build, err := buildManifestResult(ResolveBuildManifest(m, child)) + core.AssertNoError(t, err) + core.AssertEqual(t, "core", build.Name) + core.AssertEqual(t, "dist", build.Output) + + release, err := releaseManifestResult(ResolveReleaseManifest(m, child)) + core.AssertNoError(t, err) + core.AssertEqual(t, true, release.Checksums) + core.AssertEqual(t, "tar.gz", release.Archive.Format) + + run, err := runManifestResult(ResolveRunManifest(m, child)) + core.AssertNoError(t, err) + core.AssertEqual(t, "php artisan serve", run.Dev.Command) + core.AssertLen(t, run.Services, 1) + + view, err := viewManifestResult(ResolveViewManifest(m, child)) + core.AssertNoError(t, err) + core.AssertEqual(t, "photo-browser", view.Code) + core.AssertTrue(t, view.Permissions.Clipboard) + + pkg, err := packageManifestResult(ResolvePackageManifest(m, child)) + core.AssertNoError(t, err) + core.AssertEqual(t, "go-io", pkg.Code) + core.AssertEqual(t, "Core I/O", pkg.Name) + + ide, err := ideManifestResult(ResolveIDEManifest(m, child)) + core.AssertNoError(t, err) + core.AssertEqual(t, 1, ide.Version) + core.AssertEqual(t, "nvim", ide.Editor) + + repos, err := reposManifestResult(ResolveReposManifest(m, child)) + core.AssertNoError(t, err) + core.AssertEqual(t, "host-uk", repos.Org) + core.AssertLen(t, repos.Repos, 1) +} + +func TestResolve_ResolveBuildManifest_ProjectManifests_Bad(t *core.T) { + t.Setenv("DIR_HOME", t.TempDir()) + m := coreio.NewMockMedium() + start := core.PathJoin(t.TempDir(), "workspace", "repo", "service") + + _, err := buildManifestResult(ResolveBuildManifest(m, start)) + core.AssertError(t, err) + _, err = releaseManifestResult(ResolveReleaseManifest(m, start)) + core.AssertError(t, err) + _, err = runManifestResult(ResolveRunManifest(m, start)) + core.AssertError(t, err) + _, err = viewManifestResult(ResolveViewManifest(m, start)) + core.AssertError(t, err) + _, err = packageManifestResult(ResolvePackageManifest(m, start)) + core.AssertError(t, err) + _, err = ideManifestResult(ResolveIDEManifest(m, start)) + core.AssertError(t, err) + _, err = reposManifestResult(ResolveReposManifest(m, start)) + core.AssertError(t, err) +} + +func TestResolve_FindReposManifest_FallsBackToWorkspaceRoot_Good(t *core.T) { + m := coreio.NewMockMedium() + start := core.PathJoin(t.TempDir(), "workspace", "repo", "service") + reposPath := core.PathJoin(core.Env("DIR_HOME"), "Code", ".core", FileRepos) + + core.AssertNoError(t, m.EnsureDir(core.PathDir(reposPath))) + core.AssertNoError(t, m.Write(reposPath, resolveTestWorkspaceReposYAML)) + + core.AssertEqual(t, reposPath, FindReposManifest(m, start)) +} + +func TestResolve_ResolveBuildManifest_ProjectManifests_Ugly(t *core.T) { + m := coreio.NewMockMedium() + tmp := t.TempDir() + repo := core.PathJoin(tmp, "workspace", "repo") + workspace := core.PathJoin(tmp, "workspace") + child := core.PathJoin(repo, "service") + + for _, dir := range []string{ + core.PathJoin(repo, ".core"), + core.PathJoin(repo, ".git"), + child, + core.PathJoin(workspace, ".core"), + } { + core.AssertNoError(t, m.EnsureDir(dir)) + } + + core.AssertNoError(t, m.Write(core.PathJoin(repo, ".core", FileBuild), "name: core\noutput: dist\ntargets:\n - [broken yaml")) + core.AssertNoError(t, m.Write(core.PathJoin(repo, ".core", FileRelease), "version: 1\narchive:\n format: [broken yaml")) + core.AssertNoError(t, m.Write(core.PathJoin(repo, ".core", FileRun), "version: 1\nservices: [broken yaml")) + core.AssertNoError(t, m.Write(core.PathJoin(repo, ".core", FileView), "code: photo-browser\nname: Photo Browser\npermissions:\n clipboard: true\n")) + core.AssertNoError(t, m.Write(core.PathJoin(repo, ".core", FileManifest), "code: go-io\nname: Core I/O\nsign: not-base64\nsign_key: \"\"\n")) + core.AssertNoError(t, m.Write(core.PathJoin(workspace, ".core", FileRepos), "version: 1\norg: host-uk\nrepos: [broken yaml")) + + _, err := buildManifestResult(ResolveBuildManifest(m, child)) + core.AssertError(t, err) + _, err = releaseManifestResult(ResolveReleaseManifest(m, child)) + core.AssertError(t, err) + _, err = runManifestResult(ResolveRunManifest(m, child)) + core.AssertError(t, err) + + _, err = viewManifestResult(ResolveViewManifest(m, child)) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "unsigned view manifest rejected") + + _, err = packageManifestResult(ResolvePackageManifest(m, child)) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "missing package sign_key") + + _, err = reposManifestResult(ResolveReposManifest(m, child)) + core.AssertError(t, err) +} + +func packageManifestFixture(t *core.T) string { + t.Helper() + + pub, priv, err := ed25519.GenerateKey(nil) + core.AssertNoError(t, err) + + pkg := &PackageManifest{ + Code: "go-io", + Name: "Core I/O", + Version: "0.3.0", + Licence: "EUPL-1.2", + SignKey: hex.EncodeToString(pub), + } + msg, err := bytesResult(packageManifestBytes(pkg)) + core.AssertNoError(t, err) + pkg.Sign = base64.StdEncoding.EncodeToString(ed25519.Sign(priv, msg)) + + out, err := yaml.Marshal(pkg) + core.AssertNoError(t, err) + return string(out) +} + +type axResolveProject struct { + medium *coreio.MockMedium + root string + child string + core string +} + +func axResolveProjectFixture(t *core.T) axResolveProject { + t.Helper() + m := coreio.NewMockMedium() + root := core.PathJoin(t.TempDir(), "repo") + child := core.PathJoin(root, "service") + coreDir := core.PathJoin(root, Directory) + core.RequireNoError(t, m.EnsureDir(coreDir)) + core.RequireNoError(t, m.EnsureDir(core.PathJoin(root, ".git"))) + core.RequireNoError(t, m.EnsureDir(child)) + core.RequireNoError(t, m.Write(core.PathJoin(coreDir, FileBuild), "version: 1\nproject:\n name: core\nbuild:\n flags: [-trimpath]\ntargets:\n - linux/amd64\n")) + core.RequireNoError(t, m.Write(core.PathJoin(coreDir, FileTest), "version: 1\ncommands:\n - name: unit\n run: go test ./...\n")) + core.RequireNoError(t, m.Write(core.PathJoin(coreDir, FileRelease), "version: 1\narchive:\n format: tar.gz\n include: [README.md]\nchecksums: true\n")) + core.RequireNoError(t, m.Write(core.PathJoin(coreDir, FileRun), "version: 1\nservices:\n - name: db\n image: postgres:16\n port: 5432\n")) + view, _ := axSignedView(t) + viewBody, err := yaml.Marshal(&view) + core.RequireNoError(t, err) + core.RequireNoError(t, m.Write(core.PathJoin(coreDir, FileView), string(viewBody))) + pkg, pub := axSignedPackage(t) + setManifestTrustKeys(t, hex.EncodeToString(pub)) + pkgBody, err := yaml.Marshal(&pkg) + core.RequireNoError(t, err) + core.RequireNoError(t, m.Write(core.PathJoin(coreDir, FileManifest), string(pkgBody))) + core.RequireNoError(t, m.Write(core.PathJoin(coreDir, FileWorkspace), "version: 1\ndependencies: [core/go]\n")) + core.RequireNoError(t, m.Write(core.PathJoin(coreDir, FileIDE), "version: 1\neditor: codex\n")) + core.RequireNoError(t, m.Write(core.PathJoin(coreDir, FilePHP), "version: 1\nserver:\n type: php-fpm\n")) + core.RequireNoError(t, m.EnsureDir(core.PathJoin(coreDir, LinuxKitDirectory))) + core.RequireNoError(t, m.Write(core.PathJoin(coreDir, LinuxKitDirectory, FileLinuxKit), "kernel:\n image: linuxkit/kernel:6.6.0\n")) + return axResolveProject{medium: m, root: root, child: child, core: coreDir} +} + +func axResolveUserFixture(t *core.T) (*coreio.MockMedium, string) { + t.Helper() + m := coreio.NewMockMedium() + home := core.Env("DIR_HOME") + coreDir := core.PathJoin(home, Directory) + core.RequireNoError(t, m.EnsureDir(coreDir)) + core.RequireNoError(t, m.Write(core.PathJoin(coreDir, FileAgent), "daemon:\n enabled: true\nagents:\n codex:\n total: 1\n")) + core.RequireNoError(t, m.Write(core.PathJoin(coreDir, FileZone), "zone:\n name: alpha\n identity: '@alpha@lthn'\n services:\n vpn:\n enabled: true\n")) + for _, dir := range []string{DirectoryImages, DirectorySecrets, DirectoryDaemons, DirectoryWorkspaces} { + core.RequireNoError(t, m.EnsureDir(core.PathJoin(coreDir, dir))) + } + core.RequireNoError(t, m.Write(core.PathJoin(coreDir, DirectoryImages, FileImagesManifest), `{"images":{}}`)) + return m, home +} + +func TestResolve_FindUserManifest_Good(t *core.T) { + m, home := axResolveUserFixture(t) + got := FindUserManifest(m, FileAgent) + core.AssertEqual(t, core.PathJoin(home, Directory, FileAgent), got) +} + +func TestResolve_FindUserManifest_Bad(t *core.T) { + m := coreio.NewMockMedium() + got := FindUserManifest(m, FileAgent) + core.AssertEqual(t, "", got) +} + +func TestResolve_FindUserManifest_Ugly(t *core.T) { + m, home := axResolveUserFixture(t) + marked := symlinkMockMedium{MockMedium: m, symlinks: map[string]bool{core.PathJoin(home, Directory): true}} + got := FindUserManifest(marked, FileAgent) + core.AssertEqual(t, "", got) +} + +func TestResolve_FindUserDirectory_Good(t *core.T) { + m, home := axResolveUserFixture(t) + got := FindUserDirectory(m, DirectoryImages) + core.AssertEqual(t, core.PathJoin(home, Directory, DirectoryImages), got) +} + +func TestResolve_FindUserDirectory_Bad(t *core.T) { + m := coreio.NewMockMedium() + got := FindUserDirectory(m, DirectoryImages) + core.AssertEqual(t, "", got) +} + +func TestResolve_FindUserDirectory_Ugly(t *core.T) { + m, _ := axResolveUserFixture(t) + got := FindUserDirectory(m, "../images") + core.AssertEqual(t, "", got) +} + +func TestResolve_FindUserImagesManifest_Good(t *core.T) { + m, home := axResolveUserFixture(t) + got := FindUserImagesManifest(m) + core.AssertEqual(t, core.PathJoin(home, Directory, DirectoryImages, FileImagesManifest), got) +} + +func TestResolve_FindUserImagesManifest_Bad(t *core.T) { + m := coreio.NewMockMedium() + got := FindUserImagesManifest(m) + core.AssertEqual(t, "", got) +} + +func TestResolve_FindUserImagesManifest_Ugly(t *core.T) { + m, home := axResolveUserFixture(t) + marked := symlinkMockMedium{MockMedium: m, symlinks: map[string]bool{core.PathJoin(home, Directory): true}} + got := FindUserImagesManifest(marked) + core.AssertEqual(t, "", got) +} + +func TestResolve_FindUserImagesDirectory_Good(t *core.T) { + m, home := axResolveUserFixture(t) + got := FindUserImagesDirectory(m) + core.AssertEqual(t, core.PathJoin(home, Directory, DirectoryImages), got) +} + +func TestResolve_FindUserImagesDirectory_Bad(t *core.T) { + m := coreio.NewMockMedium() + got := FindUserImagesDirectory(m) + core.AssertEqual(t, "", got) +} + +func TestResolve_FindUserImagesDirectory_Ugly(t *core.T) { + m, home := axResolveUserFixture(t) + marked := symlinkMockMedium{MockMedium: m, symlinks: map[string]bool{core.PathJoin(home, Directory): true}} + got := FindUserImagesDirectory(marked) + core.AssertEqual(t, "", got) +} + +func TestResolve_FindUserSecretsDirectory_Good(t *core.T) { + m, home := axResolveUserFixture(t) + got := FindUserSecretsDirectory(m) + core.AssertEqual(t, core.PathJoin(home, Directory, DirectorySecrets), got) +} + +func TestResolve_FindUserSecretsDirectory_Bad(t *core.T) { + m := coreio.NewMockMedium() + got := FindUserSecretsDirectory(m) + core.AssertEqual(t, "", got) +} + +func TestResolve_FindUserSecretsDirectory_Ugly(t *core.T) { + m, home := axResolveUserFixture(t) + marked := symlinkMockMedium{MockMedium: m, symlinks: map[string]bool{core.PathJoin(home, Directory): true}} + got := FindUserSecretsDirectory(marked) + core.AssertEqual(t, "", got) +} + +func TestResolve_FindUserDaemonsDirectory_Good(t *core.T) { + m, home := axResolveUserFixture(t) + got := FindUserDaemonsDirectory(m) + core.AssertEqual(t, core.PathJoin(home, Directory, DirectoryDaemons), got) +} + +func TestResolve_FindUserDaemonsDirectory_Bad(t *core.T) { + m := coreio.NewMockMedium() + got := FindUserDaemonsDirectory(m) + core.AssertEqual(t, "", got) +} + +func TestResolve_FindUserDaemonsDirectory_Ugly(t *core.T) { + m, home := axResolveUserFixture(t) + marked := symlinkMockMedium{MockMedium: m, symlinks: map[string]bool{core.PathJoin(home, Directory): true}} + got := FindUserDaemonsDirectory(marked) + core.AssertEqual(t, "", got) +} + +func TestResolve_FindUserWorkspacesDirectory_Good(t *core.T) { + m, home := axResolveUserFixture(t) + got := FindUserWorkspacesDirectory(m) + core.AssertEqual(t, core.PathJoin(home, Directory, DirectoryWorkspaces), got) +} + +func TestResolve_FindUserWorkspacesDirectory_Bad(t *core.T) { + m := coreio.NewMockMedium() + got := FindUserWorkspacesDirectory(m) + core.AssertEqual(t, "", got) +} + +func TestResolve_FindUserWorkspacesDirectory_Ugly(t *core.T) { + m, home := axResolveUserFixture(t) + marked := symlinkMockMedium{MockMedium: m, symlinks: map[string]bool{core.PathJoin(home, Directory): true}} + got := FindUserWorkspacesDirectory(marked) + core.AssertEqual(t, "", got) +} + +func TestResolve_FindBuildManifest_Good(t *core.T) { + fixture := axResolveProjectFixture(t) + got := FindBuildManifest(fixture.medium, fixture.child) + core.AssertEqual(t, core.PathJoin(fixture.core, FileBuild), got) +} + +func TestResolve_FindBuildManifest_Bad(t *core.T) { + m := coreio.NewMockMedium() + got := FindBuildManifest(m, t.TempDir()) + core.AssertEqual(t, "", got) +} + +func TestResolve_FindBuildManifest_Ugly(t *core.T) { + fixture := axResolveProjectFixture(t) + marked := symlinkMockMedium{MockMedium: fixture.medium, symlinks: map[string]bool{fixture.core: true}} + got := FindBuildManifest(marked, fixture.child) + core.AssertEqual(t, "", got) +} + +func TestResolve_ResolveBuildManifest_Good(t *core.T) { + fixture := axResolveProjectFixture(t) + got, err := buildManifestResult(ResolveBuildManifest(fixture.medium, fixture.child)) + core.AssertNoError(t, err) + core.AssertEqual(t, "core", got.Project.Name) +} + +func TestResolve_ResolveBuildManifest_Bad(t *core.T) { + m := coreio.NewMockMedium() + got, err := buildManifestResult(ResolveBuildManifest(m, t.TempDir())) + core.AssertNil(t, got) + core.AssertError(t, err) +} + +func TestResolve_ResolveBuildManifest_Ugly(t *core.T) { + fixture := axResolveProjectFixture(t) + core.RequireNoError(t, fixture.medium.Write(core.PathJoin(fixture.core, FileBuild), "targets:\n - invalid-target\n")) + got, err := buildManifestResult(ResolveBuildManifest(fixture.medium, fixture.child)) + core.AssertNil(t, got) + core.AssertError(t, err) +} + +func TestResolve_FindTestManifest_Good(t *core.T) { + fixture := axResolveProjectFixture(t) + got := FindTestManifest(fixture.medium, fixture.child) + core.AssertEqual(t, core.PathJoin(fixture.core, FileTest), got) +} + +func TestResolve_FindTestManifest_Bad(t *core.T) { + m := coreio.NewMockMedium() + got := FindTestManifest(m, t.TempDir()) + core.AssertEqual(t, "", got) +} + +func TestResolve_FindTestManifest_Ugly(t *core.T) { + fixture := axResolveProjectFixture(t) + marked := symlinkMockMedium{MockMedium: fixture.medium, symlinks: map[string]bool{fixture.core: true}} + got := FindTestManifest(marked, fixture.child) + core.AssertEqual(t, "", got) +} + +func TestResolve_FindReleaseManifest_Good(t *core.T) { + fixture := axResolveProjectFixture(t) + got := FindReleaseManifest(fixture.medium, fixture.child) + core.AssertEqual(t, core.PathJoin(fixture.core, FileRelease), got) +} + +func TestResolve_FindReleaseManifest_Bad(t *core.T) { + m := coreio.NewMockMedium() + got := FindReleaseManifest(m, t.TempDir()) + core.AssertEqual(t, "", got) +} + +func TestResolve_FindReleaseManifest_Ugly(t *core.T) { + fixture := axResolveProjectFixture(t) + marked := symlinkMockMedium{MockMedium: fixture.medium, symlinks: map[string]bool{fixture.core: true}} + got := FindReleaseManifest(marked, fixture.child) + core.AssertEqual(t, "", got) +} + +func TestResolve_ResolveReleaseManifest_Good(t *core.T) { + fixture := axResolveProjectFixture(t) + got, err := releaseManifestResult(ResolveReleaseManifest(fixture.medium, fixture.child)) + core.AssertNoError(t, err) + core.AssertEqual(t, "tar.gz", got.Archive.Format) +} + +func TestResolve_ResolveReleaseManifest_Bad(t *core.T) { + m := coreio.NewMockMedium() + got, err := releaseManifestResult(ResolveReleaseManifest(m, t.TempDir())) + core.AssertNil(t, got) + core.AssertError(t, err) +} + +func TestResolve_ResolveReleaseManifest_Ugly(t *core.T) { + fixture := axResolveProjectFixture(t) + core.RequireNoError(t, fixture.medium.Write(core.PathJoin(fixture.core, FileRelease), resolveTestBrokenVersionYAML)) + got, err := releaseManifestResult(ResolveReleaseManifest(fixture.medium, fixture.child)) + core.AssertNil(t, got) + core.AssertError(t, err) +} + +func TestResolve_FindRunManifest_Good(t *core.T) { + fixture := axResolveProjectFixture(t) + got := FindRunManifest(fixture.medium, fixture.child) + core.AssertEqual(t, core.PathJoin(fixture.core, FileRun), got) +} + +func TestResolve_FindRunManifest_Bad(t *core.T) { + m := coreio.NewMockMedium() + got := FindRunManifest(m, t.TempDir()) + core.AssertEqual(t, "", got) +} + +func TestResolve_FindRunManifest_Ugly(t *core.T) { + fixture := axResolveProjectFixture(t) + marked := symlinkMockMedium{MockMedium: fixture.medium, symlinks: map[string]bool{fixture.core: true}} + got := FindRunManifest(marked, fixture.child) + core.AssertEqual(t, "", got) +} + +func TestResolve_ResolveRunManifest_Good(t *core.T) { + fixture := axResolveProjectFixture(t) + got, err := runManifestResult(ResolveRunManifest(fixture.medium, fixture.child)) + core.AssertNoError(t, err) + core.AssertEqual(t, "db", got.Services[0].Name) +} + +func TestResolve_ResolveRunManifest_Bad(t *core.T) { + m := coreio.NewMockMedium() + got, err := runManifestResult(ResolveRunManifest(m, t.TempDir())) + core.AssertNil(t, got) + core.AssertError(t, err) +} + +func TestResolve_ResolveRunManifest_Ugly(t *core.T) { + fixture := axResolveProjectFixture(t) + core.RequireNoError(t, fixture.medium.Write(core.PathJoin(fixture.core, FileRun), "services: [broken")) + got, err := runManifestResult(ResolveRunManifest(fixture.medium, fixture.child)) + core.AssertNil(t, got) + core.AssertError(t, err) +} + +func TestResolve_FindViewManifest_Good(t *core.T) { + fixture := axResolveProjectFixture(t) + got := FindViewManifest(fixture.medium, fixture.child) + core.AssertEqual(t, core.PathJoin(fixture.core, FileView), got) +} + +func TestResolve_FindViewManifest_Bad(t *core.T) { + m := coreio.NewMockMedium() + got := FindViewManifest(m, t.TempDir()) + core.AssertEqual(t, "", got) +} + +func TestResolve_FindViewManifest_Ugly(t *core.T) { + fixture := axResolveProjectFixture(t) + marked := symlinkMockMedium{MockMedium: fixture.medium, symlinks: map[string]bool{fixture.core: true}} + got := FindViewManifest(marked, fixture.child) + core.AssertEqual(t, "", got) +} + +func TestResolve_ResolveViewManifest_Good(t *core.T) { + fixture := axResolveProjectFixture(t) + got, err := viewManifestResult(ResolveViewManifest(fixture.medium, fixture.child)) + core.AssertNoError(t, err) + core.AssertEqual(t, "photo-browser", got.Code) +} + +func TestResolve_ResolveViewManifest_Bad(t *core.T) { + m := coreio.NewMockMedium() + got, err := viewManifestResult(ResolveViewManifest(m, t.TempDir())) + core.AssertNil(t, got) + core.AssertError(t, err) +} + +func TestResolve_ResolveViewManifest_Ugly(t *core.T) { + fixture := axResolveProjectFixture(t) + core.RequireNoError(t, fixture.medium.Write(core.PathJoin(fixture.core, FileView), "code: ax\nname: AX\n")) + got, err := viewManifestResult(ResolveViewManifest(fixture.medium, fixture.child)) + core.AssertNil(t, got) + core.AssertError(t, err) +} + +func TestResolve_FindPackageManifest_Good(t *core.T) { + fixture := axResolveProjectFixture(t) + got := FindPackageManifest(fixture.medium, fixture.child) + core.AssertEqual(t, core.PathJoin(fixture.core, FileManifest), got) +} + +func TestResolve_FindPackageManifest_Bad(t *core.T) { + m := coreio.NewMockMedium() + got := FindPackageManifest(m, t.TempDir()) + core.AssertEqual(t, "", got) +} + +func TestResolve_FindPackageManifest_Ugly(t *core.T) { + fixture := axResolveProjectFixture(t) + marked := symlinkMockMedium{MockMedium: fixture.medium, symlinks: map[string]bool{fixture.core: true}} + got := FindPackageManifest(marked, fixture.child) + core.AssertEqual(t, "", got) +} + +func TestResolve_ResolvePackageManifest_Good(t *core.T) { + fixture := axResolveProjectFixture(t) + got, err := packageManifestResult(ResolvePackageManifest(fixture.medium, fixture.child)) + core.AssertNoError(t, err) + core.AssertEqual(t, "go-config", got.Code) +} + +func TestResolve_ResolvePackageManifest_Bad(t *core.T) { + m := coreio.NewMockMedium() + got, err := packageManifestResult(ResolvePackageManifest(m, t.TempDir())) + core.AssertNil(t, got) + core.AssertError(t, err) +} + +func TestResolve_ResolvePackageManifest_Ugly(t *core.T) { + fixture := axResolveProjectFixture(t) + core.RequireNoError(t, fixture.medium.Write(core.PathJoin(fixture.core, FileManifest), "code: ax\nname: AX\nsign: not-base64\nsign_key: \"\"\n")) + got, err := packageManifestResult(ResolvePackageManifest(fixture.medium, fixture.child)) + core.AssertNil(t, got) + core.AssertError(t, err) +} + +func TestResolve_FindAgentManifest_Good(t *core.T) { + m, home := axResolveUserFixture(t) + got := FindAgentManifest(m) + core.AssertEqual(t, core.PathJoin(home, Directory, FileAgent), got) +} + +func TestResolve_FindAgentManifest_Bad(t *core.T) { + m := coreio.NewMockMedium() + got := FindAgentManifest(m) + core.AssertEqual(t, "", got) +} + +func TestResolve_FindAgentManifest_Ugly(t *core.T) { + m, home := axResolveUserFixture(t) + marked := symlinkMockMedium{MockMedium: m, symlinks: map[string]bool{core.PathJoin(home, Directory): true}} + got := FindAgentManifest(marked) + core.AssertEqual(t, "", got) +} + +func TestResolve_ResolveAgentManifest_Good(t *core.T) { + m, _ := axResolveUserFixture(t) + got, err := agentManifestResult(ResolveAgentManifest(m)) + core.AssertNoError(t, err) + core.AssertTrue(t, got.Daemon.Enabled) +} + +func TestResolve_ResolveAgentManifest_Bad(t *core.T) { + m := coreio.NewMockMedium() + got, err := agentManifestResult(ResolveAgentManifest(m)) + core.AssertNil(t, got) + core.AssertError(t, err) +} + +func TestResolve_ResolveAgentManifest_Ugly(t *core.T) { + m, home := axResolveUserFixture(t) + core.RequireNoError(t, m.Write(core.PathJoin(home, Directory, FileAgent), "daemon: [broken")) + got, err := agentManifestResult(ResolveAgentManifest(m)) + core.AssertNil(t, got) + core.AssertError(t, err) +} + +func TestResolve_FindZoneManifest_Good(t *core.T) { + m, home := axResolveUserFixture(t) + got := FindZoneManifest(m) + core.AssertEqual(t, core.PathJoin(home, Directory, FileZone), got) +} + +func TestResolve_FindZoneManifest_Bad(t *core.T) { + m := coreio.NewMockMedium() + got := FindZoneManifest(m) + core.AssertEqual(t, "", got) +} + +func TestResolve_FindZoneManifest_Ugly(t *core.T) { + m, home := axResolveUserFixture(t) + marked := symlinkMockMedium{MockMedium: m, symlinks: map[string]bool{core.PathJoin(home, Directory): true}} + got := FindZoneManifest(marked) + core.AssertEqual(t, "", got) +} + +func TestResolve_ResolveZoneManifest_Good(t *core.T) { + m, _ := axResolveUserFixture(t) + got, err := zoneManifestResult(ResolveZoneManifest(m)) + core.AssertNoError(t, err) + core.AssertEqual(t, "alpha", got.Zone.Name) +} + +func TestResolve_ResolveZoneManifest_Bad(t *core.T) { + m := coreio.NewMockMedium() + got, err := zoneManifestResult(ResolveZoneManifest(m)) + core.AssertNil(t, got) + core.AssertError(t, err) +} + +func TestResolve_ResolveZoneManifest_Ugly(t *core.T) { + m, home := axResolveUserFixture(t) + core.RequireNoError(t, m.Write(core.PathJoin(home, Directory, FileZone), "zone: [broken")) + got, err := zoneManifestResult(ResolveZoneManifest(m)) + core.AssertNil(t, got) + core.AssertError(t, err) +} + +func TestResolve_FindWorkspaceManifest_Good(t *core.T) { + m := coreio.NewMockMedium() + root := core.PathJoin("/", "workspace", "repo") + child := core.PathJoin(root, "service") + + core.AssertNoError(t, m.EnsureDir(core.PathJoin(root, ".core"))) + core.AssertNoError(t, m.EnsureDir(child)) + core.AssertNoError(t, m.Write(core.PathJoin(root, ".core", FileWorkspace), "version: 1\ndependencies:\n - core-php\nactive: core-php\npackages_dir: ./packages\n")) + + path := FindWorkspaceManifest(m, child) + core.AssertEqual(t, core.PathJoin(root, ".core", FileWorkspace), path) +} + +func TestResolve_FindWorkspaceManifest_Bad(t *core.T) { + m := coreio.NewMockMedium() + got := FindWorkspaceManifest(m, t.TempDir()) + core.AssertEqual(t, "", got) +} + +func TestResolve_FindWorkspaceManifest_Ugly(t *core.T) { + fixture := axResolveProjectFixture(t) + marked := symlinkMockMedium{MockMedium: fixture.medium, symlinks: map[string]bool{fixture.core: true}} + got := FindWorkspaceManifest(marked, fixture.child) + core.AssertEqual(t, "", got) +} + +func TestResolve_ResolveWorkspaceManifest_Good(t *core.T) { + m := coreio.NewMockMedium() + root := core.PathJoin("/", "workspace", "resolve") + + core.AssertNoError(t, m.EnsureDir(core.PathJoin(root, ".core"))) + core.AssertNoError(t, m.Write(core.PathJoin(root, ".core", FileWorkspace), "version: 1\ndependencies:\n - core-php\nactive: core-php\npackages_dir: ./packages\nsettings:\n suggest_core_commands: true\n")) + + manifest, err := workspaceManifestResult(ResolveWorkspaceManifest(m, root)) + core.AssertNoError(t, err) + core.AssertNotNil(t, manifest) + core.AssertEqual(t, []string{"core-php"}, manifest.Dependencies) + core.AssertEqual(t, "core-php", manifest.Active) + core.AssertEqual(t, "./packages", manifest.PackagesDir) + core.AssertEqual(t, true, manifest.Settings["suggest_core_commands"]) +} + +func TestResolve_ResolveWorkspaceManifest_Bad(t *core.T) { + m := coreio.NewMockMedium() + + manifest, err := workspaceManifestResult(ResolveWorkspaceManifest(m, core.PathJoin("/", "workspace", "missing"))) + core.AssertNil(t, manifest) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "no workspace manifest could be detected") +} + +func TestResolve_ResolveWorkspaceManifest_Ugly(t *core.T) { + m := coreio.NewMockMedium() + root := core.PathJoin("/", "workspace", "ugly") + + core.AssertNoError(t, m.EnsureDir(core.PathJoin(root, ".core"))) + core.AssertNoError(t, m.Write(core.PathJoin(root, ".core", FileWorkspace), "version: [broken yaml")) + + manifest, err := workspaceManifestResult(ResolveWorkspaceManifest(m, root)) + core.AssertNil(t, manifest) + core.AssertError(t, err) +} + +func TestResolve_FindIDEManifest_Good(t *core.T) { + fixture := axResolveProjectFixture(t) + got := FindIDEManifest(fixture.medium, fixture.child) + core.AssertEqual(t, core.PathJoin(fixture.core, FileIDE), got) +} + +func TestResolve_FindIDEManifest_Bad(t *core.T) { + m := coreio.NewMockMedium() + got := FindIDEManifest(m, t.TempDir()) + core.AssertEqual(t, "", got) +} + +func TestResolve_FindIDEManifest_Ugly(t *core.T) { + fixture := axResolveProjectFixture(t) + marked := symlinkMockMedium{MockMedium: fixture.medium, symlinks: map[string]bool{fixture.core: true}} + got := FindIDEManifest(marked, fixture.child) + core.AssertEqual(t, "", got) +} + +func TestResolve_ResolveIDEManifest_Good(t *core.T) { + fixture := axResolveProjectFixture(t) + got, err := ideManifestResult(ResolveIDEManifest(fixture.medium, fixture.child)) + core.AssertNoError(t, err) + core.AssertEqual(t, "codex", got.Editor) +} + +func TestResolve_ResolveIDEManifest_Bad(t *core.T) { + m := coreio.NewMockMedium() + got, err := ideManifestResult(ResolveIDEManifest(m, t.TempDir())) + core.AssertNil(t, got) + core.AssertError(t, err) +} + +func TestResolve_ResolveIDEManifest_Ugly(t *core.T) { + fixture := axResolveProjectFixture(t) + core.RequireNoError(t, fixture.medium.Write(core.PathJoin(fixture.core, FileIDE), resolveTestBrokenVersionYAML)) + got, err := ideManifestResult(ResolveIDEManifest(fixture.medium, fixture.child)) + core.AssertNil(t, got) + core.AssertError(t, err) +} + +func TestResolve_FindLinuxKitDirectory_Good(t *core.T) { + fixture := axResolveProjectFixture(t) + got := FindLinuxKitDirectory(fixture.medium, fixture.child) + core.AssertEqual(t, core.PathJoin(fixture.core, LinuxKitDirectory), got) +} + +func TestResolve_FindLinuxKitDirectory_Bad(t *core.T) { + m := coreio.NewMockMedium() + got := FindLinuxKitDirectory(m, t.TempDir()) + core.AssertEqual(t, "", got) +} + +func TestResolve_FindLinuxKitDirectory_Ugly(t *core.T) { + fixture := axResolveProjectFixture(t) + marked := symlinkMockMedium{MockMedium: fixture.medium, symlinks: map[string]bool{fixture.core: true}} + got := FindLinuxKitDirectory(marked, fixture.child) + core.AssertEqual(t, "", got) +} + +func TestResolve_FindLinuxKitManifest_Bad(t *core.T) { + m := coreio.NewMockMedium() + got := FindLinuxKitManifest(m, t.TempDir()) + core.AssertEqual(t, "", got) +} + +func TestResolve_FindLinuxKitManifest_Ugly(t *core.T) { + fixture := axResolveProjectFixture(t) + marked := symlinkMockMedium{MockMedium: fixture.medium, symlinks: map[string]bool{fixture.core: true}} + got := FindLinuxKitManifest(marked, fixture.child) + core.AssertEqual(t, "", got) +} + +func TestResolve_ResolveLinuxKitManifest_Ugly(t *core.T) { + fixture := axResolveProjectFixture(t) + core.RequireNoError(t, fixture.medium.Write(core.PathJoin(fixture.core, LinuxKitDirectory, FileLinuxKit), "kernel: [broken")) + got, err := linuxKitManifestResult(ResolveLinuxKitManifest(fixture.medium, fixture.child)) + core.AssertNil(t, got) + core.AssertError(t, err) +} + +func TestResolve_WorkspaceSandboxRoot_Good(t *core.T) { + got := WorkspaceSandboxRoot("repo", "dev") + core.AssertContains(t, got, core.PathJoin(Directory, WorkspaceDirectory, "repo", "dev")) + core.AssertNotContains(t, got, WorkspaceSourceDirectory) +} + +func TestResolve_WorkspaceSandboxRoot_Bad(t *core.T) { + got := WorkspaceSandboxRoot(resolveTestParentRepoPath, "dev") + core.AssertEqual(t, "", got) + core.AssertEmpty(t, got) +} + +func TestResolve_WorkspaceSandboxRoot_Ugly(t *core.T) { + got := WorkspaceSandboxRoot("", "") + core.AssertEqual(t, core.PathJoin(core.Env("DIR_HOME"), Directory, WorkspaceDirectory), got) + core.AssertContains(t, got, WorkspaceDirectory) +} + +func TestResolve_WorkspaceSandboxSourcePath_Good(t *core.T) { + got := WorkspaceSandboxSourcePath("repo", "dev", "app", resolveTestMainGo) + core.AssertContains(t, got, core.PathJoin(WorkspaceSourceDirectory, "app", resolveTestMainGo)) + core.AssertContains(t, got, "repo") +} + +func TestResolve_WorkspaceSandboxSourcePath_Bad(t *core.T) { + got := WorkspaceSandboxSourcePath("repo", "../dev") + core.AssertEqual(t, "", got) + core.AssertEmpty(t, got) +} + +func TestResolve_WorkspaceSandboxSourcePath_Ugly(t *core.T) { + got := WorkspaceSandboxSourcePath("", "", "") + core.AssertEqual(t, core.PathJoin(core.Env("DIR_HOME"), Directory, WorkspaceDirectory, WorkspaceSourceDirectory), got) + core.AssertContains(t, got, WorkspaceSourceDirectory) +} + +func TestResolve_WorkspaceSandboxMetaPath_Good(t *core.T) { + got := WorkspaceSandboxMetaPath("repo", "dev", resolveTestStatusJSON) + core.AssertContains(t, got, core.PathJoin(WorkspaceMetaDirectory, resolveTestStatusJSON)) + core.AssertContains(t, got, "dev") +} + +func TestResolve_WorkspaceSandboxMetaPath_Bad(t *core.T) { + got := WorkspaceSandboxMetaPath("repo", "dev", "../status.json") + core.AssertEqual(t, "", got) + core.AssertEmpty(t, got) +} + +func TestResolve_WorkspaceSandboxMetaPath_Ugly(t *core.T) { + got := WorkspaceSandboxMetaPath("", "", "") + core.AssertEqual(t, core.PathJoin(core.Env("DIR_HOME"), Directory, WorkspaceDirectory, WorkspaceMetaDirectory), got) + core.AssertContains(t, got, WorkspaceMetaDirectory) +} + +func TestResolve_WorkspaceSandboxInstructionsPath_Good(t *core.T) { + got := WorkspaceSandboxInstructionsPath("repo", "dev") + core.AssertContains(t, got, WorkspaceInstructionsFile) + core.AssertContains(t, got, "repo") +} + +func TestResolve_WorkspaceSandboxInstructionsPath_Bad(t *core.T) { + got := WorkspaceSandboxInstructionsPath(resolveTestParentRepoPath, "dev") + core.AssertEqual(t, "", got) + core.AssertEmpty(t, got) +} + +func TestResolve_WorkspaceSandboxInstructionsPath_Ugly(t *core.T) { + got := WorkspaceSandboxInstructionsPath("", "") + core.AssertEqual(t, core.PathJoin(core.Env("DIR_HOME"), Directory, WorkspaceDirectory, WorkspaceInstructionsFile), got) + core.AssertContains(t, got, WorkspaceInstructionsFile) +} + +func TestResolve_WorkspaceSandboxPath_Bad(t *core.T) { + got := WorkspaceSandboxPath("repo", "dev", "../secret") + core.AssertEqual(t, "", got) + core.AssertEmpty(t, got) +} + +func TestResolve_FindReposManifest_Bad(t *core.T) { + m := coreio.NewMockMedium() + got := FindReposManifest(m, core.PathJoin(t.TempDir(), "repo")) + core.AssertEqual(t, "", got) +} + +func TestResolve_FindReposManifest_Ugly(t *core.T) { + home := core.Env("DIR_HOME") + coreDir := core.PathJoin(home, "Code", Directory) + m := symlinkMockMedium{MockMedium: coreio.NewMockMedium(), symlinks: map[string]bool{coreDir: true}} + core.RequireNoError(t, m.EnsureDir(coreDir)) + core.RequireNoError(t, m.Write(core.PathJoin(coreDir, FileRepos), "version: 1\norg: ax\nrepos: []\n")) + got := FindReposManifest(m, core.PathJoin(t.TempDir(), "repo")) + core.AssertEqual(t, "", got) +} + +func TestResolve_ResolveReposManifest_Good(t *core.T) { + m := coreio.NewMockMedium() + workspace := core.PathJoin(t.TempDir(), "workspace") + start := core.PathJoin(workspace, "repo", "service") + core.RequireNoError(t, m.EnsureDir(core.PathJoin(workspace, Directory))) + core.RequireNoError(t, m.EnsureDir(start)) + core.RequireNoError(t, m.Write(core.PathJoin(workspace, Directory, FileRepos), "version: 1\norg: ax\nrepos:\n - path: core/go\n remote: ssh://example/core/go.git\n")) + got, err := reposManifestResult(ResolveReposManifest(m, start)) + core.AssertNoError(t, err) + core.AssertEqual(t, "ax", got.Org) +} + +func TestResolve_ResolveReposManifest_Bad(t *core.T) { + m := coreio.NewMockMedium() + got, err := reposManifestResult(ResolveReposManifest(m, t.TempDir())) + core.AssertNil(t, got) + core.AssertError(t, err) +} + +func TestResolve_ResolveReposManifest_Ugly(t *core.T) { + m := coreio.NewMockMedium() + workspace := core.PathJoin(t.TempDir(), "workspace") + start := core.PathJoin(workspace, "repo", "service") + core.RequireNoError(t, m.EnsureDir(core.PathJoin(workspace, Directory))) + core.RequireNoError(t, m.EnsureDir(start)) + core.RequireNoError(t, m.Write(core.PathJoin(workspace, Directory, FileRepos), resolveTestBrokenVersionYAML)) + got, err := reposManifestResult(ResolveReposManifest(m, start)) + core.AssertNil(t, got) + core.AssertError(t, err) +} + +func TestResolve_FindPHPManifest_Bad(t *core.T) { + m := coreio.NewMockMedium() + got := FindPHPManifest(m, t.TempDir()) + core.AssertEqual(t, "", got) +} + +func TestResolve_FindPHPManifest_Ugly(t *core.T) { + fixture := axResolveProjectFixture(t) + marked := symlinkMockMedium{MockMedium: fixture.medium, symlinks: map[string]bool{fixture.core: true}} + got := FindPHPManifest(marked, fixture.child) + core.AssertEqual(t, "", got) +} diff --git a/go/schema.go b/go/schema.go new file mode 100644 index 0000000..feca404 --- /dev/null +++ b/go/schema.go @@ -0,0 +1,76 @@ +package config + +import ( + "embed" + + core "dappco.re/go" + coreerr "dappco.re/go/log" + "github.com/xeipuuv/gojsonschema" +) + +//go:embed schema/*.schema.json +var schemaFS embed.FS + +var manifestSchemas = map[string]string{ + FileConfig: "schema/config.schema.json", + FileBuild: "schema/build.schema.json", + FileRelease: "schema/release.schema.json", + FileTest: "schema/test.schema.json", + FileRun: "schema/run.schema.json", + FileView: "schema/view.schema.json", + FileManifest: "schema/manifest.schema.json", + FileWorkspace: "schema/workspace.schema.json", + FileRepos: "schema/repos.schema.json", + FileIDE: "schema/ide.schema.json", + FilePHP: "schema/php.schema.json", + FileAgent: "schema/agent.schema.json", + FileZone: "schema/zone.schema.json", +} + +const callerValidateSchema = "config.validateSchema" + +// validateSchema applies an embedded JSON schema when the current filename has +// one. Empty documents are treated as absent config and skip validation so +// blank files remain a non-error baseline. +func validateSchema(path string, raw map[string]any) core.Result { + if len(raw) == 0 { + return core.Ok(nil) + } + + schemaPath, ok := manifestSchemas[core.PathBase(path)] + if !ok { + return core.Ok(nil) + } + + schemaBody, err := schemaFS.ReadFile(schemaPath) + if err != nil { + return core.Fail(coreerr.E(callerValidateSchema, "failed to read embedded schema: "+schemaPath, err)) + } + + documentResult := core.JSONMarshal(raw) + if !documentResult.OK { + return core.Fail(coreerr.E(callerValidateSchema, "failed to encode config for schema validation: "+path, resultCause(documentResult).(error))) + } + documentBody := documentResult.Value.([]byte) + + result, err := gojsonschema.Validate( + gojsonschema.NewBytesLoader(schemaBody), + gojsonschema.NewBytesLoader(documentBody), + ) + if err != nil { + return core.Fail(coreerr.E(callerValidateSchema, "schema validation failed: "+path, err)) + } + if result.Valid() { + return core.Ok(nil) + } + + var problems []string + for _, issue := range result.Errors() { + problems = append(problems, issue.String()) + } + return core.Fail(coreerr.E( + callerValidateSchema, + "schema validation failed: "+path+": "+core.Join("; ", problems...), + nil, + )) +} diff --git a/go/schema/agent.schema.json b/go/schema/agent.schema.json new file mode 100644 index 0000000..64759aa --- /dev/null +++ b/go/schema/agent.schema.json @@ -0,0 +1,54 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "daemon": { + "type": "object", + "properties": { + "enabled": { "type": "boolean" }, + "watch": { + "type": "array", + "items": { "type": "string" } + }, + "schedule": { + "type": "array", + "items": { + "type": "object", + "properties": { + "cron": { "type": "string" }, + "action": { "type": "string" } + }, + "additionalProperties": true + } + }, + "mcp": { + "type": "object", + "properties": { + "port": { "type": "integer", "minimum": 1, "maximum": 65535 } + }, + "additionalProperties": true + }, + "api": { + "type": "object", + "properties": { + "port": { "type": "integer", "minimum": 1, "maximum": 65535 }, + "bind": { "type": "string" } + }, + "additionalProperties": true + } + }, + "additionalProperties": true + }, + "agents": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "total": { "type": "integer", "minimum": 0 } + }, + "additionalProperties": true + } + } + }, + "additionalProperties": true +} diff --git a/go/schema/build.schema.json b/go/schema/build.schema.json new file mode 100644 index 0000000..df00e11 --- /dev/null +++ b/go/schema/build.schema.json @@ -0,0 +1,112 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "version": { "type": "integer" }, + "project": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "main": { "type": "string" }, + "binary": { "type": "string" }, + "output": { "type": "string" } + }, + "additionalProperties": true + }, + "build": { + "type": "object", + "properties": { + "type": { "type": "string" }, + "cgo": { "type": "boolean" }, + "flags": { + "type": "array", + "items": { "type": "string" } + }, + "ldflags": { + "oneOf": [ + { "type": "string" }, + { + "type": "array", + "items": { "type": "string" } + } + ] + } + }, + "additionalProperties": true + }, + "targets": { + "type": "array", + "items": { + "oneOf": [ + { "type": "string" }, + { + "type": "object", + "properties": { + "os": { "type": "string" }, + "arch": { "type": "string" } + }, + "additionalProperties": true + } + ] + } + }, + "sign": { + "type": "object", + "properties": { + "enabled": { "type": "boolean" }, + "gpg": { + "type": "object", + "properties": { + "key": { "type": "string" } + }, + "additionalProperties": true + }, + "macos": { + "type": "object", + "properties": { + "identity": { "type": "string" }, + "notarize": { "type": "boolean" } + }, + "additionalProperties": true + } + }, + "additionalProperties": true + }, + "sdk": { + "type": "object", + "properties": { + "spec": { "type": "string" }, + "languages": { + "type": "array", + "items": { "type": "string" } + }, + "output": { "type": "string" }, + "diff": { "type": "boolean" } + }, + "additionalProperties": true + }, + "name": { "type": "string" }, + "main": { "type": "string" }, + "binary": { "type": "string" }, + "output": { "type": "string" }, + "flags": { + "type": "array", + "items": { "type": "string" } + }, + "ldflags": { + "oneOf": [ + { "type": "string" }, + { + "type": "array", + "items": { "type": "string" } + } + ] + }, + "cgo": { "type": "boolean" }, + "env": { + "type": "object", + "additionalProperties": { "type": "string" } + } + }, + "additionalProperties": true +} diff --git a/go/schema/config.schema.json b/go/schema/config.schema.json new file mode 100644 index 0000000..81086a9 --- /dev/null +++ b/go/schema/config.schema.json @@ -0,0 +1,40 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "version": { + "type": "integer" + }, + "app": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "version": { "type": "string" } + }, + "additionalProperties": true + }, + "dev": { + "type": "object", + "properties": { + "editor": { "type": "string" }, + "language": { "type": "string" } + }, + "additionalProperties": true + }, + "features": { + "type": "object", + "additionalProperties": { "type": "boolean" } + }, + "services": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "url": { "type": "string" } + }, + "additionalProperties": true + } + } + }, + "additionalProperties": true +} diff --git a/go/schema/ide.schema.json b/go/schema/ide.schema.json new file mode 100644 index 0000000..9bd76ab --- /dev/null +++ b/go/schema/ide.schema.json @@ -0,0 +1,8 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "version": { "type": "integer" } + }, + "additionalProperties": true +} diff --git a/go/schema/images.schema.json b/go/schema/images.schema.json new file mode 100644 index 0000000..5c383f6 --- /dev/null +++ b/go/schema/images.schema.json @@ -0,0 +1,25 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "images": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "version": { "type": "string" }, + "sha256": { + "type": "string", + "pattern": "^[A-Fa-f0-9]{64}$", + "minLength": 64, + "maxLength": 64 + }, + "downloaded": { "type": "string", "format": "date-time" }, + "source": { "type": "string" } + }, + "additionalProperties": true + } + } + }, + "additionalProperties": true +} diff --git a/go/schema/manifest.schema.json b/go/schema/manifest.schema.json new file mode 100644 index 0000000..8402931 --- /dev/null +++ b/go/schema/manifest.schema.json @@ -0,0 +1,23 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "code": { "type": "string" }, + "name": { "type": "string" }, + "module": { "type": "string" }, + "version": { "type": "string" }, + "description": { "type": "string" }, + "licence": { "type": "string" }, + "sign": { "type": "string" }, + "sign_key": { "type": "string" }, + "dependencies": { + "type": "array", + "items": { "type": "string" } + }, + "tags": { + "type": "array", + "items": { "type": "string" } + } + }, + "additionalProperties": true +} diff --git a/go/schema/php.schema.json b/go/schema/php.schema.json new file mode 100644 index 0000000..9bd76ab --- /dev/null +++ b/go/schema/php.schema.json @@ -0,0 +1,8 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "version": { "type": "integer" } + }, + "additionalProperties": true +} diff --git a/go/schema/release.schema.json b/go/schema/release.schema.json new file mode 100644 index 0000000..f73ecb0 --- /dev/null +++ b/go/schema/release.schema.json @@ -0,0 +1,38 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "version": { "type": "integer" }, + "archive": { + "type": "object", + "properties": { + "format": { "type": "string" }, + "include": { + "type": "array", + "items": { "type": "string" } + } + }, + "additionalProperties": true + }, + "checksums": { "type": "boolean" }, + "github": { + "type": "object", + "properties": { + "draft": { "type": "boolean" }, + "prerelease": { "type": "boolean" } + }, + "additionalProperties": true + }, + "changelog": { + "type": "object", + "properties": { + "include": { + "type": "array", + "items": { "type": "string" } + } + }, + "additionalProperties": true + } + }, + "additionalProperties": true +} diff --git a/go/schema/repos.schema.json b/go/schema/repos.schema.json new file mode 100644 index 0000000..40941e8 --- /dev/null +++ b/go/schema/repos.schema.json @@ -0,0 +1,27 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "version": { "type": "integer" }, + "org": { "type": "string" }, + "repos": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { "type": "string" }, + "remote": { "type": "string" }, + "branch": { "type": "string" }, + "type": { "type": "string" }, + "description": { "type": "string" }, + "depends": { + "type": "array", + "items": { "type": "string" } + } + }, + "additionalProperties": true + } + } + }, + "additionalProperties": true +} diff --git a/go/schema/run.schema.json b/go/schema/run.schema.json new file mode 100644 index 0000000..90550ce --- /dev/null +++ b/go/schema/run.schema.json @@ -0,0 +1,40 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "version": { "type": "integer" }, + "services": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "image": { "type": "string" }, + "port": { "type": "integer", "minimum": 1, "maximum": 65535 }, + "env": { + "type": "object", + "additionalProperties": { "type": "string" } + } + }, + "additionalProperties": true + } + }, + "dev": { + "type": "object", + "properties": { + "command": { "type": "string" }, + "port": { "type": "integer", "minimum": 1, "maximum": 65535 }, + "watch": { + "type": "array", + "items": { "type": "string" } + } + }, + "additionalProperties": true + }, + "env": { + "type": "object", + "additionalProperties": { "type": "string" } + } + }, + "additionalProperties": true +} diff --git a/go/schema/test.schema.json b/go/schema/test.schema.json new file mode 100644 index 0000000..e4fe73e --- /dev/null +++ b/go/schema/test.schema.json @@ -0,0 +1,23 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "version": { "type": "integer" }, + "commands": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "run": { "type": "string" } + }, + "additionalProperties": true + } + }, + "env": { + "type": "object", + "additionalProperties": { "type": "string" } + } + }, + "additionalProperties": true +} diff --git a/go/schema/view.schema.json b/go/schema/view.schema.json new file mode 100644 index 0000000..9a62a8b --- /dev/null +++ b/go/schema/view.schema.json @@ -0,0 +1,52 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "version": { "type": ["integer", "string"] }, + "code": { "type": "string" }, + "name": { "type": "string" }, + "sign": { "type": "string" }, + "title": { "type": "string" }, + "width": { "type": "integer", "minimum": 0 }, + "height": { "type": "integer", "minimum": 0 }, + "resizable": { "type": "boolean" }, + "layout": { "type": "string" }, + "slots": { + "type": "object", + "additionalProperties": true + }, + "modules": { + "type": "array", + "items": { "type": "string" } + }, + "permissions": { + "type": "object", + "properties": { + "clipboard": { "type": "boolean" }, + "filesystem": { "type": "boolean" }, + "network": { "type": "boolean" }, + "notifications": { "type": "boolean" }, + "camera": { "type": "boolean" }, + "microphone": { "type": "boolean" }, + "read": { + "type": "array", + "items": { "type": "string" } + }, + "net": { + "type": "array", + "items": { "type": "string" } + }, + "run": { + "type": "array", + "items": { "type": "string" } + } + }, + "additionalProperties": true + }, + "config": { + "type": "object", + "additionalProperties": true + } + }, + "additionalProperties": true +} diff --git a/go/schema/workspace.schema.json b/go/schema/workspace.schema.json new file mode 100644 index 0000000..9947f20 --- /dev/null +++ b/go/schema/workspace.schema.json @@ -0,0 +1,18 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "version": { "type": "integer" }, + "dependencies": { + "type": "array", + "items": { "type": "string" } + }, + "active": { "type": "string" }, + "packages_dir": { "type": "string" }, + "settings": { + "type": "object", + "additionalProperties": true + } + }, + "additionalProperties": true +} diff --git a/go/schema/zone.schema.json b/go/schema/zone.schema.json new file mode 100644 index 0000000..7b576e4 --- /dev/null +++ b/go/schema/zone.schema.json @@ -0,0 +1,87 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "required": ["zone"], + "properties": { + "zone": { + "type": "object", + "required": ["name", "identity"], + "properties": { + "name": { "type": "string" }, + "identity": { "type": "string" }, + "chain": { + "type": "object", + "required": ["mode", "daemon"], + "properties": { + "mode": { "type": "string" }, + "daemon": { "type": "string" } + }, + "additionalProperties": false + }, + "network": { + "type": "object", + "required": ["wireguard"], + "properties": { + "wireguard": { + "type": "object", + "required": ["interface", "listen"], + "properties": { + "interface": { "type": "string" }, + "listen": { "type": "integer", "minimum": 1, "maximum": 65535 } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "services": { + "type": "object", + "properties": { + "vpn": { + "type": "object", + "required": ["enabled"], + "properties": { + "enabled": { "type": "boolean" }, + "price": { "type": "number", "minimum": 0 }, + "capacity": { "type": "integer", "minimum": 0 } + }, + "additionalProperties": false + }, + "dns": { + "type": "object", + "required": ["enabled"], + "properties": { + "enabled": { "type": "boolean" } + }, + "additionalProperties": false + }, + "compute": { + "type": "object", + "required": ["enabled", "models"], + "properties": { + "enabled": { "type": "boolean" }, + "models": { + "type": "array", + "items": { "type": "string" } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "staking": { + "type": "object", + "required": ["amount", "tier"], + "properties": { + "amount": { "type": "integer", "minimum": 0 }, + "tier": { "type": "string" } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false +} diff --git a/go/schema_test.go b/go/schema_test.go new file mode 100644 index 0000000..56fc9d7 --- /dev/null +++ b/go/schema_test.go @@ -0,0 +1,34 @@ +package config + +import core "dappco.re/go" + +func TestSchema_validateSchema_Good(t *core.T) { + raw := map[string]any{ + "version": 1, + "app": map[string]any{ + "name": "core", + "version": "0.1.0", + }, + } + + core.AssertNoError(t, resultError(validateSchema("/tmp/.core/config.yaml", raw))) +} + +func TestSchema_validateSchema_Bad(t *core.T) { + raw := map[string]any{ + "targets": 42, + } + + err := resultError(validateSchema("/tmp/.core/build.yaml", raw)) + if err == nil { + t.Fatal("expected schema validation error") + } + core.AssertContains(t, err.Error(), "schema validation failed") +} + +func TestSchema_validateSchema_Ugly(t *core.T) { + unknownErr := validateSchema("/tmp/.core/notes.txt", map[string]any{"anything": "goes"}) + emptyErr := validateSchema("/tmp/.core/config.yaml", map[string]any{}) + core.AssertNoError(t, resultError(unknownErr)) + core.AssertNoError(t, resultError(emptyErr)) +} diff --git a/go/service.go b/go/service.go new file mode 100644 index 0000000..1365fd9 --- /dev/null +++ b/go/service.go @@ -0,0 +1,486 @@ +package config + +import ( + "context" + "reflect" + + core "dappco.re/go" + coreio "dappco.re/go/io" + coreerr "dappco.re/go/log" +) + +const ( + callerValidateServiceLoadPath = "config.validateServiceLoadPath" + + actionConfigGet = "config.get" + actionConfigSet = "config.set" + actionConfigCommit = "config.commit" + actionConfigLoad = "config.load" + actionConfigAll = "config.all" + actionConfigPath = "config.path" + + commandConfigGet = "config/get" + commandConfigSet = "config/set" + commandConfigList = "config/list" + commandConfigCommit = "config/commit" + commandConfigLoad = "config/load" + commandConfigAll = "config/all" + commandConfigPath = "config/path" + + errConfigNotLoaded = "config not loaded" + + optionKeyPath = "pa" + "th" +) + +type configOperation func(*Service, *Config, core.Options) core.Result + +type serviceLoadPathResolution struct { + Candidate string + Core string +} + +// Service wraps Config as a framework service with lifecycle support. +// +// c := core.New(core.WithService(config.NewConfigService)) +// svc := c.Config() +type Service struct { + *core.ServiceRuntime[ServiceOptions] + config *Config + store ConfigStoreWriter +} + +// ServiceOptions holds configuration for the config service. +type ServiceOptions struct { + // Path overrides the default config file path. + Path string + // EnvPrefix overrides the default environment variable prefix. + EnvPrefix string + // Medium overrides the default storage medium. + Medium coreio.Medium + // Store mirrors Set() calls into go-store when present. + Store ConfigStoreWriter +} + +// NewConfigService creates a new config service factory for the Core framework. +// Register it with core.WithService(config.NewConfigService). The returned +// Result carries the *Service instance so core.WithService can auto-discover +// the "config" service name from the package path and wire it into the +// lifecycle and IPC bus. +// +// c := core.New(core.WithService(config.NewConfigService)) +// svc, _ := core.ServiceFor[*config.Service](c, "config") +func NewConfigService(c *core.Core) core.Result { + svc := &Service{ + ServiceRuntime: core.NewServiceRuntime(c, ServiceOptions{}), + } + return core.Ok(svc) +} + +// NewConfigServiceWith returns a service factory pre-populated with the given +// options. Use this when the default path / medium / env prefix aren't right +// for the host application. +// +// c := core.New(core.WithService(config.NewConfigServiceWith(config.ServiceOptions{ +// Path: "/etc/myapp/config.yaml", +// EnvPrefix: "MYAPP", +// }))) +func NewConfigServiceWith(opts ServiceOptions) func(*core.Core) core.Result { + return func(c *core.Core) core.Result { + svc := &Service{ + ServiceRuntime: core.NewServiceRuntime(c, opts), + } + return core.Ok(svc) + } +} + +// OnStartup loads the configuration file during application startup +// and registers named actions and commands on the Core. +// +// func (s *Service) OnStartup(ctx context.Context) core.Result +func (s *Service) OnStartup(_ context.Context) core.Result { + opts := s.Options() + + var configOpts []Option + if opts.Path != "" { + configOpts = append(configOpts, WithPath(opts.Path)) + } + if opts.EnvPrefix != "" { + configOpts = append(configOpts, WithEnvPrefix(opts.EnvPrefix)) + } + if opts.Medium != nil { + configOpts = append(configOpts, WithMedium(opts.Medium)) + } + s.store = opts.Store + if s.store == nil { + s.store = discoverStoreWriter(s.Core()) + } + if s.store != nil { + configOpts = append(configOpts, WithStore(s.store)) + } + + cfgResult := newServiceConfig(opts, configOpts) + if !cfgResult.OK { + return core.Fail(coreerr.E("config.Service.OnStartup", "failed to create config", resultCause(cfgResult).(error))) + } + + s.config = cfgResult.Value.(*Config) + + // Publish the loaded config as the process-wide feature source so + // config.Feature() reflects the current .core/config.yaml by default. + SetFeatureSource(s.config) + + if c := s.Core(); c != nil { + s.config.AttachCore(c) + s.registerActions(c) + s.registerCommands(c) + } + + return core.Ok(nil) +} + +func newServiceConfig(opts ServiceOptions, configOpts []Option) core.Result { + if opts.Path == "" { + return Discover(configOpts...) + } + if isDiscoverableConfigPath(opts.Path) { + return DiscoverFrom(serviceProjectRoot(opts.Path), configOpts...) + } + return New(configOpts...) +} + +func isDiscoverableConfigPath(path string) bool { + clean := core.CleanPath(path, string(core.PathSeparator)) + return core.PathBase(clean) == FileConfig && core.PathBase(core.PathDir(clean)) == Directory +} + +// OnShutdown releases any active config-file watcher created during the +// service lifetime so fsnotify descriptors do not leak across restarts. +func (s *Service) OnShutdown(_ context.Context) core.Result { + if s.config != nil { + s.config.StopWatch() + } + return core.Ok(nil) +} + +func resolveValidatedServiceLoadPath(basePath, path string) core.Result { + cleanResult := validateServiceLoadPathInput(path) + if !cleanResult.OK { + return cleanResult + } + clean := cleanResult.Value.(string) + if basePath != "" { + return resolveServiceLoadPathWithinCore(basePath, clean, path) + } + return validateServiceConfigPath(clean) +} + +func validateServiceLoadPathInput(path string) core.Result { + if path == "" { + return core.Fail(coreerr.E(callerValidateServiceLoadPath, "empty config path", nil)) + } + if core.PathIsAbs(path) { + return core.Fail(coreerr.E(callerValidateServiceLoadPath, "absolute config paths are not allowed: "+path, nil)) + } + + clean := core.CleanPath(path, string(core.PathSeparator)) + if clean == "." || clean == ".." || core.HasPrefix(clean, ".."+string(core.PathSeparator)) { + return core.Fail(coreerr.E(callerValidateServiceLoadPath, "path traversal rejected: "+path, nil)) + } + if !isProjectCoreRelativePath(clean) { + return core.Fail(coreerr.E(callerValidateServiceLoadPath, "config paths must remain under .core/: "+path, nil)) + } + return core.Ok(clean) +} + +func resolveServiceLoadPathWithinCore(basePath, clean, original string) core.Result { + projectRoot := serviceProjectRoot(basePath) + corePath := core.CleanPath(core.PathJoin(projectRoot, Directory), string(core.PathSeparator)) + absCoreResult := core.PathAbs(corePath) + if !absCoreResult.OK { + return core.Fail(coreerr.E(callerValidateServiceLoadPath, "resolve config base failed: "+original, resultCause(absCoreResult).(error))) + } + absCorePath := absCoreResult.Value.(string) + + candidatePath := core.CleanPath(core.PathJoin(projectRoot, clean), string(core.PathSeparator)) + absCandidateResult := core.PathAbs(candidatePath) + if !absCandidateResult.OK { + return core.Fail(coreerr.E(callerValidateServiceLoadPath, "resolve config path failed: "+original, resultCause(absCandidateResult).(error))) + } + absCandidate := absCandidateResult.Value.(string) + + resolutionResult := resolveServiceLoadPath(candidatePath, absCorePath, absCandidate) + if !resolutionResult.OK { + return resolutionResult + } + resolution := resolutionResult.Value.(serviceLoadPathResolution) + if r := ensureServicePathInsideCore(resolution.Core, resolution.Candidate, original); !r.OK { + return r + } + return validateServiceConfigPath(candidatePath) +} + +func ensureServicePathInsideCore(coreAbs, candidateAbs, original string) core.Result { + relResult := core.PathRel(coreAbs, candidateAbs) + if !relResult.OK { + return core.Fail(coreerr.E(callerValidateServiceLoadPath, "failed relative path check: "+original, resultCause(relResult).(error))) + } + rel := relResult.Value.(string) + if rel != "." && (rel == ".." || core.HasPrefix(rel, ".."+string(core.PathSeparator))) { + return core.Fail(coreerr.E(callerValidateServiceLoadPath, "config path escapes .core/: "+original, nil)) + } + return core.Ok(nil) +} + +func validateServiceConfigPath(path string) core.Result { + if r := configTypeForPath(path); !r.OK { + return r + } + return core.Ok(path) +} + +func serviceProjectRoot(basePath string) string { + baseDir := core.CleanPath(core.PathDir(basePath), string(core.PathSeparator)) + if core.PathBase(baseDir) == Directory { + return core.PathDir(baseDir) + } + return baseDir +} + +func isProjectCoreRelativePath(path string) bool { + return path == Directory || core.HasPrefix(path, Directory+string(core.PathSeparator)) +} + +func resolveServiceLoadPath(candidatePath, coreAbs, absCandidate string) core.Result { + resolvedCore := coreAbs + if r := core.Lstat(coreAbs); r.OK && r.Value.(core.FsFileInfo).Mode()&core.ModeSymlink != 0 { + return core.Fail(coreerr.E(callerValidateServiceLoadPath, "symlinked .core directories are not allowed: "+coreAbs, nil)) + } + if statResult := core.Stat(coreAbs); statResult.OK && statResult.Value.(core.FsFileInfo).IsDir() { + if realCoreResult := core.PathEvalSymlinks(coreAbs); realCoreResult.OK { + resolvedCore = realCoreResult.Value.(string) + } else { + return core.Fail(coreerr.E(callerValidateServiceLoadPath, "resolve .core symlink failed: "+coreAbs, resultCause(realCoreResult).(error))) + } + } + + resolvedCandidate := absCandidate + if core.Stat(absCandidate).OK { + realCandidateResult := core.PathEvalSymlinks(absCandidate) + if !realCandidateResult.OK { + return core.Fail(coreerr.E(callerValidateServiceLoadPath, "resolve config symlink failed: "+candidatePath, resultCause(realCandidateResult).(error))) + } + resolvedCandidate = realCandidateResult.Value.(string) + } + + return core.Ok(serviceLoadPathResolution{Candidate: resolvedCandidate, Core: resolvedCore}) +} + +func resultCause(r core.Result) any { + if err, ok := r.Value.(error); ok { + return err + } + return core.NewError(r.Error()) +} + +// registerActions exposes config.get/set/commit/load/all on the Core IPC bus. +// +// c.Action("config.get").Run(ctx, core.NewOptions(core.Option{Key:"key", Value:"dev.editor"})) +func (s *Service) registerActions(c *core.Core) { + c.Action(actionConfigGet, s.actionHandler(c, actionConfigGet, configGetOperation)) + c.Action(actionConfigSet, s.actionHandler(c, actionConfigSet, configSetOperation)) + c.Action(actionConfigCommit, s.actionHandler(c, actionConfigCommit, configCommitOperation)) + c.Action(actionConfigLoad, s.actionHandler(c, actionConfigLoad, configLoadOperation)) + c.Action(actionConfigAll, s.actionHandler(c, actionConfigAll, configAllOperation)) + c.Action(actionConfigPath, s.actionHandler(c, actionConfigPath, configPathOperation)) +} + +// registerCommands exposes config commands for CLI discovery. +// +// core config/get --key dev.editor +func (s *Service) registerCommands(c *core.Core) { + _ = c.Command(commandConfigGet, s.configCommand("Read a config value", c, commandConfigGet, configGetOperation)) + _ = c.Command(commandConfigSet, s.configCommand("Set a config value", c, commandConfigSet, configSetOperation)) + _ = c.Command(commandConfigList, s.configCommand("List all config values", c, commandConfigList, configAllOperation)) + _ = c.Command(commandConfigCommit, s.configCommand("Persist config changes", c, commandConfigCommit, configCommitOperation)) + _ = c.Command(commandConfigLoad, s.configCommand("Load a config file", c, commandConfigLoad, configLoadOperation)) + _ = c.Command(commandConfigAll, s.configCommand("List all config values", c, commandConfigAll, configAllOperation)) + _ = c.Command(commandConfigPath, s.configCommand("Show the config file path", c, commandConfigPath, configPathOperation)) +} + +func (s *Service) actionHandler(c *core.Core, name string, op configOperation) core.ActionHandler { + return func(_ context.Context, opts core.Options) core.Result { + return s.runConfigOperation(c, name, opts, op) + } +} + +func (s *Service) configCommand(description string, c *core.Core, name string, op configOperation) core.Command { + return core.Command{ + Description: description, + Action: func(opts core.Options) core.Result { + return s.runConfigOperation(c, name, opts, op) + }, + } +} + +func (s *Service) runConfigOperation(c *core.Core, name string, opts core.Options, op configOperation) core.Result { + if result := ensureConfigEntitlement(c, name); !result.OK { + return result + } + if s.config == nil { + return core.Fail(coreerr.E(name, errConfigNotLoaded, nil)) + } + return op(s, s.config, opts) +} + +func configGetOperation(_ *Service, cfg *Config, opts core.Options) core.Result { + key := opts.String("key") + var value any + if r := cfg.Get(key, &value); !r.OK { + return r + } + return core.Ok(value) +} + +func configSetOperation(_ *Service, cfg *Config, opts core.Options) core.Result { + key := opts.String("key") + r := opts.Get("value") + if result := cfg.Set(key, r.Value); !result.OK { + return result + } + return core.Ok(nil) +} + +func configCommitOperation(_ *Service, cfg *Config, _ core.Options) core.Result { + if r := cfg.Commit(); !r.OK { + return r + } + return core.Ok(nil) +} + +func configLoadOperation(s *Service, cfg *Config, opts core.Options) core.Result { + if r := s.LoadFile(cfg.medium, opts.String(optionKeyPath)); !r.OK { + return r + } + return core.Ok(nil) +} + +func configAllOperation(_ *Service, cfg *Config, _ core.Options) core.Result { + return core.Ok(configValues(cfg)) +} + +func configPathOperation(_ *Service, cfg *Config, _ core.Options) core.Result { + return core.Ok(cfg.Path()) +} + +func configValues(cfg *Config) map[string]any { + out := make(map[string]any) + for k, v := range cfg.All() { + out[k] = v + } + return out +} + +// Get retrieves a configuration value by key. +// +// var editor string +// svc.Get("dev.editor", &editor) +func (s *Service) Get(key string, out any) core.Result { + if s.config == nil { + return core.Fail(coreerr.E("config.Service.Get", errConfigNotLoaded, nil)) + } + return s.config.Get(key, out) +} + +// Set stores a configuration value by key. +// +// svc.Set("dev.editor", "vim") +func (s *Service) Set(key string, v any) core.Result { + if s.config == nil { + return core.Fail(coreerr.E("config.Service.Set", errConfigNotLoaded, nil)) + } + return s.config.Set(key, v) +} + +// Commit persists any configuration changes to disk. +// +// svc.Commit() +func (s *Service) Commit() core.Result { + if s.config == nil { + return core.Fail(coreerr.E("config.Service.Commit", errConfigNotLoaded, nil)) + } + return s.config.Commit() +} + +// LoadFile merges a configuration file into the central configuration. +// +// svc.LoadFile(io.Local, ".core/build.yaml") +func (s *Service) LoadFile(m coreio.Medium, path string) core.Result { + if s.config == nil { + return core.Fail(coreerr.E("config.Service.LoadFile", errConfigNotLoaded, nil)) + } + resolvedPathResult := resolveValidatedServiceLoadPath(s.config.Path(), path) + if !resolvedPathResult.OK { + return resolvedPathResult + } + return s.config.LoadFile(m, resolvedPathResult.Value.(string)) +} + +func ensureConfigEntitlement(c *core.Core, action string) core.Result { + if c == nil { + return core.Fail(coreerr.E(action, "core not available", nil)) + } + if e := c.Entitled(action); !e.Allowed { + return core.Fail(coreerr.E(action, "not entitled: "+action+": "+e.Reason, nil)) + } + return core.Ok(nil) +} + +// Config returns the underlying Config instance for advanced operations. +// +// cfg := svc.Config() +// cfg.OnChange(func(k string, v any) { ... }) +func (s *Service) Config() *Config { + return s.config +} + +func discoverStoreWriter(c *core.Core) ConfigStoreWriter { + if c == nil { + return nil + } + + result := c.Service("store") + if !result.OK || result.Value == nil { + return nil + } + if store, ok := result.Value.(ConfigStoreWriter); ok { + return store + } + + ref := reflect.ValueOf(result.Value) + if !ref.IsValid() { + return nil + } + if ref.Kind() == reflect.Ptr && !ref.IsNil() { + ref = ref.Elem() + } + if ref.Kind() != reflect.Struct { + return nil + } + + field := ref.FieldByName("Store") + if !field.IsValid() || !field.CanInterface() { + return nil + } + if (field.Kind() == reflect.Ptr || field.Kind() == reflect.Interface) && field.IsNil() { + return nil + } + store, ok := field.Interface().(ConfigStoreWriter) + if !ok { + return nil + } + return store +} + +// Ensure Service implements lifecycle contracts at compile time. +var _ core.Startable = (*Service)(nil) +var _ core.Stoppable = (*Service)(nil) diff --git a/go/service_example_test.go b/go/service_example_test.go new file mode 100644 index 0000000..693d70b --- /dev/null +++ b/go/service_example_test.go @@ -0,0 +1,89 @@ +package config + +import ( + "context" + + core "dappco.re/go" + coreio "dappco.re/go/io" +) + +func exampleService() (*Service, *coreio.MockMedium) { + m := coreio.NewMockMedium() + path := "/example/.core/config.yaml" + _ = m.Write(path, "app:\n name: service\n") + svc := &Service{ + ServiceRuntime: core.NewServiceRuntime(core.New(), ServiceOptions{ + Path: path, + Medium: m, + }), + } + _ = svc.OnStartup(context.Background()) + return svc, m +} + +func ExampleNewConfigService() { + result := NewConfigService(core.New()) + core.Println(result.OK) + // Output: true +} + +func ExampleNewConfigServiceWith() { + factory := NewConfigServiceWith(ServiceOptions{Path: "/example/.core/config.yaml"}) + result := factory(core.New()) + core.Println(result.OK) + // Output: true +} + +func ExampleService_OnStartup() { + svc, _ := exampleService() + core.Println(svc.Config() != nil) + // Output: true +} + +func ExampleService_OnShutdown() { + svc, _ := exampleService() + result := svc.OnShutdown(context.Background()) + core.Println(result.OK) + // Output: true +} + +func ExampleService_Get() { + svc, _ := exampleService() + var name string + err := resultError(svc.Get("app.name", &name)) + core.Println(err == nil, name) + // Output: true service +} + +func ExampleService_Set() { + svc, _ := exampleService() + err := resultError(svc.Set("dev.editor", "vim")) + var editor string + _ = svc.Get("dev.editor", &editor) + core.Println(err == nil, editor) + // Output: true vim +} + +func ExampleService_Commit() { + svc, m := exampleService() + _ = svc.Set("dev.editor", "vim") + err := resultError(svc.Commit()) + core.Println(err == nil && m.Exists("/example/.core/config.yaml")) + // Output: true +} + +func ExampleService_LoadFile() { + svc, m := exampleService() + _ = m.Write("/example/.core/extra.yaml", "dev:\n shell: zsh\n") + err := resultError(svc.LoadFile(m, ".core/extra.yaml")) + var shell string + _ = svc.Get("dev.shell", &shell) + core.Println(err == nil, shell) + // Output: true zsh +} + +func ExampleService_Config() { + svc, _ := exampleService() + core.Println(svc.Config().Path()) + // Output: /example/.core/config.yaml +} diff --git a/go/service_test.go b/go/service_test.go new file mode 100644 index 0000000..7a104f7 --- /dev/null +++ b/go/service_test.go @@ -0,0 +1,845 @@ +package config + +import ( + "context" + "runtime" + + core "dappco.re/go" + coreio "dappco.re/go/io" +) + +const ( + serviceTestConfigPath = "/tmp/svc/" + FileConfig + serviceTestConfigBody = "app:\n name: svc\n" + serviceTestBadConfigPath = "/bad/" + FileConfig + serviceTestCustomConfigPath = "/tmp/custom/" + FileConfig + serviceTestDevEditorKey = "dev.editor" + serviceTestAppNameKey = "app.name" + serviceTestDevShellKey = "dev.shell" + serviceTestShellYAML = "dev:\n shell: zsh\n" + serviceTestConfigGetAction = "config.get" + serviceTestConfigSetAction = "config.set" + serviceTestConfigCommitAction = "config.commit" + serviceTestConfigLoadAction = "config.load" + serviceTestConfigGetCommand = "config/get" + serviceTestConfigSetCommand = "config/set" + serviceTestConfigCommitCommand = "config/commit" + serviceTestConfigLoadCommand = "config/load" + serviceTestConfigListCommand = "config/list" + serviceTestConfigAllAction = "config.all" + serviceTestConfigPathAction = "config.path" + serviceTestConfigPathCommand = "config/path" + serviceTestOverridePath = ".core/override.yaml" + serviceTestConfigPathsUnderCore = "config paths must remain under .core/" + serviceTestSharedCoreDir = "shared-core" + serviceTestOverrideFilename = "override.yaml" + serviceTestSymlinkedCoreDirsRejected = "symlinked .core directories are not allowed" + serviceTestWindowsSymlinkSkipMessage = "symlink test is not portable on Windows in this environment" +) + +func TestService_OnStartup_Good(t *core.T) { + m := coreio.NewMockMedium() + m.Files[serviceTestConfigPath] = serviceTestConfigBody + + c := core.New() + svc := &Service{ + ServiceRuntime: core.NewServiceRuntime(c, ServiceOptions{ + Path: serviceTestConfigPath, + Medium: m, + }), + } + + result := svc.OnStartup(context.Background()) + core.AssertTrue(t, result.OK) + + var name string + core.AssertNoError(t, resultError(svc.Get(serviceTestAppNameKey, &name))) + core.AssertEqual(t, "svc", name) +} + +func TestService_OnStartup_Bad(t *core.T) { + m := coreio.NewMockMedium() + m.Files[serviceTestBadConfigPath] = "this is: [not: yaml" + + c := core.New() + svc := &Service{ + ServiceRuntime: core.NewServiceRuntime(c, ServiceOptions{ + Path: serviceTestBadConfigPath, + Medium: m, + }), + } + + result := svc.OnStartup(context.Background()) + core.AssertFalse(t, result.OK) +} + +func TestService_OnStartup_RegistersActions_Good(t *core.T) { + m := coreio.NewMockMedium() + m.Files[serviceTestConfigPath] = "dev:\n editor: vim\n" + + c := core.New() + svc := &Service{ + ServiceRuntime: core.NewServiceRuntime(c, ServiceOptions{ + Path: serviceTestConfigPath, + Medium: m, + }), + } + core.AssertTrue(t, svc.OnStartup(context.Background()).OK) + + // config.get must round-trip through the action bus. + result := c.Action(serviceTestConfigGetAction).Run(context.Background(), core.NewOptions(core.Option{Key: "key", Value: serviceTestDevEditorKey})) + core.AssertTrue(t, result.OK) + core.AssertEqual(t, "vim", result.Value) + + // config.set stores a value; config.get reads it back. + setResult := c.Action(serviceTestConfigSetAction).Run(context.Background(), core.NewOptions( + core.Option{Key: "key", Value: serviceTestDevShellKey}, + core.Option{Key: "value", Value: "zsh"}, + )) + core.AssertTrue(t, setResult.OK) + + readResult := c.Action(serviceTestConfigGetAction).Run(context.Background(), core.NewOptions(core.Option{Key: "key", Value: serviceTestDevShellKey})) + core.AssertTrue(t, readResult.OK) + core.AssertEqual(t, "zsh", readResult.Value) +} + +func TestService_OnStartup_RegistersCommands_Good(t *core.T) { + m := coreio.NewMockMedium() + m.Files[serviceTestConfigPath] = serviceTestConfigBody + + c := core.New() + svc := &Service{ + ServiceRuntime: core.NewServiceRuntime(c, ServiceOptions{ + Path: serviceTestConfigPath, + Medium: m, + }), + } + core.AssertTrue(t, svc.OnStartup(context.Background()).OK) + + core.AssertContains(t, c.Commands(), serviceTestConfigGetCommand) + core.AssertContains(t, c.Commands(), serviceTestConfigSetCommand) + core.AssertContains(t, c.Commands(), serviceTestConfigListCommand) +} + +func TestService_OnStartup_MergesProjectOverGlobal_Good(t *core.T) { + m := coreio.NewMockMedium() + home := core.Env("DIR_HOME") + + projectRoot := core.PathJoin("/", "service-merge", "repo") + serviceDir := core.PathJoin(projectRoot, "app") + + for _, dir := range []string{ + core.PathJoin(home, ".core"), + core.PathJoin(projectRoot, ".core"), + core.PathJoin(projectRoot, ".git"), + serviceDir, + } { + core.AssertNoError(t, m.EnsureDir(dir)) + } + + core.AssertNoError(t, m.Write(core.PathJoin(home, ".core", FileConfig), "app:\n name: global\nservices:\n ollama:\n url: http://global\n")) + core.AssertNoError(t, m.Write(core.PathJoin(projectRoot, ".core", FileConfig), "app:\n name: project\n")) + + c := core.New() + svc := &Service{ + ServiceRuntime: core.NewServiceRuntime(c, ServiceOptions{ + Path: core.PathJoin(projectRoot, ".core", FileConfig), + Medium: m, + }), + } + + core.AssertTrue(t, svc.OnStartup(context.Background()).OK) + + var name string + core.AssertNoError(t, resultError(svc.Get(serviceTestAppNameKey, &name))) + core.AssertEqual(t, "project", name) + + var ollamaURL string + core.AssertNoError(t, resultError(svc.Get("services.ollama.url", &ollamaURL))) + core.AssertEqual(t, "http://global", ollamaURL) +} + +func TestService_Config_Good(t *core.T) { + m := coreio.NewMockMedium() + c := core.New() + svc := &Service{ + ServiceRuntime: core.NewServiceRuntime(c, ServiceOptions{ + Path: serviceTestConfigPath, + Medium: m, + }), + } + + // Before OnStartup, Config() returns nil. + core.AssertNil(t, svc.Config()) + + core.AssertTrue(t, svc.OnStartup(context.Background()).OK) + core.AssertNotNil(t, svc.Config()) +} + +func TestService_Get_Bad(t *core.T) { + c := core.New() + svc := &Service{ + ServiceRuntime: core.NewServiceRuntime(c, ServiceOptions{}), + } + var v string + err := resultError(svc.Get("anything", &v)) + core.AssertError(t, err) +} + +func TestService_NewConfigService_Good(t *core.T) { + // The documented usage must compile and succeed: the factory is a + // core.WithService value and produces a retrievable *Service. + c := core.New(core.WithService(NewConfigService)) + svc, ok := core.ServiceFor[*Service](c, "config") + core.AssertTrue(t, ok) + core.AssertNotNil(t, svc) +} + +func TestService_NewConfigServiceWith_Good(t *core.T) { + m := coreio.NewMockMedium() + m.Files[serviceTestCustomConfigPath] = "app:\n name: custom\n" + + c := core.New(core.WithService(NewConfigServiceWith(ServiceOptions{ + Path: serviceTestCustomConfigPath, + Medium: m, + }))) + + svc, ok := core.ServiceFor[*Service](c, "config") + core.AssertTrue(t, ok) + + // OnStartup has not run yet; trigger it via the Core's service lifecycle. + startables := c.Startables() + core.AssertTrue(t, startables.OK) + for _, s := range startables.Value.([]*core.Service) { + core.AssertTrue(t, s.OnStart().OK) + } + + var name string + core.AssertNoError(t, resultError(svc.Get(serviceTestAppNameKey, &name))) + core.AssertEqual(t, "custom", name) +} + +func TestService_NewConfigService_Bad(t *core.T) { + // With a broken medium path (unsupported file type) OnStartup must fail + // gracefully and return a non-OK Result rather than panicking. + m := coreio.NewMockMedium() + m.Files["/broken.txt"] = "ignored" + direct := NewConfigService(core.New()) + core.AssertTrue(t, direct.OK) + + c := core.New(core.WithService(NewConfigServiceWith(ServiceOptions{ + Path: "/broken.txt", + Medium: m, + }))) + svc, ok := core.ServiceFor[*Service](c, "config") + core.AssertTrue(t, ok) + + startables := c.Startables() + core.AssertTrue(t, startables.OK) + var gotFailure bool + for _, s := range startables.Value.([]*core.Service) { + if !s.OnStart().OK { + gotFailure = true + } + } + core.AssertTrue(t, gotFailure) + core.AssertNil(t, svc.Config()) +} + +func TestService_LoadFile_RejectsUnsafePaths(t *core.T) { + m := coreio.NewMockMedium() + m.Files[serviceTestConfigPath] = serviceTestConfigBody + + c := core.New() + svc := &Service{ + ServiceRuntime: core.NewServiceRuntime(c, ServiceOptions{ + Path: serviceTestConfigPath, + Medium: m, + }), + } + core.AssertTrue(t, svc.OnStartup(context.Background()).OK) + + err := resultError(svc.LoadFile(m, "../../etc/passwd")) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "path traversal rejected") + + err = resultError(svc.LoadFile(m, "/etc/passwd")) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "absolute config paths are not allowed") +} + +func TestService_Set_Good(t *core.T) { + m := coreio.NewMockMedium() + m.Files[serviceTestConfigPath] = serviceTestConfigBody + + c := core.New() + svc := &Service{ + ServiceRuntime: core.NewServiceRuntime(c, ServiceOptions{ + Path: serviceTestConfigPath, + Medium: m, + }), + } + core.AssertTrue(t, svc.OnStartup(context.Background()).OK) + + core.AssertNoError(t, resultError(svc.Set(serviceTestDevEditorKey, "vim"))) + + var editor string + core.AssertNoError(t, resultError(svc.Get(serviceTestDevEditorKey, &editor))) + core.AssertEqual(t, "vim", editor) +} + +func TestService_Set_Bad(t *core.T) { + svc := &Service{} + + err := resultError(svc.Set(serviceTestDevEditorKey, "vim")) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), errConfigNotLoaded) +} + +func TestService_Commit_Good(t *core.T) { + m := coreio.NewMockMedium() + m.Files[serviceTestConfigPath] = serviceTestConfigBody + + c := core.New() + svc := &Service{ + ServiceRuntime: core.NewServiceRuntime(c, ServiceOptions{ + Path: serviceTestConfigPath, + Medium: m, + }), + } + core.AssertTrue(t, svc.OnStartup(context.Background()).OK) + core.AssertNoError(t, resultError(svc.Set(serviceTestDevEditorKey, "vim"))) + + core.AssertNoError(t, resultError(svc.Commit())) + body, err := m.Read(serviceTestConfigPath) + core.AssertNoError(t, err) + core.AssertContains(t, body, "editor: vim") +} + +func TestService_Commit_Bad(t *core.T) { + svc := &Service{} + + err := resultError(svc.Commit()) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), errConfigNotLoaded) +} + +func TestService_LoadFile_Good(t *core.T) { + m := coreio.NewMockMedium() + m.Files[serviceTestConfigPath] = serviceTestConfigBody + m.Files["/tmp/svc/.core/override.yaml"] = serviceTestShellYAML + + c := core.New() + svc := &Service{ + ServiceRuntime: core.NewServiceRuntime(c, ServiceOptions{ + Path: serviceTestConfigPath, + Medium: m, + }), + } + core.AssertTrue(t, svc.OnStartup(context.Background()).OK) + + core.AssertNoError(t, resultError(svc.LoadFile(m, serviceTestOverridePath))) + + var shell string + core.AssertNoError(t, resultError(svc.Get(serviceTestDevShellKey, &shell))) + core.AssertEqual(t, "zsh", shell) +} + +func TestService_LoadFile_Bad_NoConfig(t *core.T) { + svc := &Service{} + + err := resultError(svc.LoadFile(coreio.NewMockMedium(), serviceTestOverridePath)) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), errConfigNotLoaded) +} + +func TestService_LoadFile_Ugly(t *core.T) { + m := coreio.NewMockMedium() + m.Files[serviceTestConfigPath] = serviceTestConfigBody + + c := core.New() + svc := &Service{ + ServiceRuntime: core.NewServiceRuntime(c, ServiceOptions{ + Path: serviceTestConfigPath, + Medium: m, + }), + } + core.AssertTrue(t, svc.OnStartup(context.Background()).OK) + + err := resultError(svc.LoadFile(m, core.PathJoin("tmp", "svc", "config.yaml"))) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), serviceTestConfigPathsUnderCore) +} + +func TestService_LoadFile_RejectsSymlinkedCore(t *core.T) { + if runtime.GOOS == "windows" { + t.Skip(serviceTestWindowsSymlinkSkipMessage) + } + + projectRoot := t.TempDir() + externalCore := core.PathJoin(t.TempDir(), serviceTestSharedCoreDir) + testMkdirAll(t, externalCore, 0755) + testWriteFile(t, core.PathJoin(externalCore, serviceTestOverrideFilename), []byte(serviceTestShellYAML), 0600) + testSymlink(t, externalCore, core.PathJoin(projectRoot, ".core")) + testWriteFile(t, core.PathJoin(projectRoot, "config.yaml"), []byte(serviceTestConfigBody), 0600) + + c := core.New() + svc := &Service{ + ServiceRuntime: core.NewServiceRuntime(c, ServiceOptions{ + Path: core.PathJoin(projectRoot, "config.yaml"), + Medium: coreio.Local, + }), + } + core.AssertTrue(t, svc.OnStartup(context.Background()).OK) + + err := resultError(svc.LoadFile(coreio.Local, serviceTestOverridePath)) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), serviceTestSymlinkedCoreDirsRejected) +} + +func TestService_resolveValidatedServiceLoadPath_Good(t *core.T) { + projectRoot := t.TempDir() + coreDir := core.PathJoin(projectRoot, ".core") + configPath := core.PathJoin(projectRoot, "config.yaml") + overridePath := core.PathJoin(coreDir, serviceTestOverrideFilename) + + testMkdirAll(t, coreDir, 0755) + testWriteFile(t, configPath, []byte(serviceTestConfigBody), 0600) + testWriteFile(t, overridePath, []byte("dev:\n editor: vim\n"), 0600) + + resolved, err := stringResult(resolveValidatedServiceLoadPath(configPath, serviceTestOverridePath)) + core.AssertNoError(t, err) + core.AssertEqual(t, overridePath, resolved) +} + +func TestService_resolveValidatedServiceLoadPath_Bad(t *core.T) { + projectRoot := t.TempDir() + configPath := core.PathJoin(projectRoot, "config.yaml") + testWriteFile(t, configPath, []byte(serviceTestConfigBody), 0600) + + cases := []struct { + name string + path string + want string + }{ + {name: "empty", path: "", want: "empty config path"}, + {name: "absolute", path: "/etc/passwd", want: "absolute config paths are not allowed"}, + {name: "traversal", path: "../escape.yaml", want: "path traversal rejected"}, + {name: "outside-core", path: "config.yaml", want: serviceTestConfigPathsUnderCore}, + {name: "nested-traversal", path: ".core/../escape.yaml", want: serviceTestConfigPathsUnderCore}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *core.T) { + resolved, err := stringResult(resolveValidatedServiceLoadPath(configPath, tc.path)) + core.AssertEmpty(t, resolved) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), tc.want) + }) + } +} + +func TestService_resolveValidatedServiceLoadPath_Ugly(t *core.T) { + if runtime.GOOS == "windows" { + t.Skip(serviceTestWindowsSymlinkSkipMessage) + } + + projectRoot := t.TempDir() + externalCore := core.PathJoin(t.TempDir(), serviceTestSharedCoreDir) + configPath := core.PathJoin(projectRoot, "config.yaml") + + testMkdirAll(t, externalCore, 0755) + testWriteFile(t, configPath, []byte(serviceTestConfigBody), 0600) + testSymlink(t, externalCore, core.PathJoin(projectRoot, ".core")) + + resolved, err := stringResult(resolveValidatedServiceLoadPath(configPath, serviceTestOverridePath)) + core.AssertEmpty(t, resolved) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), serviceTestSymlinkedCoreDirsRejected) +} + +func TestService_resolveServiceLoadPath_Good(t *core.T) { + if runtime.GOOS == "windows" { + t.Skip(serviceTestWindowsSymlinkSkipMessage) + } + + projectRoot := t.TempDir() + coreDir := core.PathJoin(projectRoot, ".core") + realFile := core.PathJoin(projectRoot, serviceTestOverrideFilename) + symlinkFile := core.PathJoin(coreDir, serviceTestOverrideFilename) + + testMkdirAll(t, coreDir, 0755) + testWriteFile(t, realFile, []byte(serviceTestShellYAML), 0600) + testSymlink(t, realFile, symlinkFile) + + absCorePath := testPathAbs(t, coreDir) + absCandidate := testPathAbs(t, symlinkFile) + + resolvedCandidate, resolvedCore, err := serviceLoadPathResult(resolveServiceLoadPath(symlinkFile, absCorePath, absCandidate)) + core.AssertNoError(t, err) + realCandidate := testPathEvalSymlinks(t, realFile) + realCore := testPathEvalSymlinks(t, coreDir) + core.AssertEqual(t, realCandidate, resolvedCandidate) + core.AssertEqual(t, realCore, resolvedCore) +} + +func TestService_resolveServiceLoadPath_Bad(t *core.T) { + if runtime.GOOS == "windows" { + t.Skip(serviceTestWindowsSymlinkSkipMessage) + } + + projectRoot := t.TempDir() + externalCore := core.PathJoin(t.TempDir(), serviceTestSharedCoreDir) + candidatePath := core.PathJoin(projectRoot, ".core", serviceTestOverrideFilename) + + testMkdirAll(t, externalCore, 0755) + testSymlink(t, externalCore, core.PathJoin(projectRoot, ".core")) + + absCorePath := testPathAbs(t, core.PathJoin(projectRoot, ".core")) + absCandidate := testPathAbs(t, candidatePath) + + resolvedCandidate, resolvedCore, err := serviceLoadPathResult(resolveServiceLoadPath(candidatePath, absCorePath, absCandidate)) + core.AssertEmpty(t, resolvedCandidate) + core.AssertEmpty(t, resolvedCore) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), serviceTestSymlinkedCoreDirsRejected) +} + +func TestService_OnShutdown_StopsWatcher_Good(t *core.T) { + tmp := t.TempDir() + path := core.PathJoin(tmp, "config.yaml") + core.AssertNoError(t, coreio.Local.Write(path, serviceTestConfigBody)) + + c := core.New() + svc := &Service{ + ServiceRuntime: core.NewServiceRuntime(c, ServiceOptions{ + Path: path, + Medium: coreio.Local, + }), + } + core.AssertTrue(t, svc.OnStartup(context.Background()).OK) + core.AssertNoError(t, resultError(svc.Config().Watch())) + core.AssertNotNil(t, svc.Config().watcher) + + result := svc.OnShutdown(context.Background()) + core.AssertTrue(t, result.OK) + core.AssertNil(t, svc.Config().watcher) +} + +func TestService_Service_OnStartup_RegistersActionsAndCommands_Good(t *core.T) { + m := coreio.NewMockMedium() + m.Files[serviceTestConfigPath] = serviceTestConfigBody + m.Files["/tmp/svc/.core/loaded.yaml"] = "dev:\n editor: nano\n" + + c := core.New() + svc := &Service{ + ServiceRuntime: core.NewServiceRuntime(c, ServiceOptions{ + Path: serviceTestConfigPath, + Medium: m, + }), + } + core.AssertTrue(t, svc.OnStartup(context.Background()).OK) + + runAction := func(name string, opts core.Options) core.Result { + return c.Action(name).Run(context.Background(), opts) + } + runCommand := func(name string, opts core.Options) core.Result { + r := c.Command(name) + if !r.OK { + core.AssertTrue(t, r.OK) + return r + } + return r.Value.(*core.Command).Run(opts) + } + + core.AssertTrue(t, runAction(serviceTestConfigGetAction, core.NewOptions(core.Option{Key: "key", Value: serviceTestAppNameKey})).OK) + core.AssertTrue(t, runAction(serviceTestConfigSetAction, core.NewOptions( + core.Option{Key: "key", Value: serviceTestDevShellKey}, + core.Option{Key: "value", Value: "zsh"}, + )).OK) + core.AssertTrue(t, runAction(serviceTestConfigCommitAction, core.NewOptions()).OK) + core.AssertTrue(t, runAction(serviceTestConfigLoadAction, core.NewOptions(core.Option{Key: optionKeyPath, Value: ".core/loaded.yaml"})).OK) + + all := runAction(serviceTestConfigAllAction, core.NewOptions()) + core.AssertTrue(t, all.OK) + core.AssertContains(t, all.Value.(map[string]any), serviceTestAppNameKey) + + path := runAction(serviceTestConfigPathAction, core.NewOptions()) + core.AssertTrue(t, path.OK) + core.AssertEqual(t, serviceTestConfigPath, path.Value) + + core.AssertTrue(t, runCommand(serviceTestConfigGetCommand, core.NewOptions(core.Option{Key: "key", Value: serviceTestAppNameKey})).OK) + core.AssertTrue(t, runCommand(serviceTestConfigSetCommand, core.NewOptions( + core.Option{Key: "key", Value: "dev.theme"}, + core.Option{Key: "value", Value: "dark"}, + )).OK) + core.AssertTrue(t, runCommand(serviceTestConfigCommitCommand, core.NewOptions()).OK) + core.AssertTrue(t, runCommand(serviceTestConfigLoadCommand, core.NewOptions(core.Option{Key: optionKeyPath, Value: ".core/loaded.yaml"})).OK) + core.AssertTrue(t, runCommand(serviceTestConfigListCommand, core.NewOptions()).OK) + core.AssertTrue(t, runCommand(serviceTestConfigPathCommand, core.NewOptions()).OK) +} + +func TestServiceReadCommandsRequireEntitlement(t *core.T) { + m := coreio.NewMockMedium() + m.Files[serviceTestConfigPath] = serviceTestConfigBody + + c := core.New() + c.SetEntitlementChecker(func(action string, qty int, _ context.Context) core.Entitlement { + _ = qty + switch action { + case serviceTestConfigGetCommand, serviceTestConfigListCommand, serviceTestConfigPathCommand: + return core.Entitlement{Allowed: false, Reason: "denied"} + default: + return core.Entitlement{Allowed: true, Unlimited: true} + } + }) + + svc := &Service{ + ServiceRuntime: core.NewServiceRuntime(c, ServiceOptions{ + Path: serviceTestConfigPath, + Medium: m, + }), + } + core.AssertTrue(t, svc.OnStartup(context.Background()).OK) + + for _, name := range []string{serviceTestConfigGetCommand, serviceTestConfigListCommand, serviceTestConfigPathCommand} { + cmdResult := c.Command(name) + if !cmdResult.OK { + core.AssertTrue(t, cmdResult.OK, name) + continue + } + res := cmdResult.Value.(*core.Command).Run(core.NewOptions()) + core.AssertFalse(t, res.OK, name) + core.AssertContains(t, res.Value.(error).Error(), "not entitled") + } +} + +func TestService_Service_OnStartup_ReadActionsRequireEntitlement_Bad(t *core.T) { + m := coreio.NewMockMedium() + m.Files[serviceTestConfigPath] = serviceTestConfigBody + + c := core.New() + c.SetEntitlementChecker(func(action string, qty int, _ context.Context) core.Entitlement { + _ = qty + switch action { + case serviceTestConfigGetAction, serviceTestConfigAllAction, serviceTestConfigPathAction: + return core.Entitlement{Allowed: false, Reason: "denied"} + default: + return core.Entitlement{Allowed: true, Unlimited: true} + } + }) + + svc := &Service{ + ServiceRuntime: core.NewServiceRuntime(c, ServiceOptions{ + Path: serviceTestConfigPath, + Medium: m, + }), + } + core.AssertTrue(t, svc.OnStartup(context.Background()).OK) + + actions := map[string]core.Options{ + serviceTestConfigGetAction: core.NewOptions(core.Option{Key: "key", Value: serviceTestAppNameKey}), + serviceTestConfigAllAction: core.NewOptions(), + serviceTestConfigPathAction: core.NewOptions(), + } + + for name, opts := range actions { + t.Run(name, func(t *core.T) { + res := c.Action(name).Run(context.Background(), opts) + core.AssertFalse(t, res.OK) + core.AssertContains(t, res.Value.(error).Error(), "not entitled") + }) + } +} + +func axServiceFixture(t *core.T) (*Service, *coreio.MockMedium, string) { + t.Helper() + m := coreio.NewMockMedium() + path := "/ax7/service/config.yaml" + m.Files[path] = serviceTestConfigBody + c := core.New() + svc := &Service{ServiceRuntime: core.NewServiceRuntime(c, ServiceOptions{Path: path, Medium: m})} + core.RequireTrue(t, svc.OnStartup(context.Background()).OK) + return svc, m, path +} + +func TestService_NewConfigService_Ugly(t *core.T) { + result := NewConfigService(nil) + core.AssertTrue(t, result.OK) + core.AssertNotNil(t, result.Value) +} + +func TestService_NewConfigServiceWith_Bad(t *core.T) { + factory := NewConfigServiceWith(ServiceOptions{}) + result := factory(core.New()) + core.AssertTrue(t, result.OK) + core.AssertNotNil(t, result.Value) +} + +func TestService_NewConfigServiceWith_Ugly(t *core.T) { + factory := NewConfigServiceWith(ServiceOptions{Path: "/ax7/config.yaml", EnvPrefix: "AX7"}) + result := factory(nil) + svc := result.Value.(*Service) + core.AssertTrue(t, result.OK) + core.AssertEqual(t, "/ax7/config.yaml", svc.Options().Path) +} + +func TestService_Service_OnStartup_LoadsConfig_Good(t *core.T) { + m := coreio.NewMockMedium() + path := "/ax7/service/config.yaml" + m.Files[path] = serviceTestConfigBody + svc := &Service{ServiceRuntime: core.NewServiceRuntime(core.New(), ServiceOptions{Path: path, Medium: m})} + core.RequireTrue(t, svc.OnStartup(context.Background()).OK) + var got string + err := resultError(svc.Get(serviceTestAppNameKey, &got)) + core.AssertNoError(t, err) + core.AssertEqual(t, "svc", got) +} + +func TestService_Service_OnStartup_Bad(t *core.T) { + m := coreio.NewMockMedium() + m.Files["/ax7/bad.yaml"] = "app: [broken" + svc := &Service{ServiceRuntime: core.NewServiceRuntime(core.New(), ServiceOptions{Path: "/ax7/bad.yaml", Medium: m})} + result := svc.OnStartup(context.Background()) + core.AssertFalse(t, result.OK) +} + +func TestService_Service_OnStartup_Ugly(t *core.T) { + m := coreio.NewMockMedium() + svc := &Service{ServiceRuntime: core.NewServiceRuntime(core.New(), ServiceOptions{Path: "/ax7/empty.yaml", Medium: m})} + result := svc.OnStartup(context.Background()) + core.AssertTrue(t, result.OK) + core.AssertNotNil(t, svc.Config()) +} + +func TestService_Service_OnShutdown_Good(t *core.T) { + svc, _, _ := axServiceFixture(t) + result := svc.OnShutdown(context.Background()) + core.AssertTrue(t, result.OK) + core.AssertNotNil(t, svc.Config()) +} + +func TestService_Service_OnShutdown_Bad(t *core.T) { + svc := &Service{ServiceRuntime: core.NewServiceRuntime(core.New(), ServiceOptions{})} + result := svc.OnShutdown(context.Background()) + core.AssertTrue(t, result.OK) + core.AssertNil(t, svc.Config()) +} + +func TestService_Service_OnShutdown_Ugly(t *core.T) { + svc, _, _ := axServiceFixture(t) + first := svc.OnShutdown(context.Background()) + second := svc.OnShutdown(context.Background()) + core.AssertTrue(t, first.OK) + core.AssertTrue(t, second.OK) +} + +func TestService_Service_Get_Good(t *core.T) { + svc, _, path := axServiceFixture(t) + var got string + err := resultError(svc.Get(serviceTestAppNameKey, &got)) + core.AssertNoError(t, err) + core.AssertEqual(t, "svc", got) + core.AssertEqual(t, path, svc.Options().Path) +} + +func TestService_Service_Get_Bad(t *core.T) { + svc := &Service{ServiceRuntime: core.NewServiceRuntime(core.New(), ServiceOptions{})} + err := resultError(svc.Get("missing", new(string))) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), errConfigNotLoaded) +} + +func TestService_Service_Get_Ugly(t *core.T) { + svc, _, _ := axServiceFixture(t) + var got map[string]any + err := resultError(svc.Get("", &got)) + core.AssertNoError(t, err) + core.AssertEqual(t, "svc", got["app"].(map[string]any)["name"]) +} + +func TestService_Service_Set_Good(t *core.T) { + svc, _, _ := axServiceFixture(t) + err := resultError(svc.Set(serviceTestDevEditorKey, "vim")) + core.AssertNoError(t, err) + core.AssertEqual(t, "vim", configValues(svc.Config())[serviceTestDevEditorKey]) +} + +func TestService_Service_Set_Bad(t *core.T) { + svc := &Service{ServiceRuntime: core.NewServiceRuntime(core.New(), ServiceOptions{})} + err := resultError(svc.Set(serviceTestDevEditorKey, "vim")) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), errConfigNotLoaded) +} + +func TestService_Service_Set_Ugly(t *core.T) { + svc, _, _ := axServiceFixture(t) + err := resultError(svc.Set("", "root")) + core.AssertNoError(t, err) + core.AssertEqual(t, "root", svc.Config().file.Get("")) +} + +func TestService_Service_Commit_Good(t *core.T) { + svc, m, path := axServiceFixture(t) + core.RequireNoError(t, resultError(svc.Set(serviceTestDevEditorKey, "vim"))) + err := resultError(svc.Commit()) + core.AssertNoError(t, err) + core.AssertTrue(t, m.Exists(path)) +} + +func TestService_Service_Commit_Bad(t *core.T) { + svc := &Service{ServiceRuntime: core.NewServiceRuntime(core.New(), ServiceOptions{})} + err := resultError(svc.Commit()) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), errConfigNotLoaded) +} + +func TestService_Service_Commit_Ugly(t *core.T) { + svc, _, _ := axServiceFixture(t) + svc.Config().path = "/ax7/config.json" + err := resultError(svc.Commit()) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "unsupported config file type") +} + +func TestService_Service_LoadFile_Good(t *core.T) { + svc, m, _ := axServiceFixture(t) + core.RequireNoError(t, m.Write("/ax7/service/.core/override.yaml", serviceTestShellYAML)) + err := resultError(svc.LoadFile(m, serviceTestOverridePath)) + core.AssertNoError(t, err) + core.AssertEqual(t, "zsh", configValues(svc.Config())[serviceTestDevShellKey]) +} + +func TestService_Service_LoadFile_Bad(t *core.T) { + svc := &Service{ServiceRuntime: core.NewServiceRuntime(core.New(), ServiceOptions{})} + err := resultError(svc.LoadFile(coreio.NewMockMedium(), serviceTestOverridePath)) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), errConfigNotLoaded) +} + +func TestService_Service_LoadFile_Ugly(t *core.T) { + svc, m, _ := axServiceFixture(t) + err := resultError(svc.LoadFile(m, "../escape.yaml")) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "path traversal") +} + +func TestService_Service_Config_Good(t *core.T) { + svc, _, _ := axServiceFixture(t) + got := svc.Config() + core.AssertNotNil(t, got) +} + +func TestService_Service_Config_Bad(t *core.T) { + svc := &Service{ServiceRuntime: core.NewServiceRuntime(core.New(), ServiceOptions{})} + got := svc.Config() + core.AssertNil(t, got) +} + +func TestService_Service_Config_Ugly(t *core.T) { + svc, _, _ := axServiceFixture(t) + replacement, err := configResult(New(WithMedium(coreio.NewMockMedium()), WithPath("/ax7/replacement.yaml"))) + core.RequireNoError(t, err) + svc.config = replacement + core.AssertSame(t, replacement, svc.Config()) +} diff --git a/go/test_detect.go b/go/test_detect.go new file mode 100644 index 0000000..47dcf1b --- /dev/null +++ b/go/test_detect.go @@ -0,0 +1,85 @@ +package config + +import ( + core "dappco.re/go" + coreio "dappco.re/go/io" + coreerr "dappco.re/go/log" +) + +const callerResolveTestManifest = "config.ResolveTestManifest" + +type detectedTestCommand struct { + Command string + Found bool +} + +func detectTestCommand(medium coreio.Medium, start string) core.Result { + start = normalizeUpwardStart(medium, start) + for dir := start; ; dir = core.PathDir(dir) { + detectedResult := detectTestCommandAtDir(medium, dir) + if !detectedResult.OK { + return detectedResult + } + detected := detectedResult.Value.(detectedTestCommand) + if detected.Found { + return detectedResult + } + + if medium.Exists(core.Path(dir, ".git")) { + break + } + parent := core.PathDir(dir) + if parent == dir { + break + } + } + + return core.Ok(detectedTestCommand{}) +} + +func detectTestCommandAtDir(medium coreio.Medium, dir string) core.Result { + switch { + case medium.Exists(core.Path(dir, "composer.json")): + return detectJSONTestCommand(medium, core.Path(dir, "composer.json"), "composer", "composer test") + case medium.Exists(core.Path(dir, "package.json")): + return detectJSONTestCommand(medium, core.Path(dir, "package.json"), "npm", "npm test") + case medium.Exists(core.Path(dir, "go.mod")): + return core.Ok(detectedTestCommand{Command: "core go qa", Found: true}) + case medium.Exists(core.Path(dir, "pytest.ini")): + return core.Ok(detectedTestCommand{Command: "pytest", Found: true}) + case medium.Exists(core.Path(dir, "pyproject.toml")): + return core.Ok(detectedTestCommand{Command: "pytest", Found: true}) + case medium.Exists(core.Path(dir, "Taskfile.yaml")) || medium.Exists(core.Path(dir, "Taskfile.yml")): + return core.Ok(detectedTestCommand{Command: "task test", Found: true}) + default: + return core.Ok(detectedTestCommand{}) + } +} + +func detectJSONTestCommand(medium coreio.Medium, path, label, fallback string) core.Result { + content, err := medium.Read(path) + if err != nil { + return core.Fail(coreerr.E(callerResolveTestManifest, "failed to read "+label+" manifest: "+path, err)) + } + + var data struct { + Scripts map[string]any `json:"scripts"` + } + if r := core.JSONUnmarshalString(content, &data); !r.OK { + return core.Fail(coreerr.E(callerResolveTestManifest, "failed to parse "+label+" manifest: "+path, resultCause(r).(error))) + } + + raw, ok := data.Scripts["test"] + if !ok { + return core.Ok(detectedTestCommand{Command: fallback, Found: true}) + } + + script, ok := raw.(string) + if !ok { + return core.Fail(coreerr.E(callerResolveTestManifest, "invalid "+label+" test script: "+path, nil)) + } + if script == "" { + return core.Ok(detectedTestCommand{Command: fallback, Found: true}) + } + return core.Ok(detectedTestCommand{Command: script, Found: true}) +} diff --git a/go/tests/cli/config/Taskfile.yaml b/go/tests/cli/config/Taskfile.yaml new file mode 100644 index 0000000..4c97ff7 --- /dev/null +++ b/go/tests/cli/config/Taskfile.yaml @@ -0,0 +1,13 @@ +--- +version: "3" + +tasks: + default: + deps: + - qa + + qa: + desc: Run repository QA checks for config. + dir: ../../.. + cmds: + - GOWORK=off core go qa diff --git a/go/watch.go b/go/watch.go new file mode 100644 index 0000000..f960c81 --- /dev/null +++ b/go/watch.go @@ -0,0 +1,303 @@ +package config + +import ( + "reflect" + "slices" + "sync" + "time" + + core "dappco.re/go" + coreerr "dappco.re/go/log" + "github.com/fsnotify/fsnotify" +) + +// debounceWindow coalesces rapid filesystem events from multi-step editor saves. +const debounceWindow = 100 * time.Millisecond + +type fileWatcher struct { + mu sync.Mutex + w watchBackend + stop chan struct{} + stopped bool +} + +type watchBackend interface { + Add(string) core.Result + Close() core.Result + Events() <-chan fsnotify.Event + Errors() <-chan core.Result +} + +type fsnotifyBackend struct { + w *fsnotify.Watcher + errors <-chan core.Result +} + +func (b fsnotifyBackend) Add(path string) core.Result { + return core.ResultOf(nil, b.w.Add(path)) +} + +func (b fsnotifyBackend) Close() core.Result { + return core.ResultOf(nil, b.w.Close()) +} + +func (b fsnotifyBackend) Events() <-chan fsnotify.Event { + return b.w.Events +} + +func (b fsnotifyBackend) Errors() <-chan core.Result { + return b.errors +} + +var newWatchBackend = func() core.Result { + w, err := fsnotify.NewWatcher() + if err != nil { + return core.Fail(err) + } + errors := make(chan core.Result) + go func() { + defer close(errors) + for err := range w.Errors { + errors <- core.Fail(err) + } + }() + return core.Ok(fsnotifyBackend{w: w, errors: errors}) +} + +// Watch starts monitoring the config file for changes. When the file is modified, +// registered OnChange callbacks are invoked for every key whose value changed +// between the previous state and the reloaded state. Rapid filesystem events +// within a 100ms window are coalesced into a single reload+diff pass. +// +// cfg.Watch() +// defer cfg.StopWatch() +func (c *Config) Watch() core.Result { + c.mu.Lock() + if c.watcher != nil { + c.mu.Unlock() + return core.Ok(nil) + } + wResult := newWatchBackend() + if !wResult.OK { + c.mu.Unlock() + return core.Fail(coreerr.E("config.Watch", "failed to create watcher", resultCause(wResult).(error))) + } + w := wResult.Value.(watchBackend) + path := c.path + fw := &fileWatcher{w: w, stop: make(chan struct{})} + c.watcher = fw + c.mu.Unlock() + + if r := w.Add(path); !r.OK { + watchErr := resultCause(r).(error) + if closeResult := w.Close(); !closeResult.OK { + watchErr = core.ErrorJoin(watchErr, resultCause(closeResult).(error)) + } + c.mu.Lock() + c.watcher = nil + c.mu.Unlock() + return core.Fail(coreerr.E("config.Watch", "failed to watch path: "+path, watchErr)) + } + + go c.watchLoop(fw) + return core.Ok(nil) +} + +// StopWatch stops the filesystem watcher if one is running. +// +// defer cfg.StopWatch() +func (c *Config) StopWatch() { + c.mu.Lock() + fw := c.watcher + c.watcher = nil + c.mu.Unlock() + if fw == nil { + return + } + fw.mu.Lock() + if !fw.stopped { + fw.stopped = true + close(fw.stop) + _ = fw.w.Close() + } + fw.mu.Unlock() +} + +func (c *Config) watchLoop(fw *fileWatcher) { + reloadRequests := make(chan struct{}, 1) + done := make(chan struct{}) + go c.watchReloadLoop(fw.stop, done, reloadRequests) + defer close(done) + + for { + select { + case <-fw.stop: + return + case ev, ok := <-fw.w.Events(): + if !ok { + return + } + if ev.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Rename|fsnotify.Remove) == 0 { + continue + } + // Atomic-save editors (vim, VSCode) rename/replace the file on save. + // fsnotify tracks the old inode, so the watch silently dies — re-Add + // the watch on the same path so subsequent saves still fire events. + if ev.Op&(fsnotify.Rename|fsnotify.Remove) != 0 { + c.mu.RLock() + path := c.path + c.mu.RUnlock() + // Best-effort for atomic-save editors: the replacement file may + // not exist during the swap. There is no automatic retry loop; + // another fsnotify event is required to attempt Add again. + _ = fw.w.Add(path) + } + requestReload(reloadRequests) + case _, ok := <-fw.w.Errors(): + if !ok { + return + } + } + } +} + +func (c *Config) watchReloadLoop(stop, done <-chan struct{}, requests <-chan struct{}) { + timer := time.NewTimer(debounceWindow) + if !timer.Stop() { + <-timer.C + } + defer timer.Stop() + + for { + select { + case <-stop: + return + case <-done: + return + case <-requests: + resetDebounceTimer(timer) + case <-timer.C: + select { + case <-stop: + return + case <-done: + return + default: + c.reloadAndNotify() + } + } + } +} + +func requestReload(requests chan<- struct{}) { + select { + case requests <- struct{}{}: + default: + } +} + +func resetDebounceTimer(timer *time.Timer) { + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + timer.Reset(debounceWindow) +} + +// reloadAndNotify snapshots the current values, reloads the underlying file, +// and fires OnChange callbacks for each key whose value differs between the +// snapshot and the reloaded state. Source on the broadcast ConfigChanged is +// "file" — it distinguishes filesystem reloads from in-process Set() calls. +func (c *Config) reloadAndNotify() { + before := c.snapshotAll() + + if r := c.loadFile(c.medium, c.path, false); !r.OK { + return + } + + after := c.snapshotAll() + changes := diffSnapshots(before, after) + + c.mu.RLock() + callbacks := append([]func(string, any){}, c.callbacks...) + attached := c.core + c.mu.RUnlock() + + for _, change := range changes { + for _, fn := range callbacks { + fn(change.Key, change.Value) + } + if attached != nil { + _ = attached.ACTION(ConfigChanged{ + Key: change.Key, + Value: change.Value, + Previous: change.Previous, + Source: configChangeSourceFile, + }) + } + } +} + +// snapshotAll copies every key/value currently known to the full viper into a +// flat map so the watcher can diff before/after reload. The read lock guards +// the underlying viper during the AllSettings walk. +func (c *Config) snapshotAll() map[string]any { + c.mu.RLock() + defer c.mu.RUnlock() + out := make(map[string]any, len(c.full.AllKeys())) + for _, key := range c.full.AllKeys() { + out[key] = c.full.Get(key) + } + return out +} + +// configChange describes a single key-level transition between snapshots. +type configChange struct { + Key string + Value any + Previous any +} + +// diffSnapshots returns every key whose value changed (or appeared/disappeared) +// between before and after. Order is lexical so repeated reloads produce a +// deterministic callback sequence. +func diffSnapshots(before, after map[string]any) []configChange { + keys := make(map[string]struct{}, len(before)+len(after)) + for k := range before { + keys[k] = struct{}{} + } + for k := range after { + keys[k] = struct{}{} + } + ordered := make([]string, 0, len(keys)) + for k := range keys { + ordered = append(ordered, k) + } + sortStrings(ordered) + + changes := make([]configChange, 0, len(ordered)) + for _, k := range ordered { + prev, hadPrev := before[k] + next, hasNext := after[k] + if hadPrev == hasNext && equalAny(prev, next) { + continue + } + changes = append(changes, configChange{Key: k, Value: next, Previous: prev}) + } + return changes +} + +// sortStrings sorts keys lexically via slices.Sort so the diff helpers stay +// dependency-thin without pulling in the banned sort package. +func sortStrings(keys []string) { + slices.Sort(keys) +} + +// equalAny compares two any values, including map[string]any and []any shapes +// that yaml/viper commonly produce. Falls back to reflect.DeepEqual so nested +// structures compare correctly regardless of concrete element type. +func equalAny(a, b any) bool { + return reflect.DeepEqual(a, b) +} diff --git a/go/watch_example_test.go b/go/watch_example_test.go new file mode 100644 index 0000000..59657e5 --- /dev/null +++ b/go/watch_example_test.go @@ -0,0 +1,79 @@ +package config + +import ( + core "dappco.re/go" + coreio "dappco.re/go/io" + "github.com/fsnotify/fsnotify" +) + +type Backend = watchBackend + +func ExampleBackend_Add() { + backend := newFakeWatchBackend() + err := resultError(backend.Add("/example/config.yaml")) + core.Println(err == nil, backend.addCount()) + // Output: true 1 +} + +func ExampleBackend_Close() { + backend := newFakeWatchBackend() + err := resultError(backend.Close()) + core.Println(err == nil, backend.closed) + // Output: true true +} + +func ExampleBackend_Events() { + backend := newFakeWatchBackend() + backend.emit(fsnotify.Event{Name: "/example/config.yaml", Op: fsnotify.Write}) + event := <-backend.Events() + core.Println(event.Name) + // Output: /example/config.yaml +} + +func ExampleBackend_Errors() { + backend := newFakeWatchBackend() + backend.errors <- core.Fail(core.NewError("watch failed")) + result := <-backend.Errors() + core.Println(!result.OK) + // Output: true +} + +func ExampleConfig_Watch() { + m := coreio.NewMockMedium() + path := "/example/config.yaml" + _ = m.Write(path, "app:\n name: before\n") + backend := newFakeWatchBackend() + previous := newWatchBackend + newWatchBackend = func() core.Result { + return core.Ok(backend) + } + defer func() { + newWatchBackend = previous + }() + + cfg, _ := configResult(New(WithMedium(m), WithPath(path))) + err := resultError(cfg.Watch()) + cfg.StopWatch() + core.Println(err == nil, backend.closed) + // Output: true true +} + +func ExampleConfig_StopWatch() { + m := coreio.NewMockMedium() + path := "/example/config.yaml" + _ = m.Write(path, "app:\n name: before\n") + backend := newFakeWatchBackend() + previous := newWatchBackend + newWatchBackend = func() core.Result { + return core.Ok(backend) + } + defer func() { + newWatchBackend = previous + }() + + cfg, _ := configResult(New(WithMedium(m), WithPath(path))) + _ = cfg.Watch() + cfg.StopWatch() + core.Println(backend.closed) + // Output: true +} diff --git a/go/watch_test.go b/go/watch_test.go new file mode 100644 index 0000000..2c79cda --- /dev/null +++ b/go/watch_test.go @@ -0,0 +1,463 @@ +package config + +import ( + core "dappco.re/go" + "sync" + "time" + + coreio "dappco.re/go/io" + "github.com/fsnotify/fsnotify" +) + +const ( + watchDevEditorKey = "dev.editor" + watchAppNameKey = "app.name" + watchConfigPath = "ax7/watch.yaml" + watchNameYAML = "name: one\n" +) + +type fakeWatchBackend struct { + mu sync.Mutex + events chan fsnotify.Event + errors chan core.Result + addErr error + adds []string + closed bool +} + +func newFakeWatchBackend() *fakeWatchBackend { + return &fakeWatchBackend{ + events: make(chan fsnotify.Event, 8), + errors: make(chan core.Result, 1), + } +} + +func (w *fakeWatchBackend) Add(path string) core.Result { + w.mu.Lock() + defer w.mu.Unlock() + w.adds = append(w.adds, path) + return core.ResultOf(nil, w.addErr) +} + +func (w *fakeWatchBackend) Close() core.Result { + w.mu.Lock() + defer w.mu.Unlock() + w.closed = true + return core.Ok(nil) +} + +func (w *fakeWatchBackend) Events() <-chan fsnotify.Event { + return w.events +} + +func (w *fakeWatchBackend) Errors() <-chan core.Result { + return w.errors +} + +func (w *fakeWatchBackend) emit(event fsnotify.Event) { + w.events <- event +} + +func (w *fakeWatchBackend) addCount() int { + w.mu.Lock() + defer w.mu.Unlock() + return len(w.adds) +} + +func useFakeWatchBackend(t *core.T, backend *fakeWatchBackend) { + t.Helper() + previous := newWatchBackend + newWatchBackend = func() core.Result { + return core.Ok(backend) + } + t.Cleanup(func() { + newWatchBackend = previous + }) +} + +func TestWatch_Watch_Good(t *core.T) { + m := coreio.NewMockMedium() + path := "watch/config.yaml" + core.AssertNoError(t, m.Write(path, "key: one\n")) + backend := newFakeWatchBackend() + useFakeWatchBackend(t, backend) + + cfg, err := configResult(New(WithMedium(m), WithPath(path))) + core.AssertNoError(t, err) + + var mu sync.Mutex + fired := 0 + cfg.OnChange(func(_ string, _ any) { + mu.Lock() + fired++ + mu.Unlock() + }) + + core.AssertNoError(t, resultError(cfg.Watch())) + t.Cleanup(cfg.StopWatch) + + core.AssertNoError(t, m.Write(path, "key: two\n")) + backend.emit(fsnotify.Event{Name: path, Op: fsnotify.Write}) + waitFor(t, &mu, func() int { return fired }, 1) + + mu.Lock() + defer mu.Unlock() + core.AssertGreater(t, fired, 0) +} + +func TestWatch_Watch_Bad(t *core.T) { + m := coreio.NewMockMedium() + path := "watch/missing.yaml" + backend := newFakeWatchBackend() + backend.addErr = core.NewError("missing") + useFakeWatchBackend(t, backend) + + cfg, err := configResult(New(WithMedium(m), WithPath(path))) + core.AssertNoError(t, err) + // Watching a non-existent path should return an error rather than crashing. + err = resultError(cfg.Watch()) + core.AssertError(t, err) +} + +func TestWatch_Watch_Ugly(t *core.T) { + m := coreio.NewMockMedium() + path := "watch/idempotent.yaml" + core.AssertNoError(t, m.Write(path, "key: value\n")) + backend := newFakeWatchBackend() + useFakeWatchBackend(t, backend) + + cfg, err := configResult(New(WithMedium(m), WithPath(path))) + core.AssertNoError(t, err) + + // Double Watch is idempotent — no duplicate watchers, no panic. + core.AssertNoError(t, resultError(cfg.Watch())) + core.AssertNoError(t, resultError(cfg.Watch())) + cfg.StopWatch() + cfg.StopWatch() +} + +func TestWatch_Config_Watch_ReloadKeys_Good(t *core.T) { + // When a file is reloaded via the watcher, OnChange must fire once per + // changed key with the new value — not a single empty-key signal. + m := coreio.NewMockMedium() + path := "watch/reload.yaml" + core.AssertNoError(t, m.Write(path, "dev:\n editor: vim\napp:\n name: alpha\n")) + backend := newFakeWatchBackend() + useFakeWatchBackend(t, backend) + + cfg, err := configResult(New(WithMedium(m), WithPath(path))) + core.AssertNoError(t, err) + + var mu sync.Mutex + seen := map[string]any{} + cfg.OnChange(func(key string, value any) { + mu.Lock() + defer mu.Unlock() + seen[key] = value + }) + + core.AssertNoError(t, resultError(cfg.Watch())) + t.Cleanup(cfg.StopWatch) + + // Change editor and name, plus add a new key. + core.AssertNoError(t, m.Write(path, "dev:\n editor: nano\napp:\n name: beta\n version: \"1\"\n")) + backend.emit(fsnotify.Event{Name: path, Op: fsnotify.Write}) + waitFor(t, &mu, func() int { return len(seen) }, 3) + + mu.Lock() + defer mu.Unlock() + core.AssertEqual(t, "nano", seen[watchDevEditorKey]) + core.AssertEqual(t, "beta", seen[watchAppNameKey]) + core.AssertEqual(t, "1", seen["app.version"]) +} + +func TestWatch_Config_Watch_AtomicSave_Good(t *core.T) { + // Atomic-save editors (vim, VSCode, most IDE auto-formatters) replace a + // file via rename: write new inode, rename over the old path, unlink the + // original. fsnotify tracks the original inode and silently stops firing + // after the first rename — the watcher re-Adds the path to survive this. + m := coreio.NewMockMedium() + path := "watch/atomic.yaml" + core.AssertNoError(t, m.Write(path, "key: first\n")) + backend := newFakeWatchBackend() + useFakeWatchBackend(t, backend) + + cfg, err := configResult(New(WithMedium(m), WithPath(path))) + core.AssertNoError(t, err) + + var mu sync.Mutex + fires := 0 + cfg.OnChange(func(_ string, _ any) { + mu.Lock() + fires++ + mu.Unlock() + }) + + core.AssertNoError(t, resultError(cfg.Watch())) + t.Cleanup(cfg.StopWatch) + + // Simulate an atomic save: write to sidecar, rename over target. + sidecar := "watch/atomic.yaml.swp" + core.AssertNoError(t, m.Write(sidecar, "key: second\n")) + core.AssertNoError(t, m.Rename(sidecar, path)) + backend.emit(fsnotify.Event{Name: path, Op: fsnotify.Rename}) + + // Wait for the first rename-driven reload to land. + waitFor(t, &mu, func() int { return fires }, 1) + + // Second atomic save: watcher must still be live. + sidecar2 := "watch/atomic.yaml.swp2" + core.AssertNoError(t, m.Write(sidecar2, "key: third\n")) + core.AssertNoError(t, m.Rename(sidecar2, path)) + backend.emit(fsnotify.Event{Name: path, Op: fsnotify.Rename}) + + waitFor(t, &mu, func() int { return fires }, 2) + + mu.Lock() + defer mu.Unlock() + core.AssertGreaterOrEqual(t, fires, 2, "watcher must survive the second atomic save") + core.AssertGreaterOrEqual(t, backend.addCount(), 3, "initial watch plus two re-add attempts") +} + +// waitFor polls the provided getter until it reaches target or 2s elapse. +// Used by watch tests where fsnotify latency is platform-dependent. +func waitFor(t *core.T, mu *sync.Mutex, get func() int, target int) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + mu.Lock() + got := get() + mu.Unlock() + if got >= target { + return + } + time.Sleep(25 * time.Millisecond) + } + mu.Lock() + got := get() + mu.Unlock() + t.Fatalf("timed out waiting for %d events; got %d", target, got) +} + +func TestWatch_diffSnapshots_Good(t *core.T) { + // diffSnapshots is the core of reload notifications — feed it the two + // snapshots a watcher would produce and verify the per-key changes. + before := map[string]any{ + watchDevEditorKey: "vim", + watchAppNameKey: "alpha", + "gone": true, + } + after := map[string]any{ + watchDevEditorKey: "nano", // changed + watchAppNameKey: "alpha", // unchanged + "app.new": "arrived", // added + } + + changes := diffSnapshots(before, after) + // Sorted lexically: app.new, dev.editor, gone + core.AssertLen(t, changes, 3) + core.AssertEqual(t, "app.new", changes[0].Key) + core.AssertEqual(t, "arrived", changes[0].Value) + core.AssertEqual(t, watchDevEditorKey, changes[1].Key) + core.AssertEqual(t, "nano", changes[1].Value) + core.AssertEqual(t, "vim", changes[1].Previous) + core.AssertEqual(t, "gone", changes[2].Key) + core.AssertNil(t, changes[2].Value) + core.AssertEqual(t, true, changes[2].Previous) +} + +func TestWatch_diffSnapshots_Ugly(t *core.T) { + // Nested map values should compare structurally, not by pointer identity. + nested := map[string]any{"features": map[string]any{"dark-mode": true}} + same := map[string]any{"features": map[string]any{"dark-mode": true}} + + changes := diffSnapshots(nested, same) + core.AssertEmpty(t, changes) +} + +func TestWatch_Backend_Add_Good(t *core.T) { + backend, err := watchBackendResult(newWatchBackend()) + core.RequireNoError(t, err) + defer func() { + core.RequireNoError(t, resultError(backend.Close())) + }() + got := backend.Add(t.TempDir()) + core.AssertNoError(t, resultError(got)) +} + +func TestWatch_Backend_Add_Bad(t *core.T) { + backend, err := watchBackendResult(newWatchBackend()) + core.RequireNoError(t, err) + core.RequireNoError(t, resultError(backend.Close())) + got := backend.Add(t.TempDir()) + core.AssertError(t, resultError(got)) +} + +func TestWatch_Backend_Add_Ugly(t *core.T) { + backend, err := watchBackendResult(newWatchBackend()) + core.RequireNoError(t, err) + defer func() { + core.RequireNoError(t, resultError(backend.Close())) + }() + got := backend.Add("missing/watch/path") + core.AssertError(t, resultError(got)) +} + +func TestWatch_Backend_Close_Good(t *core.T) { + backend, err := watchBackendResult(newWatchBackend()) + core.RequireNoError(t, err) + got := backend.Close() + core.AssertNoError(t, resultError(got)) +} + +func TestWatch_Backend_Close_Bad(t *core.T) { + backend, err := watchBackendResult(newWatchBackend()) + core.RequireNoError(t, err) + core.RequireNoError(t, resultError(backend.Close())) + got := backend.Close() + core.AssertNoError(t, resultError(got)) +} + +func TestWatch_Backend_Close_Ugly(t *core.T) { + backend, err := watchBackendResult(newWatchBackend()) + core.RequireNoError(t, err) + events := backend.Events() + core.AssertNotNil(t, events) + core.AssertNoError(t, resultError(backend.Close())) +} + +func TestWatch_Backend_Events_Good(t *core.T) { + backend, err := watchBackendResult(newWatchBackend()) + core.RequireNoError(t, err) + defer func() { + core.RequireNoError(t, resultError(backend.Close())) + }() + got := backend.Events() + core.AssertNotNil(t, got) +} + +func TestWatch_Backend_Events_Bad(t *core.T) { + backend, err := watchBackendResult(newWatchBackend()) + core.RequireNoError(t, err) + core.RequireNoError(t, resultError(backend.Close())) + got := backend.Events() + core.AssertNotNil(t, got) +} + +func TestWatch_Backend_Events_Ugly(t *core.T) { + backend, err := watchBackendResult(newWatchBackend()) + core.RequireNoError(t, err) + defer func() { + core.RequireNoError(t, resultError(backend.Close())) + }() + got := cap(backend.Events()) + core.AssertGreaterOrEqual(t, got, 0) +} + +func TestWatch_Backend_Errors_Good(t *core.T) { + backend, err := watchBackendResult(newWatchBackend()) + core.RequireNoError(t, err) + defer func() { + core.RequireNoError(t, resultError(backend.Close())) + }() + got := backend.Errors() + core.AssertNotNil(t, got) +} + +func TestWatch_Backend_Errors_Bad(t *core.T) { + backend, err := watchBackendResult(newWatchBackend()) + core.RequireNoError(t, err) + core.RequireNoError(t, resultError(backend.Close())) + got := backend.Errors() + core.AssertNotNil(t, got) +} + +func TestWatch_Backend_Errors_Ugly(t *core.T) { + backend, err := watchBackendResult(newWatchBackend()) + core.RequireNoError(t, err) + defer func() { + core.RequireNoError(t, resultError(backend.Close())) + }() + got := cap(backend.Errors()) + core.AssertGreaterOrEqual(t, got, 0) +} + +func TestWatch_Config_Watch_Good(t *core.T) { + m := coreio.NewMockMedium() + path := watchConfigPath + core.RequireNoError(t, m.Write(path, watchNameYAML)) + backend := newFakeWatchBackend() + useFakeWatchBackend(t, backend) + cfg, err := configResult(New(WithMedium(m), WithPath(path))) + core.RequireNoError(t, err) + + err = resultError(cfg.Watch()) + core.AssertNoError(t, err) + core.AssertEqual(t, 1, backend.addCount()) +} + +func TestWatch_Config_Watch_Bad(t *core.T) { + m := coreio.NewMockMedium() + path := watchConfigPath + core.RequireNoError(t, m.Write(path, watchNameYAML)) + backend := newFakeWatchBackend() + backend.addErr = core.NewError("add failed") + useFakeWatchBackend(t, backend) + cfg, err := configResult(New(WithMedium(m), WithPath(path))) + core.RequireNoError(t, err) + + err = resultError(cfg.Watch()) + core.AssertError(t, err) + core.AssertTrue(t, backend.closed) +} + +func TestWatch_Config_Watch_Ugly(t *core.T) { + m := coreio.NewMockMedium() + path := watchConfigPath + core.RequireNoError(t, m.Write(path, watchNameYAML)) + backend := newFakeWatchBackend() + useFakeWatchBackend(t, backend) + cfg, err := configResult(New(WithMedium(m), WithPath(path))) + core.RequireNoError(t, err) + + core.AssertNoError(t, resultError(cfg.Watch())) + core.AssertNoError(t, resultError(cfg.Watch())) + core.AssertEqual(t, 1, backend.addCount()) +} + +func TestWatch_Config_StopWatch_Good(t *core.T) { + m := coreio.NewMockMedium() + path := watchConfigPath + core.RequireNoError(t, m.Write(path, watchNameYAML)) + backend := newFakeWatchBackend() + useFakeWatchBackend(t, backend) + cfg, err := configResult(New(WithMedium(m), WithPath(path))) + core.RequireNoError(t, err) + core.RequireNoError(t, resultError(cfg.Watch())) + + cfg.StopWatch() + core.AssertNil(t, cfg.watcher) + core.AssertTrue(t, backend.closed) +} + +func TestWatch_Config_StopWatch_Bad(t *core.T) { + cfg, err := configResult(New(WithMedium(coreio.NewMockMedium()), WithPath(watchConfigPath))) + core.RequireNoError(t, err) + cfg.StopWatch() + core.AssertNil(t, cfg.watcher) +} + +func TestWatch_Config_StopWatch_Ugly(t *core.T) { + m := coreio.NewMockMedium() + path := watchConfigPath + core.RequireNoError(t, m.Write(path, watchNameYAML)) + backend := newFakeWatchBackend() + useFakeWatchBackend(t, backend) + cfg, err := configResult(New(WithMedium(m), WithPath(path))) + core.RequireNoError(t, err) + core.RequireNoError(t, resultError(cfg.Watch())) + + cfg.StopWatch() + cfg.StopWatch() + core.AssertTrue(t, backend.closed) +} diff --git a/go/workspace.go b/go/workspace.go new file mode 100644 index 0000000..3c58d6d --- /dev/null +++ b/go/workspace.go @@ -0,0 +1,19 @@ +package config + +import ( + core "dappco.re/go" + coreio "dappco.re/go/io" +) + +// FindWorkspaceRoot returns the directory that contains the nearest +// .core/workspace.yaml while walking upward from start. If no workspace +// manifest exists, it returns an empty string. +// +// root := config.FindWorkspaceRoot(io.Local, cwd) +func FindWorkspaceRoot(medium coreio.Medium, start string) string { + path := FindWorkspaceManifest(medium, start) + if path == "" { + return "" + } + return core.PathDir(core.PathDir(path)) +} diff --git a/go/workspace_example_test.go b/go/workspace_example_test.go new file mode 100644 index 0000000..a7309dd --- /dev/null +++ b/go/workspace_example_test.go @@ -0,0 +1,17 @@ +package config + +import ( + core "dappco.re/go" + coreio "dappco.re/go/io" +) + +func ExampleFindWorkspaceRoot() { + m := coreio.NewMockMedium() + root := core.PathJoin("/", "workspace", "repo") + child := core.PathJoin(root, "service") + _ = m.EnsureDir(core.PathJoin(root, ".core")) + _ = m.EnsureDir(child) + _ = m.Write(core.PathJoin(root, ".core", FileWorkspace), "version: 1\n") + core.Println(FindWorkspaceRoot(m, child)) + // Output: /workspace/repo +} diff --git a/go/workspace_test.go b/go/workspace_test.go new file mode 100644 index 0000000..88a5100 --- /dev/null +++ b/go/workspace_test.go @@ -0,0 +1,31 @@ +package config + +import core "dappco.re/go" + +import coreio "dappco.re/go/io" + +func TestWorkspace_FindWorkspaceRoot_Good(t *core.T) { + m := coreio.NewMockMedium() + root := core.PathJoin("/", "workspace", "root") + child := core.PathJoin(root, "service") + + core.AssertNoError(t, m.EnsureDir(core.PathJoin(root, ".core"))) + core.AssertNoError(t, m.EnsureDir(child)) + core.AssertNoError(t, m.Write(core.PathJoin(root, ".core", FileWorkspace), "version: 1\n")) + + core.AssertEqual(t, root, FindWorkspaceRoot(m, child)) +} + +func TestWorkspace_FindWorkspaceRoot_Bad(t *core.T) { + m := coreio.NewMockMedium() + start := core.PathJoin("/", "workspace", "none") + got := FindWorkspaceRoot(m, start) + core.AssertEmpty(t, got) +} + +func TestWorkspace_FindWorkspaceRoot_Ugly(t *core.T) { + m := falseExistsMedium{coreio.NewMockMedium()} + start := core.PathJoin("/", "workspace", "repo", "file.go") + got := FindWorkspaceRoot(m, start) + core.AssertEqual(t, "", got) +} diff --git a/go/xdg.go b/go/xdg.go new file mode 100644 index 0000000..43d95e9 --- /dev/null +++ b/go/xdg.go @@ -0,0 +1,144 @@ +package config + +import ( + "strconv" + "syscall" + + core "dappco.re/go" +) + +// XDGPaths resolves platform-aware directories following the XDG Base Directory +// Specification on Linux, with sensible defaults for macOS and Windows. +// All paths are suffixed with the application prefix (default "core"). +// +// paths := config.XDG() +// paths.Config() // ~/.config/core on Linux +// paths.Data() // ~/.local/share/core on Linux +// paths.Cache() // ~/.cache/core on Linux +// paths.Runtime() // /run/user/{uid}/core on Linux +type XDGPaths struct { + prefix string // Application name (default: "core") +} + +// XDG returns a platform-aware path resolver using the default "core" prefix. +// +// configDir := config.XDG().Config() // ~/.config/core on Linux +func XDG() *XDGPaths { + return &XDGPaths{prefix: "core"} +} + +// XDGWithPrefix returns a path resolver using a custom application prefix. +// +// paths := config.XDGWithPrefix("myapp") +// paths.Config() // ~/.config/myapp on Linux +func XDGWithPrefix(prefix string) *XDGPaths { + if prefix == "" { + prefix = "core" + } + return &XDGPaths{prefix: prefix} +} + +// Config returns the configuration directory suffixed with the prefix. +// +// path := config.XDG().Config() // ~/.config/core +func (x *XDGPaths) Config() string { + return core.Path(xdgOrDefault("XDG_CONFIG_HOME", defaultConfigHome()), x.prefix) +} + +// Data returns the persistent data directory suffixed with the prefix. +// +// path := config.XDG().Data() // ~/.local/share/core +func (x *XDGPaths) Data() string { + return core.Path(xdgOrDefault("XDG_DATA_HOME", defaultDataHome()), x.prefix) +} + +// Cache returns the cache directory (safe to delete) suffixed with the prefix. +// +// path := config.XDG().Cache() // ~/.cache/core +func (x *XDGPaths) Cache() string { + return core.Path(xdgOrDefault("XDG_CACHE_HOME", defaultCacheHome()), x.prefix) +} + +// Runtime returns the ephemeral runtime directory suffixed with the prefix. +// +// path := config.XDG().Runtime() // /run/user/1000/core on Linux +func (x *XDGPaths) Runtime() string { + return core.Path(xdgOrDefault("XDG_RUNTIME_DIR", defaultRuntimeDir()), x.prefix) +} + +// Prefix returns the application prefix used by this resolver. +// +// core.XDG().Prefix() // "core" +func (x *XDGPaths) Prefix() string { + return x.prefix +} + +func xdgOrDefault(envVar, fallback string) string { + if v := core.Getenv(envVar); v != "" { + return v + } + return fallback +} + +func home() string { + if h := core.Env("DIR_HOME"); h != "" { + return h + } + return "." +} + +func defaultConfigHome() string { + switch core.Env("OS") { + case "darwin": + return core.Path(home(), "Library", "Application Support") + case "windows": + if v := core.Getenv("APPDATA"); v != "" { + return v + } + return core.Path(home(), "AppData", "Roaming") + default: + return core.Path(home(), ".config") + } +} + +func defaultDataHome() string { + switch core.Env("OS") { + case "darwin": + return core.Path(home(), "Library", "Application Support") + case "windows": + if v := core.Getenv("LOCALAPPDATA"); v != "" { + return v + } + return core.Path(home(), "AppData", "Local") + default: + return core.Path(home(), ".local", "share") + } +} + +func defaultCacheHome() string { + switch core.Env("OS") { + case "darwin": + return core.Path(home(), "Library", "Caches") + case "windows": + if v := core.Getenv("LOCALAPPDATA"); v != "" { + return core.Path(v, "cache") + } + return core.Path(home(), "AppData", "Local", "cache") + default: + return core.Path(home(), ".cache") + } +} + +func defaultRuntimeDir() string { + switch core.Env("OS") { + case "darwin": + return core.Path(home(), "Library", "Caches") + case "windows": + if v := core.Getenv("LOCALAPPDATA"); v != "" { + return core.Path(v, "temp") + } + return core.Path(home(), "AppData", "Local", "temp") + default: + return core.Path("/run/user", strconv.Itoa(syscall.Getuid())) + } +} diff --git a/go/xdg_example_test.go b/go/xdg_example_test.go new file mode 100644 index 0000000..0330384 --- /dev/null +++ b/go/xdg_example_test.go @@ -0,0 +1,45 @@ +package config + +import core "dappco.re/go" + +func ExampleXDG() { + paths := XDG() + core.Println(paths.Prefix()) + // Output: core +} + +func ExampleXDGWithPrefix() { + paths := XDGWithPrefix("app") + core.Println(paths.Prefix()) + // Output: app +} + +func ExampleXDGPaths_Config() { + paths := XDGWithPrefix("app") + core.Println(core.HasSuffix(paths.Config(), core.PathJoin("app"))) + // Output: true +} + +func ExampleXDGPaths_Data() { + paths := XDGWithPrefix("app") + core.Println(core.HasSuffix(paths.Data(), core.PathJoin("app"))) + // Output: true +} + +func ExampleXDGPaths_Cache() { + paths := XDGWithPrefix("app") + core.Println(core.HasSuffix(paths.Cache(), core.PathJoin("app"))) + // Output: true +} + +func ExampleXDGPaths_Runtime() { + paths := XDGWithPrefix("app") + core.Println(core.HasSuffix(paths.Runtime(), core.PathJoin("app"))) + // Output: true +} + +func ExampleXDGPaths_Prefix() { + paths := XDGWithPrefix("app") + core.Println(paths.Prefix()) + // Output: app +} diff --git a/go/xdg_test.go b/go/xdg_test.go new file mode 100644 index 0000000..1ed223e --- /dev/null +++ b/go/xdg_test.go @@ -0,0 +1,159 @@ +package config + +import ( + core "dappco.re/go" +) + +const ( + xdgCoreSuffixUnix = "/core" + xdgCoreSuffixWindows = "\\core" + xdgCoreToolsPrefix = "core tools" +) + +func TestXdg_XDG_Good(t *core.T) { + paths := XDG() + core.AssertEqual(t, "core", paths.Prefix()) + core.AssertTrue(t, core.HasSuffix(paths.Config(), xdgCoreSuffixUnix) || core.HasSuffix(paths.Config(), xdgCoreSuffixWindows)) + core.AssertTrue(t, core.HasSuffix(paths.Data(), xdgCoreSuffixUnix) || core.HasSuffix(paths.Data(), xdgCoreSuffixWindows)) + core.AssertTrue(t, core.HasSuffix(paths.Cache(), xdgCoreSuffixUnix) || core.HasSuffix(paths.Cache(), xdgCoreSuffixWindows)) + core.AssertTrue(t, core.HasSuffix(paths.Runtime(), xdgCoreSuffixUnix) || core.HasSuffix(paths.Runtime(), xdgCoreSuffixWindows)) +} + +func TestXdg_XDG_Bad(t *core.T) { + // An empty prefix falls back to the default "core" — no panic, no empty paths. + defaultPaths := XDG() + paths := XDGWithPrefix("") + core.AssertEqual(t, defaultPaths.Prefix(), paths.Prefix()) + configPath := paths.Config() + core.AssertEqual(t, "core", paths.Prefix()) + core.AssertContains(t, configPath, "core") +} + +func TestXdg_XDG_Ugly(t *core.T) { + // Overriding XDG_CONFIG_HOME via env must change the resolved Config dir. + t.Setenv("XDG_CONFIG_HOME", "/custom/config") + defaultPaths := XDG() + core.AssertContains(t, defaultPaths.Config(), "core") + paths := XDGWithPrefix("myapp") + core.AssertTrue(t, core.HasSuffix(paths.Config(), core.PathJoin("custom", "config", "myapp"))) +} + +func TestXdg_XDGWithPrefix_Good(t *core.T) { + paths := XDGWithPrefix("testing") + core.AssertEqual(t, "testing", paths.Prefix()) + core.AssertContains(t, paths.Config(), "testing") +} + +func TestXdg_defaultConfigHome_DefaultHomes_Ugly(t *core.T) { + configHome := defaultConfigHome() + dataHome := defaultDataHome() + core.AssertNotEmpty(t, configHome) + core.AssertNotEmpty(t, dataHome) +} + +func TestXdg_XDGWithPrefix_Bad(t *core.T) { + paths := XDGWithPrefix("") + got := paths.Prefix() + core.AssertEqual(t, "core", got) +} + +func TestXdg_XDGWithPrefix_Ugly(t *core.T) { + paths := XDGWithPrefix(xdgCoreToolsPrefix) + got := paths.Config() + core.AssertContains(t, got, xdgCoreToolsPrefix) +} + +func TestXdg_XDGPaths_Config_Good(t *core.T) { + paths := XDGWithPrefix("codex") + got := paths.Config() + core.AssertContains(t, got, "codex") +} + +func TestXdg_XDGPaths_Config_Bad(t *core.T) { + paths := XDGWithPrefix("") + got := paths.Config() + core.AssertContains(t, got, "core") +} + +func TestXdg_XDGPaths_Config_Ugly(t *core.T) { + t.Setenv("XDG_CONFIG_HOME", core.PathJoin(t.TempDir(), "config")) + paths := XDGWithPrefix("codex") + got := paths.Config() + core.AssertTrue(t, core.HasSuffix(got, core.PathJoin("config", "codex"))) +} + +func TestXdg_XDGPaths_Data_Good(t *core.T) { + paths := XDGWithPrefix("codex") + got := paths.Data() + core.AssertContains(t, got, "codex") +} + +func TestXdg_XDGPaths_Data_Bad(t *core.T) { + paths := XDGWithPrefix("") + got := paths.Data() + core.AssertContains(t, got, "core") +} + +func TestXdg_XDGPaths_Data_Ugly(t *core.T) { + t.Setenv("XDG_DATA_HOME", core.PathJoin(t.TempDir(), "data")) + paths := XDGWithPrefix("codex") + got := paths.Data() + core.AssertTrue(t, core.HasSuffix(got, core.PathJoin("data", "codex"))) +} + +func TestXdg_XDGPaths_Cache_Good(t *core.T) { + paths := XDGWithPrefix("codex") + got := paths.Cache() + core.AssertContains(t, got, "codex") +} + +func TestXdg_XDGPaths_Cache_Bad(t *core.T) { + paths := XDGWithPrefix("") + got := paths.Cache() + core.AssertContains(t, got, "core") +} + +func TestXdg_XDGPaths_Cache_Ugly(t *core.T) { + t.Setenv("XDG_CACHE_HOME", core.PathJoin(t.TempDir(), "cache")) + paths := XDGWithPrefix("codex") + got := paths.Cache() + core.AssertTrue(t, core.HasSuffix(got, core.PathJoin("cache", "codex"))) +} + +func TestXdg_XDGPaths_Runtime_Good(t *core.T) { + paths := XDGWithPrefix("codex") + got := paths.Runtime() + core.AssertContains(t, got, "codex") +} + +func TestXdg_XDGPaths_Runtime_Bad(t *core.T) { + paths := XDGWithPrefix("") + got := paths.Runtime() + core.AssertContains(t, got, "core") +} + +func TestXdg_XDGPaths_Runtime_Ugly(t *core.T) { + t.Setenv("XDG_RUNTIME_DIR", core.PathJoin(t.TempDir(), "runtime")) + paths := XDGWithPrefix("codex") + got := paths.Runtime() + core.AssertTrue(t, core.HasSuffix(got, core.PathJoin("runtime", "codex"))) +} + +func TestXdg_XDGPaths_Prefix_Good(t *core.T) { + paths := XDGWithPrefix("codex") + got := paths.Prefix() + core.AssertEqual(t, "codex", got) +} + +func TestXdg_XDGPaths_Prefix_Bad(t *core.T) { + paths := XDGWithPrefix("") + got := paths.Prefix() + core.AssertEqual(t, "core", got) + core.AssertContains(t, paths.Config(), xdgCoreSuffixUnix) +} + +func TestXdg_XDGPaths_Prefix_Ugly(t *core.T) { + paths := XDGWithPrefix(xdgCoreToolsPrefix) + got := paths.Prefix() + core.AssertEqual(t, xdgCoreToolsPrefix, got) +} diff --git a/schema/agent.schema.json b/schema/agent.schema.json new file mode 100644 index 0000000..64759aa --- /dev/null +++ b/schema/agent.schema.json @@ -0,0 +1,54 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "daemon": { + "type": "object", + "properties": { + "enabled": { "type": "boolean" }, + "watch": { + "type": "array", + "items": { "type": "string" } + }, + "schedule": { + "type": "array", + "items": { + "type": "object", + "properties": { + "cron": { "type": "string" }, + "action": { "type": "string" } + }, + "additionalProperties": true + } + }, + "mcp": { + "type": "object", + "properties": { + "port": { "type": "integer", "minimum": 1, "maximum": 65535 } + }, + "additionalProperties": true + }, + "api": { + "type": "object", + "properties": { + "port": { "type": "integer", "minimum": 1, "maximum": 65535 }, + "bind": { "type": "string" } + }, + "additionalProperties": true + } + }, + "additionalProperties": true + }, + "agents": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "total": { "type": "integer", "minimum": 0 } + }, + "additionalProperties": true + } + } + }, + "additionalProperties": true +} diff --git a/schema/build.schema.json b/schema/build.schema.json new file mode 100644 index 0000000..df00e11 --- /dev/null +++ b/schema/build.schema.json @@ -0,0 +1,112 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "version": { "type": "integer" }, + "project": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "main": { "type": "string" }, + "binary": { "type": "string" }, + "output": { "type": "string" } + }, + "additionalProperties": true + }, + "build": { + "type": "object", + "properties": { + "type": { "type": "string" }, + "cgo": { "type": "boolean" }, + "flags": { + "type": "array", + "items": { "type": "string" } + }, + "ldflags": { + "oneOf": [ + { "type": "string" }, + { + "type": "array", + "items": { "type": "string" } + } + ] + } + }, + "additionalProperties": true + }, + "targets": { + "type": "array", + "items": { + "oneOf": [ + { "type": "string" }, + { + "type": "object", + "properties": { + "os": { "type": "string" }, + "arch": { "type": "string" } + }, + "additionalProperties": true + } + ] + } + }, + "sign": { + "type": "object", + "properties": { + "enabled": { "type": "boolean" }, + "gpg": { + "type": "object", + "properties": { + "key": { "type": "string" } + }, + "additionalProperties": true + }, + "macos": { + "type": "object", + "properties": { + "identity": { "type": "string" }, + "notarize": { "type": "boolean" } + }, + "additionalProperties": true + } + }, + "additionalProperties": true + }, + "sdk": { + "type": "object", + "properties": { + "spec": { "type": "string" }, + "languages": { + "type": "array", + "items": { "type": "string" } + }, + "output": { "type": "string" }, + "diff": { "type": "boolean" } + }, + "additionalProperties": true + }, + "name": { "type": "string" }, + "main": { "type": "string" }, + "binary": { "type": "string" }, + "output": { "type": "string" }, + "flags": { + "type": "array", + "items": { "type": "string" } + }, + "ldflags": { + "oneOf": [ + { "type": "string" }, + { + "type": "array", + "items": { "type": "string" } + } + ] + }, + "cgo": { "type": "boolean" }, + "env": { + "type": "object", + "additionalProperties": { "type": "string" } + } + }, + "additionalProperties": true +} diff --git a/schema/config.schema.json b/schema/config.schema.json new file mode 100644 index 0000000..81086a9 --- /dev/null +++ b/schema/config.schema.json @@ -0,0 +1,40 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "version": { + "type": "integer" + }, + "app": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "version": { "type": "string" } + }, + "additionalProperties": true + }, + "dev": { + "type": "object", + "properties": { + "editor": { "type": "string" }, + "language": { "type": "string" } + }, + "additionalProperties": true + }, + "features": { + "type": "object", + "additionalProperties": { "type": "boolean" } + }, + "services": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "url": { "type": "string" } + }, + "additionalProperties": true + } + } + }, + "additionalProperties": true +} diff --git a/schema/ide.schema.json b/schema/ide.schema.json new file mode 100644 index 0000000..9bd76ab --- /dev/null +++ b/schema/ide.schema.json @@ -0,0 +1,8 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "version": { "type": "integer" } + }, + "additionalProperties": true +} diff --git a/schema/images.schema.json b/schema/images.schema.json new file mode 100644 index 0000000..5c383f6 --- /dev/null +++ b/schema/images.schema.json @@ -0,0 +1,25 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "images": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "version": { "type": "string" }, + "sha256": { + "type": "string", + "pattern": "^[A-Fa-f0-9]{64}$", + "minLength": 64, + "maxLength": 64 + }, + "downloaded": { "type": "string", "format": "date-time" }, + "source": { "type": "string" } + }, + "additionalProperties": true + } + } + }, + "additionalProperties": true +} diff --git a/schema/manifest.schema.json b/schema/manifest.schema.json new file mode 100644 index 0000000..8402931 --- /dev/null +++ b/schema/manifest.schema.json @@ -0,0 +1,23 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "code": { "type": "string" }, + "name": { "type": "string" }, + "module": { "type": "string" }, + "version": { "type": "string" }, + "description": { "type": "string" }, + "licence": { "type": "string" }, + "sign": { "type": "string" }, + "sign_key": { "type": "string" }, + "dependencies": { + "type": "array", + "items": { "type": "string" } + }, + "tags": { + "type": "array", + "items": { "type": "string" } + } + }, + "additionalProperties": true +} diff --git a/schema/php.schema.json b/schema/php.schema.json new file mode 100644 index 0000000..9bd76ab --- /dev/null +++ b/schema/php.schema.json @@ -0,0 +1,8 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "version": { "type": "integer" } + }, + "additionalProperties": true +} diff --git a/schema/release.schema.json b/schema/release.schema.json new file mode 100644 index 0000000..f73ecb0 --- /dev/null +++ b/schema/release.schema.json @@ -0,0 +1,38 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "version": { "type": "integer" }, + "archive": { + "type": "object", + "properties": { + "format": { "type": "string" }, + "include": { + "type": "array", + "items": { "type": "string" } + } + }, + "additionalProperties": true + }, + "checksums": { "type": "boolean" }, + "github": { + "type": "object", + "properties": { + "draft": { "type": "boolean" }, + "prerelease": { "type": "boolean" } + }, + "additionalProperties": true + }, + "changelog": { + "type": "object", + "properties": { + "include": { + "type": "array", + "items": { "type": "string" } + } + }, + "additionalProperties": true + } + }, + "additionalProperties": true +} diff --git a/schema/repos.schema.json b/schema/repos.schema.json new file mode 100644 index 0000000..40941e8 --- /dev/null +++ b/schema/repos.schema.json @@ -0,0 +1,27 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "version": { "type": "integer" }, + "org": { "type": "string" }, + "repos": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { "type": "string" }, + "remote": { "type": "string" }, + "branch": { "type": "string" }, + "type": { "type": "string" }, + "description": { "type": "string" }, + "depends": { + "type": "array", + "items": { "type": "string" } + } + }, + "additionalProperties": true + } + } + }, + "additionalProperties": true +} diff --git a/schema/run.schema.json b/schema/run.schema.json new file mode 100644 index 0000000..90550ce --- /dev/null +++ b/schema/run.schema.json @@ -0,0 +1,40 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "version": { "type": "integer" }, + "services": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "image": { "type": "string" }, + "port": { "type": "integer", "minimum": 1, "maximum": 65535 }, + "env": { + "type": "object", + "additionalProperties": { "type": "string" } + } + }, + "additionalProperties": true + } + }, + "dev": { + "type": "object", + "properties": { + "command": { "type": "string" }, + "port": { "type": "integer", "minimum": 1, "maximum": 65535 }, + "watch": { + "type": "array", + "items": { "type": "string" } + } + }, + "additionalProperties": true + }, + "env": { + "type": "object", + "additionalProperties": { "type": "string" } + } + }, + "additionalProperties": true +} diff --git a/schema/test.schema.json b/schema/test.schema.json new file mode 100644 index 0000000..e4fe73e --- /dev/null +++ b/schema/test.schema.json @@ -0,0 +1,23 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "version": { "type": "integer" }, + "commands": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "run": { "type": "string" } + }, + "additionalProperties": true + } + }, + "env": { + "type": "object", + "additionalProperties": { "type": "string" } + } + }, + "additionalProperties": true +} diff --git a/schema/view.schema.json b/schema/view.schema.json new file mode 100644 index 0000000..9a62a8b --- /dev/null +++ b/schema/view.schema.json @@ -0,0 +1,52 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "version": { "type": ["integer", "string"] }, + "code": { "type": "string" }, + "name": { "type": "string" }, + "sign": { "type": "string" }, + "title": { "type": "string" }, + "width": { "type": "integer", "minimum": 0 }, + "height": { "type": "integer", "minimum": 0 }, + "resizable": { "type": "boolean" }, + "layout": { "type": "string" }, + "slots": { + "type": "object", + "additionalProperties": true + }, + "modules": { + "type": "array", + "items": { "type": "string" } + }, + "permissions": { + "type": "object", + "properties": { + "clipboard": { "type": "boolean" }, + "filesystem": { "type": "boolean" }, + "network": { "type": "boolean" }, + "notifications": { "type": "boolean" }, + "camera": { "type": "boolean" }, + "microphone": { "type": "boolean" }, + "read": { + "type": "array", + "items": { "type": "string" } + }, + "net": { + "type": "array", + "items": { "type": "string" } + }, + "run": { + "type": "array", + "items": { "type": "string" } + } + }, + "additionalProperties": true + }, + "config": { + "type": "object", + "additionalProperties": true + } + }, + "additionalProperties": true +} diff --git a/schema/workspace.schema.json b/schema/workspace.schema.json new file mode 100644 index 0000000..9947f20 --- /dev/null +++ b/schema/workspace.schema.json @@ -0,0 +1,18 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "version": { "type": "integer" }, + "dependencies": { + "type": "array", + "items": { "type": "string" } + }, + "active": { "type": "string" }, + "packages_dir": { "type": "string" }, + "settings": { + "type": "object", + "additionalProperties": true + } + }, + "additionalProperties": true +} diff --git a/schema/zone.schema.json b/schema/zone.schema.json new file mode 100644 index 0000000..7b576e4 --- /dev/null +++ b/schema/zone.schema.json @@ -0,0 +1,87 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "required": ["zone"], + "properties": { + "zone": { + "type": "object", + "required": ["name", "identity"], + "properties": { + "name": { "type": "string" }, + "identity": { "type": "string" }, + "chain": { + "type": "object", + "required": ["mode", "daemon"], + "properties": { + "mode": { "type": "string" }, + "daemon": { "type": "string" } + }, + "additionalProperties": false + }, + "network": { + "type": "object", + "required": ["wireguard"], + "properties": { + "wireguard": { + "type": "object", + "required": ["interface", "listen"], + "properties": { + "interface": { "type": "string" }, + "listen": { "type": "integer", "minimum": 1, "maximum": 65535 } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "services": { + "type": "object", + "properties": { + "vpn": { + "type": "object", + "required": ["enabled"], + "properties": { + "enabled": { "type": "boolean" }, + "price": { "type": "number", "minimum": 0 }, + "capacity": { "type": "integer", "minimum": 0 } + }, + "additionalProperties": false + }, + "dns": { + "type": "object", + "required": ["enabled"], + "properties": { + "enabled": { "type": "boolean" } + }, + "additionalProperties": false + }, + "compute": { + "type": "object", + "required": ["enabled", "models"], + "properties": { + "enabled": { "type": "boolean" }, + "models": { + "type": "array", + "items": { "type": "string" } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "staking": { + "type": "object", + "required": ["amount", "tier"], + "properties": { + "amount": { "type": "integer", "minimum": 0 }, + "tier": { "type": "string" } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false +} diff --git a/service.go b/service.go deleted file mode 100644 index fed7300..0000000 --- a/service.go +++ /dev/null @@ -1,89 +0,0 @@ -package config - -import core "dappco.re/go" - -const errConfigNotLoaded = "config not loaded" - -// Service wraps Config as a framework service with lifecycle support. -type Service struct { - *core.ServiceRuntime[ServiceOptions] - config *Config -} - -// ServiceOptions holds configuration for the config service. -type ServiceOptions struct { - // Path overrides the default config file path. - Path string - // EnvPrefix overrides the default environment variable prefix. - EnvPrefix string - // Medium overrides the default storage medium. - Medium Medium -} - -// NewConfigService creates a new config service factory for the Core framework. -// Register it with core.WithService(config.NewConfigService). -func NewConfigService(c *core.Core) core.Result { - svc := &Service{ - ServiceRuntime: core.NewServiceRuntime(c, ServiceOptions{}), - } - return core.Ok(svc) -} - -// OnStartup loads the configuration file during application startup. -func (s *Service) OnStartup(_ core.Context) core.Result { - opts := s.Options() - - var configOpts []Option - if opts.Path != "" { - configOpts = append(configOpts, WithPath(opts.Path)) - } - if opts.EnvPrefix != "" { - configOpts = append(configOpts, WithEnvPrefix(opts.EnvPrefix)) - } - if opts.Medium != nil { - configOpts = append(configOpts, WithMedium(opts.Medium)) - } - - r := New(configOpts...) - if !r.OK { - return core.Fail(core.E("config.Service.OnStartup", "failed to create config", core.NewError(r.Error()))) - } - - s.config = r.Value.(*Config) - return core.Ok(nil) -} - -// Get retrieves a configuration value by key. -func (s *Service) Get(key string, out any) core.Result { - if s == nil || s.config == nil { - return core.Fail(core.E("config.Service.Get", errConfigNotLoaded, nil)) - } - return s.config.Get(key, out) -} - -// Set stores a configuration value by key. -func (s *Service) Set(key string, v any) core.Result { - if s == nil || s.config == nil { - return core.Fail(core.E("config.Service.Set", errConfigNotLoaded, nil)) - } - return s.config.Set(key, v) -} - -// Commit persists any configuration changes to disk. -func (s *Service) Commit() core.Result { - if s == nil || s.config == nil { - return core.Fail(core.E("config.Service.Commit", errConfigNotLoaded, nil)) - } - return s.config.Commit() -} - -// LoadFile merges a configuration file into the central configuration. -func (s *Service) LoadFile(m Medium, path string) core.Result { - if s == nil || s.config == nil { - return core.Fail(core.E("config.Service.LoadFile", errConfigNotLoaded, nil)) - } - return s.config.LoadFile(m, path) -} - -// Ensure Service implements Startable at compile time. -var _ core.Startable = (*Service)(nil) diff --git a/service_example_test.go b/service_example_test.go deleted file mode 100644 index d89f2b8..0000000 --- a/service_example_test.go +++ /dev/null @@ -1,103 +0,0 @@ -package config_test - -import ( - . "dappco.re/go" - config "dappco.re/go/config" -) - -func ExampleNewConfigService() { - r := config.NewConfigService(New()) - svc := r.Value.(*config.Service) - - Println(r.OK) - Println(svc != nil) - // Output: - // true - // true -} - -func ExampleService_OnStartup() { - fs, path, cleanup := exampleConfigMedium("go-config-service-startup") - defer cleanup() - _ = fs.Write(path, "agent: service\n") - svc := &config.Service{ServiceRuntime: NewServiceRuntime(nil, config.ServiceOptions{Path: path, Medium: fs})} - - r := svc.OnStartup(Background()) - var agent string - _ = svc.Get("agent", &agent) - - Println(r.OK) - Println(agent) - // Output: - // true - // service -} - -func ExampleService_Get() { - fs, path, cleanup := exampleConfigMedium("go-config-service-get") - defer cleanup() - svc := &config.Service{ServiceRuntime: NewServiceRuntime(nil, config.ServiceOptions{Path: path, Medium: fs})} - _ = svc.OnStartup(Background()) - _ = svc.Set("agent", "codex") - - var agent string - r := svc.Get("agent", &agent) - - Println(r.OK) - Println(agent) - // Output: - // true - // codex -} - -func ExampleService_Set() { - fs, path, cleanup := exampleConfigMedium("go-config-service-set") - defer cleanup() - svc := &config.Service{ServiceRuntime: NewServiceRuntime(nil, config.ServiceOptions{Path: path, Medium: fs})} - _ = svc.OnStartup(Background()) - - r := svc.Set("agent", "codex") - var agent string - _ = svc.Get("agent", &agent) - - Println(r.OK) - Println(agent) - // Output: - // true - // codex -} - -func ExampleService_Commit() { - fs, path, cleanup := exampleConfigMedium("go-config-service-commit") - defer cleanup() - svc := &config.Service{ServiceRuntime: NewServiceRuntime(nil, config.ServiceOptions{Path: path, Medium: fs})} - _ = svc.OnStartup(Background()) - _ = svc.Set("agent", "codex") - - r := svc.Commit() - content := fs.Read(path) - - Println(r.OK) - Println(Contains(content.Value.(string), testAgentCodexText)) - // Output: - // true - // true -} - -func ExampleService_LoadFile() { - fs, path, cleanup := exampleConfigMedium("go-config-service-loadfile") - defer cleanup() - _ = fs.Write(testExtraYAMLPath, testAgentCodexYAML) - svc := &config.Service{ServiceRuntime: NewServiceRuntime(nil, config.ServiceOptions{Path: path, Medium: fs})} - _ = svc.OnStartup(Background()) - - r := svc.LoadFile(fs, testExtraYAMLPath) - var agent string - _ = svc.Get("agent", &agent) - - Println(r.OK) - Println(agent) - // Output: - // true - // codex -} diff --git a/service_test.go b/service_test.go deleted file mode 100644 index fff3ff9..0000000 --- a/service_test.go +++ /dev/null @@ -1,221 +0,0 @@ -package config_test - -import ( - . "dappco.re/go" - config "dappco.re/go/config" -) - -func TestService_NewConfigService_Good(t *T) { - r := config.NewConfigService(New()) - - AssertTrue(t, r.OK, r.Error()) - svc, ok := r.Value.(*config.Service) - AssertTrue(t, ok) - AssertNotNil(t, svc) -} - -func TestService_NewConfigService_Bad(t *T) { - r := config.NewConfigService(nil) - RequireTrue(t, r.OK, r.Error()) - svc := r.Value.(*config.Service) - - var value string - get := svc.Get("missing", &value) - - AssertFalse(t, get.OK) - AssertContains(t, get.Error(), testConfigNotLoaded) -} - -func TestService_NewConfigService_Ugly(t *T) { - c := New(WithService(config.NewConfigService)) - - svc, ok := ServiceFor[*config.Service](c, "config") - - AssertTrue(t, ok) - AssertNotNil(t, svc) -} - -func TestService_Service_OnStartup_Good(t *T) { - fs, path := configTestMedium(t) - writeConfigFile(t, fs, path, "app:\n name: service\n") - svc := &config.Service{ServiceRuntime: NewServiceRuntime(nil, config.ServiceOptions{Path: path, Medium: fs})} - - r := svc.OnStartup(Background()) - - AssertTrue(t, r.OK, r.Error()) - var name string - get := svc.Get(testAppNameKey, &name) - AssertTrue(t, get.OK, get.Error()) - AssertEqual(t, "service", name) -} - -func TestService_Service_OnStartup_Bad(t *T) { - fs := configTestFS(t) - writeConfigFile(t, fs, testConfigTextPath, "app.name=service") - svc := &config.Service{ServiceRuntime: NewServiceRuntime(nil, config.ServiceOptions{Path: testConfigTextPath, Medium: fs})} - - r := svc.OnStartup(Background()) - - AssertFalse(t, r.OK) - AssertContains(t, r.Error(), testUnsupportedConfigFileType) -} - -func TestService_Service_OnStartup_Ugly(t *T) { - t.Setenv("SERVICE_SETTING", "secret") - fs, path := configTestMedium(t) - svc := &config.Service{ - ServiceRuntime: NewServiceRuntime(nil, config.ServiceOptions{Path: path, EnvPrefix: "SERVICE", Medium: fs}), - } - - r := svc.OnStartup(Background()) - - AssertTrue(t, r.OK, r.Error()) - var setting string - get := svc.Get("setting", &setting) - AssertTrue(t, get.OK, get.Error()) - AssertEqual(t, "secret", setting) -} - -func TestService_Service_Get_Good(t *T) { - fs, path := configTestMedium(t) - svc := startedService(t, config.ServiceOptions{Path: path, Medium: fs}) - RequireTrue(t, svc.Set("agent", "codex").OK) - - var agent string - r := svc.Get("agent", &agent) - - AssertTrue(t, r.OK, r.Error()) - AssertEqual(t, "codex", agent) -} - -func TestService_Service_Get_Bad(t *T) { - svc := &config.Service{} - - var agent string - r := svc.Get("agent", &agent) - - AssertFalse(t, r.OK) - AssertContains(t, r.Error(), testConfigNotLoaded) -} - -func TestService_Service_Get_Ugly(t *T) { - fs := configTestFS(t) - writeConfigFile(t, fs, testConfigYAMLPath, "app:\n name: service\n") - svc := startedService(t, config.ServiceOptions{Path: testConfigYAMLPath, Medium: fs}) - - var full struct { - App struct { - Name string `mapstructure:"name"` - } `mapstructure:"app"` - } - r := svc.Get("", &full) - - AssertTrue(t, r.OK, r.Error()) - AssertEqual(t, "service", full.App.Name) -} - -func TestService_Service_Set_Good(t *T) { - fs, path := configTestMedium(t) - svc := startedService(t, config.ServiceOptions{Path: path, Medium: fs}) - - r := svc.Set("agent", "codex") - - AssertTrue(t, r.OK, r.Error()) - var agent string - get := svc.Get("agent", &agent) - AssertTrue(t, get.OK, get.Error()) - AssertEqual(t, "codex", agent) -} - -func TestService_Service_Set_Bad(t *T) { - svc := &config.Service{} - - r := svc.Set("agent", "codex") - - AssertFalse(t, r.OK) - AssertContains(t, r.Error(), testConfigNotLoaded) -} - -func TestService_Service_Set_Ugly(t *T) { - fs, path := configTestMedium(t) - svc := startedService(t, config.ServiceOptions{Path: path, Medium: fs}) - - r := svc.Set("nested.agent", "codex") - - AssertTrue(t, r.OK, r.Error()) - var agent string - get := svc.Get("nested.agent", &agent) - AssertTrue(t, get.OK, get.Error()) - AssertEqual(t, "codex", agent) -} - -func TestService_Service_Commit_Good(t *T) { - fs, path := configTestMedium(t) - svc := startedService(t, config.ServiceOptions{Path: path, Medium: fs}) - RequireTrue(t, svc.Set("agent", "codex").OK) - - r := svc.Commit() - - AssertTrue(t, r.OK, r.Error()) - content := fs.Read(path) - RequireTrue(t, content.OK, content.Error()) - AssertContains(t, content.Value.(string), testAgentCodexText) -} - -func TestService_Service_Commit_Bad(t *T) { - svc := &config.Service{} - - r := svc.Commit() - - AssertFalse(t, r.OK) - AssertContains(t, r.Error(), testConfigNotLoaded) -} - -func TestService_Service_Commit_Ugly(t *T) { - fs := configTestFS(t) - svc := startedService(t, config.ServiceOptions{Path: testConfigJSONPath, Medium: fs}) - RequireTrue(t, svc.Set("agent", "codex").OK) - - r := svc.Commit() - - AssertFalse(t, r.OK) - AssertContains(t, r.Error(), testUnsupportedConfigFileType) -} - -func TestService_Service_LoadFile_Good(t *T) { - fs, path := configTestMedium(t) - writeConfigFile(t, fs, testExtraYAMLPath, testAgentCodexYAML) - svc := startedService(t, config.ServiceOptions{Path: path, Medium: fs}) - - r := svc.LoadFile(fs, testExtraYAMLPath) - - AssertTrue(t, r.OK, r.Error()) - var agent string - get := svc.Get("agent", &agent) - AssertTrue(t, get.OK, get.Error()) - AssertEqual(t, "codex", agent) -} - -func TestService_Service_LoadFile_Bad(t *T) { - svc := &config.Service{} - fs, path := configTestMedium(t) - - r := svc.LoadFile(fs, path) - - AssertFalse(t, r.OK) - AssertContains(t, r.Error(), testConfigNotLoaded) -} - -func TestService_Service_LoadFile_Ugly(t *T) { - fs, path := configTestMedium(t) - writeConfigFile(t, fs, testDotEnvPath, "TOKEN=abc\n") - svc := startedService(t, config.ServiceOptions{Path: path, Medium: fs}) - - r := svc.LoadFile(fs, testDotEnvPath) - - AssertTrue(t, r.OK, r.Error()) - var token string - get := svc.Get("token", &token) - AssertTrue(t, get.OK, get.Error()) - AssertEqual(t, "abc", token) -} diff --git a/sonar-project.properties b/sonar-project.properties index bdf7c23..93ec960 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,8 +1,8 @@ -sonar.projectKey=core_go-config -sonar.projectName=core/go-config +sonar.projectKey=core_config +sonar.projectName=core/config sonar.sources=. sonar.exclusions=**/vendor/**,**/third_party/**,**/.tmp/**,**/gomodcache/**,**/node_modules/**,**/dist/**,**/build/**,**/*_test.go,**/*.test.ts,**/*.test.js,**/*.spec.ts,**/*.spec.js sonar.tests=. sonar.test.inclusions=**/*_test.go,**/*.test.ts,**/*.test.js,**/*.spec.ts,**/*.spec.js sonar.test.exclusions=**/vendor/**,**/third_party/**,**/.tmp/**,**/gomodcache/**,**/node_modules/**,**/dist/**,**/build/** -sonar.go.coverage.reportPaths=coverage.out +sonar.go.coverage.reportPaths=go/coverage.out diff --git a/tests/cli/config/Taskfile.yaml b/tests/cli/config/Taskfile.yaml deleted file mode 100644 index 7bcc9d9..0000000 --- a/tests/cli/config/Taskfile.yaml +++ /dev/null @@ -1,27 +0,0 @@ ---- -version: "3" - -tasks: - default: - deps: - - build - - vet - - test - - build: - desc: Compile every package in go-config. - dir: ../../.. - cmds: - - GOWORK=off go build ./... - - vet: - desc: Run go vet across the module. - dir: ../../.. - cmds: - - GOWORK=off go vet ./... - - test: - desc: Run unit tests. - dir: ../../.. - cmds: - - GOWORK=off go test -count=1 ./...