feat(lib): embed AX spec + Core source in default workspace template

Reference files (.core/reference/) are now part of the embedded
workspace template. ExtractWorkspace extracts them automatically —
no hardcoded filesystem paths, ships with the binary.

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Snider 2026-03-22 06:59:52 +00:00
parent 4f66eb4cca
commit 6f5b239da6
23 changed files with 4300 additions and 0 deletions

View file

@ -0,0 +1,303 @@
# RFC-025: Agent Experience (AX) Design Principles
- **Status:** Draft
- **Authors:** Snider, Cladius
- **Date:** 2026-03-19
- **Applies to:** All Core ecosystem packages (CoreGO, CorePHP, CoreTS, core-agent)
## Abstract
Agent Experience (AX) is a design paradigm for software systems where the primary code consumer is an AI agent, not a human developer. AX sits alongside User Experience (UX) and Developer Experience (DX) as the third era of interface design.
This RFC establishes AX as a formal design principle for the Core ecosystem and defines the conventions that follow from it.
## Motivation
As of early 2026, AI agents write, review, and maintain the majority of code in the Core ecosystem. The original author has not manually edited code (outside of Core struct design) since October 2025. Code is processed semantically — agents reason about intent, not characters.
Design patterns inherited from the human-developer era optimise for the wrong consumer:
- **Short names** save keystrokes but increase semantic ambiguity
- **Functional option chains** are fluent for humans but opaque for agents tracing configuration
- **Error-at-every-call-site** produces 50% boilerplate that obscures intent
- **Generic type parameters** force agents to carry type context that the runtime already has
- **Panic-hiding conventions** (`Must*`) create implicit control flow that agents must special-case
AX acknowledges this shift and provides principles for designing code, APIs, file structures, and conventions that serve AI agents as first-class consumers.
## The Three Eras
| Era | Primary Consumer | Optimises For | Key Metric |
|-----|-----------------|---------------|------------|
| UX | End users | Discoverability, forgiveness, visual clarity | Task completion time |
| DX | Developers | Typing speed, IDE support, convention familiarity | Time to first commit |
| AX | AI agents | Predictability, composability, semantic navigation | Correct-on-first-pass rate |
AX does not replace UX or DX. End users still need good UX. Developers still need good DX. But when the primary code author and maintainer is an AI agent, the codebase should be designed for that consumer first.
## Principles
### 1. Predictable Names Over Short Names
Names are tokens that agents pattern-match across languages and contexts. Abbreviations introduce mapping overhead.
```
Config not Cfg
Service not Srv
Embed not Emb
Error not Err (as a subsystem name; err for local variables is fine)
Options not Opts
```
**Rule:** If a name would require a comment to explain, it is too short.
**Exception:** Industry-standard abbreviations that are universally understood (`HTTP`, `URL`, `ID`, `IPC`, `I18n`) are acceptable. The test: would an agent trained on any mainstream language recognise it without context?
### 2. Comments as Usage Examples
The function signature tells WHAT. The comment shows HOW with real values.
```go
// Detect the project type from files present
setup.Detect("/path/to/project")
// Set up a workspace with auto-detected template
setup.Run(setup.Options{Path: ".", Template: "auto"})
// Scaffold a PHP module workspace
setup.Run(setup.Options{Path: "./my-module", Template: "php"})
```
**Rule:** If a comment restates what the type signature already says, delete it. If a comment shows a concrete usage with realistic values, keep it.
**Rationale:** Agents learn from examples more effectively than from descriptions. A comment like "Run executes the setup process" adds zero information. A comment like `setup.Run(setup.Options{Path: ".", Template: "auto"})` teaches an agent exactly how to call the function.
### 3. Path Is Documentation
File and directory paths should be self-describing. An agent navigating the filesystem should understand what it is looking at without reading a README.
```
flow/deploy/to/homelab.yaml — deploy TO the homelab
flow/deploy/from/github.yaml — deploy FROM GitHub
flow/code/review.yaml — code review flow
template/file/go/struct.go.tmpl — Go struct file template
template/dir/workspace/php/ — PHP workspace scaffold
```
**Rule:** If an agent needs to read a file to understand what a directory contains, the directory naming has failed.
**Corollary:** The unified path convention (folder structure = HTTP route = CLI command = test path) is AX-native. One path, every surface.
### 4. Templates Over Freeform
When an agent generates code from a template, the output is constrained to known-good shapes. When an agent writes freeform, the output varies.
```go
// Template-driven — consistent output
lib.RenderFile("php/action", data)
lib.ExtractDir("php", targetDir, data)
// Freeform — variance in output
"write a PHP action class that..."
```
**Rule:** For any code pattern that recurs, provide a template. Templates are guardrails for agents.
**Scope:** Templates apply to file generation, workspace scaffolding, config generation, and commit messages. They do NOT apply to novel logic — agents should write business logic freeform with the domain knowledge available.
### 5. Declarative Over Imperative
Agents reason better about declarations of intent than sequences of operations.
```yaml
# Declarative — agent sees what should happen
steps:
- name: build
flow: tools/docker-build
with:
context: "{{ .app_dir }}"
image_name: "{{ .image_name }}"
- name: deploy
flow: deploy/with/docker
with:
host: "{{ .host }}"
```
```go
// Imperative — agent must trace execution
cmd := exec.Command("docker", "build", "--platform", "linux/amd64", "-t", imageName, ".")
cmd.Dir = appDir
if err := cmd.Run(); err != nil {
return fmt.Errorf("docker build: %w", err)
}
```
**Rule:** Orchestration, configuration, and pipeline logic should be declarative (YAML/JSON). Implementation logic should be imperative (Go/PHP/TS). The boundary is: if an agent needs to compose or modify the logic, make it declarative.
### 6. Universal Types (Core Primitives)
Every component in the ecosystem accepts and returns the same primitive types. An agent processing any level of the tree sees identical shapes.
`Option` is a single key-value pair. `Options` is a collection. Any function that returns `Result` can accept `Options`.
```go
// Option — the atom
core.Option{K: "name", V: "brain"}
// Options — universal input (collection of Option)
core.Options{
{K: "name", V: "myapp"},
{K: "port", V: 8080},
}
// Result[T] — universal return
core.Result[*Embed]{Value: emb, OK: true}
```
Usage across subsystems — same shape everywhere:
```go
// Create Core
c := core.New(core.Options{{K: "name", V: "myapp"}})
// Mount embedded content
c.Data().New(core.Options{
{K: "name", V: "brain"},
{K: "source", V: brainFS},
{K: "path", V: "prompts"},
})
// Register a transport handle
c.Drive().New(core.Options{
{K: "name", V: "api"},
{K: "transport", V: "https://api.lthn.ai"},
})
// Read back what was passed in
c.Options().String("name") // "myapp"
```
**Core primitive types:**
| Type | Purpose |
|------|---------|
| `core.Option` | Single key-value pair (the atom) |
| `core.Options` | Collection of Option (universal input) |
| `core.Result[T]` | Return value with OK/fail state (universal output) |
| `core.Config` | Runtime settings (what is active) |
| `core.Data` | Embedded or stored content from packages |
| `core.Drive` | Resource handle registry (transports) |
| `core.Service` | A managed component with lifecycle |
**Core struct subsystems:**
| Accessor | Analogy | Purpose |
|----------|---------|---------|
| `c.Options()` | argv | Input configuration used to create this Core |
| `c.Data()` | /mnt | Embedded assets mounted by packages |
| `c.Drive()` | /dev | Transport handles (API, MCP, SSH, VPN) |
| `c.Config()` | /etc | Configuration, settings, feature flags |
| `c.Fs()` | / | Local filesystem I/O (sandboxable) |
| `c.Error()` | — | Panic recovery and crash reporting (`ErrorPanic`) |
| `c.Log()` | — | Structured logging (`ErrorLog`) |
| `c.Service()` | — | Service registry and lifecycle |
| `c.Cli()` | — | CLI command framework |
| `c.IPC()` | — | Message bus |
| `c.I18n()` | — | Internationalisation |
**What this replaces:**
| Go Convention | Core AX | Why |
|--------------|---------|-----|
| `func With*(v) Option` | `core.Options{{K: k, V: v}}` | K/V pairs are parseable; option chains require tracing |
| `func Must*(v) T` | `core.Result[T]` | No hidden panics; errors flow through Core |
| `func *For[T](c) T` | `c.Service("name")` | String lookup is greppable; generics require type context |
| `val, err :=` everywhere | Single return via `core.Result` | Intent not obscured by error handling |
| `_ = err` | Never needed | Core handles all errors internally |
| `ErrPan` / `ErrLog` | `ErrorPanic` / `ErrorLog` | Full names — AX principle 1 |
## Applying AX to Existing Patterns
### File Structure
```
# AX-native: path describes content
core/agent/
├── go/ # Go source
├── php/ # PHP source
├── ui/ # Frontend source
├── claude/ # Claude Code plugin
└── codex/ # Codex plugin
# Not AX: generic names requiring README
src/
├── lib/
├── utils/
└── helpers/
```
### Error Handling
```go
// AX-native: errors are infrastructure, not application logic
svc := c.Service("brain")
cfg := c.Config().Get("database.host")
// Errors logged by Core. Code reads like a spec.
// Not AX: errors dominate the code
svc, err := c.ServiceFor[brain.Service]()
if err != nil {
return fmt.Errorf("get brain service: %w", err)
}
cfg, err := c.Config().Get("database.host")
if err != nil {
_ = err // silenced because "it'll be fine"
}
```
### API Design
```go
// AX-native: one shape, every surface
c := core.New(core.Options{
{K: "name", V: "my-app"},
})
c.Service("process", processSvc)
c.Data().New(core.Options{{K: "name", V: "app"}, {K: "source", V: appFS}})
// Not AX: multiple patterns for the same thing
c, err := core.New(
core.WithName("my-app"),
core.WithService(factory1),
core.WithAssets(appFS),
)
if err != nil { ... }
```
## Compatibility
AX conventions are valid, idiomatic Go/PHP/TS. They do not require language extensions, code generation, or non-standard tooling. An AX-designed codebase compiles, tests, and deploys with standard toolchains.
The conventions diverge from community patterns (functional options, Must/For, etc.) but do not violate language specifications. This is a style choice, not a fork.
## Adoption
AX applies to all new code in the Core ecosystem. Existing code migrates incrementally as it is touched — no big-bang rewrite.
Priority order:
1. **Public APIs** (package-level functions, struct constructors)
2. **File structure** (path naming, template locations)
3. **Internal fields** (struct field names, local variables)
## References
- dAppServer unified path convention (2024)
- CoreGO DTO pattern refactor (2026-03-18)
- Core primitives design (2026-03-19)
- Go Proverbs, Rob Pike (2015) — AX provides an updated lens
## Changelog
- 2026-03-20: Updated to match implementation — Option K/V atoms, Options as []Option, Data/Drive split, ErrorPanic/ErrorLog renames, subsystem table
- 2026-03-19: Initial draft

View file

@ -0,0 +1,53 @@
// SPDX-License-Identifier: EUPL-1.2
// Application identity for the Core framework.
// Based on leaanthony/sail — Name, Filename, Path.
package core
import (
"os/exec"
"path/filepath"
)
// App holds the application identity and optional GUI runtime.
type App struct {
// Name is the human-readable application name (e.g., "Core CLI").
Name string
// Version is the application version string (e.g., "1.2.3").
Version string
// Description is a short description of the application.
Description string
// Filename is the executable filename (e.g., "core").
Filename string
// Path is the absolute path to the executable.
Path string
// Runtime is the GUI runtime (e.g., Wails App).
// Nil for CLI-only applications.
Runtime any
}
// Find locates a program on PATH and returns a Result containing the App.
//
// r := core.Find("node", "Node.js")
// if r.OK { app := r.Value.(*App) }
func Find(filename, name string) Result {
path, err := exec.LookPath(filename)
if err != nil {
return Result{err, false}
}
abs, err := filepath.Abs(path)
if err != nil {
return Result{err, false}
}
return Result{&App{
Name: name,
Filename: filename,
Path: abs,
}, true}
}

View file

@ -0,0 +1,101 @@
// SPDX-License-Identifier: EUPL-1.2
// Generic slice operations for the Core framework.
// Based on leaanthony/slicer, rewritten with Go 1.18+ generics.
package core
// Array is a typed slice with common operations.
type Array[T comparable] struct {
items []T
}
// NewArray creates an empty Array.
func NewArray[T comparable](items ...T) *Array[T] {
return &Array[T]{items: items}
}
// Add appends values.
func (s *Array[T]) Add(values ...T) {
s.items = append(s.items, values...)
}
// AddUnique appends values only if not already present.
func (s *Array[T]) AddUnique(values ...T) {
for _, v := range values {
if !s.Contains(v) {
s.items = append(s.items, v)
}
}
}
// Contains returns true if the value is in the slice.
func (s *Array[T]) Contains(val T) bool {
for _, v := range s.items {
if v == val {
return true
}
}
return false
}
// Filter returns a new Array with elements matching the predicate.
func (s *Array[T]) Filter(fn func(T) bool) Result {
filtered := &Array[T]{}
for _, v := range s.items {
if fn(v) {
filtered.items = append(filtered.items, v)
}
}
return Result{filtered, true}
}
// Each runs a function on every element.
func (s *Array[T]) Each(fn func(T)) {
for _, v := range s.items {
fn(v)
}
}
// Remove removes the first occurrence of a value.
func (s *Array[T]) Remove(val T) {
for i, v := range s.items {
if v == val {
s.items = append(s.items[:i], s.items[i+1:]...)
return
}
}
}
// Deduplicate removes duplicate values, preserving order.
func (s *Array[T]) Deduplicate() {
seen := make(map[T]struct{})
result := make([]T, 0, len(s.items))
for _, v := range s.items {
if _, exists := seen[v]; !exists {
seen[v] = struct{}{}
result = append(result, v)
}
}
s.items = result
}
// Len returns the number of elements.
func (s *Array[T]) Len() int {
return len(s.items)
}
// Clear removes all elements.
func (s *Array[T]) Clear() {
s.items = nil
}
// AsSlice returns a copy of the underlying slice.
func (s *Array[T]) AsSlice() []T {
if s.items == nil {
return nil
}
out := make([]T, len(s.items))
copy(out, s.items)
return out
}

View file

@ -0,0 +1,169 @@
// SPDX-License-Identifier: EUPL-1.2
// Cli is the CLI surface layer for the Core command tree.
// It reads commands from Core's registry and wires them to terminal I/O.
//
// Run the CLI:
//
// c := core.New(core.Options{{Key: "name", Value: "myapp"}})
// c.Command("deploy", handler)
// c.Cli().Run()
//
// The Cli resolves os.Args to a command path, parses flags,
// and calls the command's action with parsed options.
package core
import (
"io"
"os"
)
// Cli is the CLI surface for the Core command tree.
type Cli struct {
core *Core
output io.Writer
banner func(*Cli) string
}
// Print writes to the CLI output (defaults to os.Stdout).
//
// c.Cli().Print("hello %s", "world")
func (cl *Cli) Print(format string, args ...any) {
Print(cl.output, format, args...)
}
// SetOutput sets the CLI output writer.
//
// c.Cli().SetOutput(os.Stderr)
func (cl *Cli) SetOutput(w io.Writer) {
cl.output = w
}
// Run resolves os.Args to a command path and executes it.
//
// c.Cli().Run()
// c.Cli().Run("deploy", "to", "homelab")
func (cl *Cli) Run(args ...string) Result {
if len(args) == 0 {
args = os.Args[1:]
}
clean := FilterArgs(args)
if cl.core == nil || cl.core.commands == nil {
if cl.banner != nil {
cl.Print(cl.banner(cl))
}
return Result{}
}
cl.core.commands.mu.RLock()
cmdCount := len(cl.core.commands.commands)
cl.core.commands.mu.RUnlock()
if cmdCount == 0 {
if cl.banner != nil {
cl.Print(cl.banner(cl))
}
return Result{}
}
// Resolve command path from args
var cmd *Command
var remaining []string
cl.core.commands.mu.RLock()
for i := len(clean); i > 0; i-- {
path := JoinPath(clean[:i]...)
if c, ok := cl.core.commands.commands[path]; ok {
cmd = c
remaining = clean[i:]
break
}
}
cl.core.commands.mu.RUnlock()
if cmd == nil {
if cl.banner != nil {
cl.Print(cl.banner(cl))
}
cl.PrintHelp()
return Result{}
}
// Build options from remaining args
opts := Options{}
for _, arg := range remaining {
key, val, valid := ParseFlag(arg)
if valid {
if Contains(arg, "=") {
opts = append(opts, Option{Key: key, Value: val})
} else {
opts = append(opts, Option{Key: key, Value: true})
}
} else if !IsFlag(arg) {
opts = append(opts, Option{Key: "_arg", Value: arg})
}
}
if cmd.Action != nil {
return cmd.Run(opts)
}
if cmd.Lifecycle != nil {
return cmd.Start(opts)
}
return Result{E("core.Cli.Run", Concat("command \"", cmd.Path, "\" is not executable"), nil), false}
}
// PrintHelp prints available commands.
//
// c.Cli().PrintHelp()
func (cl *Cli) PrintHelp() {
if cl.core == nil || cl.core.commands == nil {
return
}
name := ""
if cl.core.app != nil {
name = cl.core.app.Name
}
if name != "" {
cl.Print("%s commands:", name)
} else {
cl.Print("Commands:")
}
cl.core.commands.mu.RLock()
defer cl.core.commands.mu.RUnlock()
for path, cmd := range cl.core.commands.commands {
if cmd.Hidden || (cmd.Action == nil && cmd.Lifecycle == nil) {
continue
}
tr := cl.core.I18n().Translate(cmd.I18nKey())
desc, _ := tr.Value.(string)
if desc == "" || desc == cmd.I18nKey() {
cl.Print(" %s", path)
} else {
cl.Print(" %-30s %s", path, desc)
}
}
}
// SetBanner sets the banner function.
//
// c.Cli().SetBanner(func(_ *core.Cli) string { return "My App v1.0" })
func (cl *Cli) SetBanner(fn func(*Cli) string) {
cl.banner = fn
}
// Banner returns the banner string.
func (cl *Cli) Banner() string {
if cl.banner != nil {
return cl.banner(cl)
}
if cl.core != nil && cl.core.app != nil && cl.core.app.Name != "" {
return cl.core.app.Name
}
return ""
}

View file

@ -0,0 +1,208 @@
// SPDX-License-Identifier: EUPL-1.2
// Command is a DTO representing an executable operation.
// Commands don't know if they're root, child, or nested — the tree
// structure comes from composition via path-based registration.
//
// Register a command:
//
// c.Command("deploy", func(opts core.Options) core.Result {
// return core.Result{"deployed", true}
// })
//
// Register a nested command:
//
// c.Command("deploy/to/homelab", handler)
//
// Description is an i18n key — derived from path if omitted:
//
// "deploy" → "cmd.deploy.description"
// "deploy/to/homelab" → "cmd.deploy.to.homelab.description"
package core
import (
"sync"
)
// CommandAction is the function signature for command handlers.
//
// func(opts core.Options) core.Result
type CommandAction func(Options) Result
// CommandLifecycle is implemented by commands that support managed lifecycle.
// Basic commands only need an action. Daemon commands implement Start/Stop/Signal
// via go-process.
type CommandLifecycle interface {
Start(Options) Result
Stop() Result
Restart() Result
Reload() Result
Signal(string) Result
}
// Command is the DTO for an executable operation.
type Command struct {
Name string
Description string // i18n key — derived from path if empty
Path string // "deploy/to/homelab"
Action CommandAction // business logic
Lifecycle CommandLifecycle // optional — provided by go-process
Flags Options // declared flags
Hidden bool
commands map[string]*Command // child commands (internal)
mu sync.RWMutex
}
// I18nKey returns the i18n key for this command's description.
//
// cmd with path "deploy/to/homelab" → "cmd.deploy.to.homelab.description"
func (cmd *Command) I18nKey() string {
if cmd.Description != "" {
return cmd.Description
}
path := cmd.Path
if path == "" {
path = cmd.Name
}
return Concat("cmd.", Replace(path, "/", "."), ".description")
}
// Run executes the command's action with the given options.
//
// result := cmd.Run(core.Options{{Key: "target", Value: "homelab"}})
func (cmd *Command) Run(opts Options) Result {
if cmd.Action == nil {
return Result{E("core.Command.Run", Concat("command \"", cmd.Path, "\" is not executable"), nil), false}
}
return cmd.Action(opts)
}
// Start delegates to the lifecycle implementation if available.
func (cmd *Command) Start(opts Options) Result {
if cmd.Lifecycle != nil {
return cmd.Lifecycle.Start(opts)
}
return cmd.Run(opts)
}
// Stop delegates to the lifecycle implementation.
func (cmd *Command) Stop() Result {
if cmd.Lifecycle != nil {
return cmd.Lifecycle.Stop()
}
return Result{}
}
// Restart delegates to the lifecycle implementation.
func (cmd *Command) Restart() Result {
if cmd.Lifecycle != nil {
return cmd.Lifecycle.Restart()
}
return Result{}
}
// Reload delegates to the lifecycle implementation.
func (cmd *Command) Reload() Result {
if cmd.Lifecycle != nil {
return cmd.Lifecycle.Reload()
}
return Result{}
}
// Signal delegates to the lifecycle implementation.
func (cmd *Command) Signal(sig string) Result {
if cmd.Lifecycle != nil {
return cmd.Lifecycle.Signal(sig)
}
return Result{}
}
// --- Command Registry (on Core) ---
// commandRegistry holds the command tree.
type commandRegistry struct {
commands map[string]*Command
mu sync.RWMutex
}
// Command gets or registers a command by path.
//
// c.Command("deploy", Command{Action: handler})
// r := c.Command("deploy")
func (c *Core) Command(path string, command ...Command) Result {
if len(command) == 0 {
c.commands.mu.RLock()
cmd, ok := c.commands.commands[path]
c.commands.mu.RUnlock()
return Result{cmd, ok}
}
if path == "" || HasPrefix(path, "/") || HasSuffix(path, "/") || Contains(path, "//") {
return Result{E("core.Command", Concat("invalid command path: \"", path, "\""), nil), false}
}
c.commands.mu.Lock()
defer c.commands.mu.Unlock()
if existing, exists := c.commands.commands[path]; exists && (existing.Action != nil || existing.Lifecycle != nil) {
return Result{E("core.Command", Concat("command \"", path, "\" already registered"), nil), false}
}
cmd := &command[0]
cmd.Name = pathName(path)
cmd.Path = path
if cmd.commands == nil {
cmd.commands = make(map[string]*Command)
}
// Preserve existing subtree when overwriting a placeholder parent
if existing, exists := c.commands.commands[path]; exists {
for k, v := range existing.commands {
if _, has := cmd.commands[k]; !has {
cmd.commands[k] = v
}
}
}
c.commands.commands[path] = cmd
// Build parent chain — "deploy/to/homelab" creates "deploy" and "deploy/to" if missing
parts := Split(path, "/")
for i := len(parts) - 1; i > 0; i-- {
parentPath := JoinPath(parts[:i]...)
if _, exists := c.commands.commands[parentPath]; !exists {
c.commands.commands[parentPath] = &Command{
Name: parts[i-1],
Path: parentPath,
commands: make(map[string]*Command),
}
}
c.commands.commands[parentPath].commands[parts[i]] = cmd
cmd = c.commands.commands[parentPath]
}
return Result{OK: true}
}
// Commands returns all registered command paths.
//
// paths := c.Commands()
func (c *Core) Commands() []string {
if c.commands == nil {
return nil
}
c.commands.mu.RLock()
defer c.commands.mu.RUnlock()
var paths []string
for k := range c.commands.commands {
paths = append(paths, k)
}
return paths
}
// pathName extracts the last segment of a path.
// "deploy/to/homelab" → "homelab"
func pathName(path string) string {
parts := Split(path, "/")
return parts[len(parts)-1]
}

View file

@ -0,0 +1,135 @@
// SPDX-License-Identifier: EUPL-1.2
// Settings, feature flags, and typed configuration for the Core framework.
package core
import (
"sync"
)
// ConfigVar is a variable that can be set, unset, and queried for its state.
type ConfigVar[T any] struct {
val T
set bool
}
func (v *ConfigVar[T]) Get() T { return v.val }
func (v *ConfigVar[T]) Set(val T) { v.val = val; v.set = true }
func (v *ConfigVar[T]) IsSet() bool { return v.set }
func (v *ConfigVar[T]) Unset() {
v.set = false
var zero T
v.val = zero
}
func NewConfigVar[T any](val T) ConfigVar[T] {
return ConfigVar[T]{val: val, set: true}
}
// ConfigOptions holds configuration data.
type ConfigOptions struct {
Settings map[string]any
Features map[string]bool
}
func (o *ConfigOptions) init() {
if o.Settings == nil {
o.Settings = make(map[string]any)
}
if o.Features == nil {
o.Features = make(map[string]bool)
}
}
// Config holds configuration settings and feature flags.
type Config struct {
*ConfigOptions
mu sync.RWMutex
}
// Set stores a configuration value by key.
func (e *Config) Set(key string, val any) {
e.mu.Lock()
if e.ConfigOptions == nil {
e.ConfigOptions = &ConfigOptions{}
}
e.ConfigOptions.init()
e.Settings[key] = val
e.mu.Unlock()
}
// Get retrieves a configuration value by key.
func (e *Config) Get(key string) Result {
e.mu.RLock()
defer e.mu.RUnlock()
if e.ConfigOptions == nil || e.Settings == nil {
return Result{}
}
val, ok := e.Settings[key]
if !ok {
return Result{}
}
return Result{val, true}
}
func (e *Config) String(key string) string { return ConfigGet[string](e, key) }
func (e *Config) Int(key string) int { return ConfigGet[int](e, key) }
func (e *Config) Bool(key string) bool { return ConfigGet[bool](e, key) }
// ConfigGet retrieves a typed configuration value.
func ConfigGet[T any](e *Config, key string) T {
r := e.Get(key)
if !r.OK {
var zero T
return zero
}
typed, _ := r.Value.(T)
return typed
}
// --- Feature Flags ---
func (e *Config) Enable(feature string) {
e.mu.Lock()
if e.ConfigOptions == nil {
e.ConfigOptions = &ConfigOptions{}
}
e.ConfigOptions.init()
e.Features[feature] = true
e.mu.Unlock()
}
func (e *Config) Disable(feature string) {
e.mu.Lock()
if e.ConfigOptions == nil {
e.ConfigOptions = &ConfigOptions{}
}
e.ConfigOptions.init()
e.Features[feature] = false
e.mu.Unlock()
}
func (e *Config) Enabled(feature string) bool {
e.mu.RLock()
defer e.mu.RUnlock()
if e.ConfigOptions == nil || e.Features == nil {
return false
}
return e.Features[feature]
}
func (e *Config) EnabledFeatures() []string {
e.mu.RLock()
defer e.mu.RUnlock()
if e.ConfigOptions == nil || e.Features == nil {
return nil
}
var result []string
for k, v := range e.Features {
if v {
result = append(result, k)
}
}
return result
}

View file

@ -0,0 +1,105 @@
// SPDX-License-Identifier: EUPL-1.2
// Contracts, options, and type definitions for the Core framework.
package core
import (
"context"
)
// Message is the type for IPC broadcasts (fire-and-forget).
type Message any
// Query is the type for read-only IPC requests.
type Query any
// Task is the type for IPC requests that perform side effects.
type Task any
// TaskWithIdentifier is an optional interface for tasks that need to know their assigned identifier.
type TaskWithIdentifier interface {
Task
SetTaskIdentifier(id string)
GetTaskIdentifier() string
}
// QueryHandler handles Query requests. Returns Result{Value, OK}.
type QueryHandler func(*Core, Query) Result
// TaskHandler handles Task requests. Returns Result{Value, OK}.
type TaskHandler func(*Core, Task) Result
// Startable is implemented by services that need startup initialisation.
type Startable interface {
OnStartup(ctx context.Context) error
}
// Stoppable is implemented by services that need shutdown cleanup.
type Stoppable interface {
OnShutdown(ctx context.Context) error
}
// --- Action Messages ---
type ActionServiceStartup struct{}
type ActionServiceShutdown struct{}
type ActionTaskStarted struct {
TaskIdentifier string
Task Task
}
type ActionTaskProgress struct {
TaskIdentifier string
Task Task
Progress float64
Message string
}
type ActionTaskCompleted struct {
TaskIdentifier string
Task Task
Result any
Error error
}
// --- Constructor ---
// New creates a Core instance.
//
// c := core.New(core.Options{
// {Key: "name", Value: "myapp"},
// })
func New(opts ...Options) *Core {
c := &Core{
app: &App{},
data: &Data{},
drive: &Drive{},
fs: &Fs{root: "/"},
config: &Config{ConfigOptions: &ConfigOptions{}},
error: &ErrorPanic{},
log: &ErrorLog{log: Default()},
lock: &Lock{},
ipc: &Ipc{},
i18n: &I18n{},
services: &serviceRegistry{services: make(map[string]*Service)},
commands: &commandRegistry{commands: make(map[string]*Command)},
}
c.context, c.cancel = context.WithCancel(context.Background())
if len(opts) > 0 {
cp := make(Options, len(opts[0]))
copy(cp, opts[0])
c.options = &cp
name := cp.String("name")
if name != "" {
c.app.Name = name
}
}
// Init Cli surface with Core reference
c.cli = &Cli{core: c}
return c
}

View file

@ -0,0 +1,81 @@
// SPDX-License-Identifier: EUPL-1.2
// Package core is a dependency injection and service lifecycle framework for Go.
// This file defines the Core struct, accessors, and IPC/error wrappers.
package core
import (
"context"
"sync"
"sync/atomic"
)
// --- Core Struct ---
// Core is the central application object that manages services, assets, and communication.
type Core struct {
options *Options // c.Options() — Input configuration used to create this Core
app *App // c.App() — Application identity + optional GUI runtime
data *Data // c.Data() — Embedded/stored content from packages
drive *Drive // c.Drive() — Resource handle registry (transports)
fs *Fs // c.Fs() — Local filesystem I/O (sandboxable)
config *Config // c.Config() — Configuration, settings, feature flags
error *ErrorPanic // c.Error() — Panic recovery and crash reporting
log *ErrorLog // c.Log() — Structured logging + error wrapping
cli *Cli // c.Cli() — CLI surface layer
commands *commandRegistry // c.Command("path") — Command tree
services *serviceRegistry // c.Service("name") — Service registry
lock *Lock // c.Lock("name") — Named mutexes
ipc *Ipc // c.IPC() — Message bus for IPC
i18n *I18n // c.I18n() — Internationalisation and locale collection
context context.Context
cancel context.CancelFunc
taskIDCounter atomic.Uint64
waitGroup sync.WaitGroup
shutdown atomic.Bool
}
// --- Accessors ---
func (c *Core) Options() *Options { return c.options }
func (c *Core) App() *App { return c.app }
func (c *Core) Data() *Data { return c.data }
func (c *Core) Drive() *Drive { return c.drive }
func (c *Core) Embed() Result { return c.data.Get("app") } // legacy — use Data()
func (c *Core) Fs() *Fs { return c.fs }
func (c *Core) Config() *Config { return c.config }
func (c *Core) Error() *ErrorPanic { return c.error }
func (c *Core) Log() *ErrorLog { return c.log }
func (c *Core) Cli() *Cli { return c.cli }
func (c *Core) IPC() *Ipc { return c.ipc }
func (c *Core) I18n() *I18n { return c.i18n }
func (c *Core) Context() context.Context { return c.context }
func (c *Core) Core() *Core { return c }
// --- IPC (uppercase aliases) ---
func (c *Core) ACTION(msg Message) Result { return c.Action(msg) }
func (c *Core) QUERY(q Query) Result { return c.Query(q) }
func (c *Core) QUERYALL(q Query) Result { return c.QueryAll(q) }
func (c *Core) PERFORM(t Task) Result { return c.Perform(t) }
// --- Error+Log ---
// LogError logs an error and returns the Result from ErrorLog.
func (c *Core) LogError(err error, op, msg string) Result {
return c.log.Error(err, op, msg)
}
// LogWarn logs a warning and returns the Result from ErrorLog.
func (c *Core) LogWarn(err error, op, msg string) Result {
return c.log.Warn(err, op, msg)
}
// Must logs and panics if err is not nil.
func (c *Core) Must(err error, op, msg string) {
c.log.Must(err, op, msg)
}
// --- Global Instance ---

View file

@ -0,0 +1,202 @@
// SPDX-License-Identifier: EUPL-1.2
// Data is the embedded/stored content system for core packages.
// Packages mount their embedded content here and other packages
// read from it by path.
//
// Mount a package's assets:
//
// c.Data().New(core.Options{
// {Key: "name", Value: "brain"},
// {Key: "source", Value: brainFS},
// {Key: "path", Value: "prompts"},
// })
//
// Read from any mounted path:
//
// content := c.Data().ReadString("brain/coding.md")
// entries := c.Data().List("agent/flow")
//
// Extract a template directory:
//
// c.Data().Extract("agent/workspace/default", "/tmp/ws", data)
package core
import (
"io/fs"
"path/filepath"
"sync"
)
// Data manages mounted embedded filesystems from core packages.
type Data struct {
mounts map[string]*Embed
mu sync.RWMutex
}
// New registers an embedded filesystem under a named prefix.
//
// c.Data().New(core.Options{
// {Key: "name", Value: "brain"},
// {Key: "source", Value: brainFS},
// {Key: "path", Value: "prompts"},
// })
func (d *Data) New(opts Options) Result {
name := opts.String("name")
if name == "" {
return Result{}
}
r := opts.Get("source")
if !r.OK {
return r
}
fsys, ok := r.Value.(fs.FS)
if !ok {
return Result{E("data.New", "source is not fs.FS", nil), false}
}
path := opts.String("path")
if path == "" {
path = "."
}
d.mu.Lock()
defer d.mu.Unlock()
if d.mounts == nil {
d.mounts = make(map[string]*Embed)
}
mr := Mount(fsys, path)
if !mr.OK {
return mr
}
emb := mr.Value.(*Embed)
d.mounts[name] = emb
return Result{emb, true}
}
// Get returns the Embed for a named mount point.
//
// r := c.Data().Get("brain")
// if r.OK { emb := r.Value.(*Embed) }
func (d *Data) Get(name string) Result {
d.mu.RLock()
defer d.mu.RUnlock()
if d.mounts == nil {
return Result{}
}
emb, ok := d.mounts[name]
if !ok {
return Result{}
}
return Result{emb, true}
}
// resolve splits a path like "brain/coding.md" into mount name + relative path.
func (d *Data) resolve(path string) (*Embed, string) {
d.mu.RLock()
defer d.mu.RUnlock()
parts := SplitN(path, "/", 2)
if len(parts) < 2 {
return nil, ""
}
if d.mounts == nil {
return nil, ""
}
emb := d.mounts[parts[0]]
return emb, parts[1]
}
// ReadFile reads a file by full path.
//
// r := c.Data().ReadFile("brain/prompts/coding.md")
// if r.OK { data := r.Value.([]byte) }
func (d *Data) ReadFile(path string) Result {
emb, rel := d.resolve(path)
if emb == nil {
return Result{}
}
return emb.ReadFile(rel)
}
// ReadString reads a file as a string.
//
// r := c.Data().ReadString("agent/flow/deploy/to/homelab.yaml")
// if r.OK { content := r.Value.(string) }
func (d *Data) ReadString(path string) Result {
r := d.ReadFile(path)
if !r.OK {
return r
}
return Result{string(r.Value.([]byte)), true}
}
// List returns directory entries at a path.
//
// r := c.Data().List("agent/persona/code")
// if r.OK { entries := r.Value.([]fs.DirEntry) }
func (d *Data) List(path string) Result {
emb, rel := d.resolve(path)
if emb == nil {
return Result{}
}
r := emb.ReadDir(rel)
if !r.OK {
return r
}
return Result{r.Value, true}
}
// ListNames returns filenames (without extensions) at a path.
//
// r := c.Data().ListNames("agent/flow")
// if r.OK { names := r.Value.([]string) }
func (d *Data) ListNames(path string) Result {
r := d.List(path)
if !r.OK {
return r
}
entries := r.Value.([]fs.DirEntry)
var names []string
for _, e := range entries {
name := e.Name()
if !e.IsDir() {
name = TrimSuffix(name, filepath.Ext(name))
}
names = append(names, name)
}
return Result{names, true}
}
// Extract copies a template directory to targetDir.
//
// r := c.Data().Extract("agent/workspace/default", "/tmp/ws", templateData)
func (d *Data) Extract(path, targetDir string, templateData any) Result {
emb, rel := d.resolve(path)
if emb == nil {
return Result{}
}
r := emb.Sub(rel)
if !r.OK {
return r
}
return Extract(r.Value.(*Embed).FS(), targetDir, templateData)
}
// Mounts returns the names of all mounted content.
//
// names := c.Data().Mounts()
func (d *Data) Mounts() []string {
d.mu.RLock()
defer d.mu.RUnlock()
var names []string
for k := range d.mounts {
names = append(names, k)
}
return names
}

View file

@ -0,0 +1,112 @@
// SPDX-License-Identifier: EUPL-1.2
// Drive is the resource handle registry for transport connections.
// Packages register their transport handles (API, MCP, SSH, VPN)
// and other packages access them by name.
//
// Register a transport:
//
// c.Drive().New(core.Options{
// {Key: "name", Value: "api"},
// {Key: "transport", Value: "https://api.lthn.ai"},
// })
// c.Drive().New(core.Options{
// {Key: "name", Value: "ssh"},
// {Key: "transport", Value: "ssh://claude@10.69.69.165"},
// })
// c.Drive().New(core.Options{
// {Key: "name", Value: "mcp"},
// {Key: "transport", Value: "mcp://mcp.lthn.sh"},
// })
//
// Retrieve a handle:
//
// api := c.Drive().Get("api")
package core
import (
"sync"
)
// DriveHandle holds a named transport resource.
type DriveHandle struct {
Name string
Transport string
Options Options
}
// Drive manages named transport handles.
type Drive struct {
handles map[string]*DriveHandle
mu sync.RWMutex
}
// New registers a transport handle.
//
// c.Drive().New(core.Options{
// {Key: "name", Value: "api"},
// {Key: "transport", Value: "https://api.lthn.ai"},
// })
func (d *Drive) New(opts Options) Result {
name := opts.String("name")
if name == "" {
return Result{}
}
transport := opts.String("transport")
d.mu.Lock()
defer d.mu.Unlock()
if d.handles == nil {
d.handles = make(map[string]*DriveHandle)
}
cp := make(Options, len(opts))
copy(cp, opts)
handle := &DriveHandle{
Name: name,
Transport: transport,
Options: cp,
}
d.handles[name] = handle
return Result{handle, true}
}
// Get returns a handle by name.
//
// r := c.Drive().Get("api")
// if r.OK { handle := r.Value.(*DriveHandle) }
func (d *Drive) Get(name string) Result {
d.mu.RLock()
defer d.mu.RUnlock()
if d.handles == nil {
return Result{}
}
h, ok := d.handles[name]
if !ok {
return Result{}
}
return Result{h, true}
}
// Has returns true if a handle is registered.
//
// if c.Drive().Has("ssh") { ... }
func (d *Drive) Has(name string) bool {
return d.Get(name).OK
}
// Names returns all registered handle names.
//
// names := c.Drive().Names()
func (d *Drive) Names() []string {
d.mu.RLock()
defer d.mu.RUnlock()
var names []string
for k := range d.handles {
names = append(names, k)
}
return names
}

View file

@ -0,0 +1,668 @@
// SPDX-License-Identifier: EUPL-1.2
// Embedded assets for the Core framework.
//
// Embed provides scoped filesystem access for go:embed and any fs.FS.
// Also includes build-time asset packing (AST scanner + compressor)
// and template-based directory extraction.
//
// Usage (mount):
//
// sub, _ := core.Mount(myFS, "lib/persona")
// content, _ := sub.ReadString("secops/developer.md")
//
// Usage (extract):
//
// core.Extract(fsys, "/tmp/workspace", data)
//
// Usage (pack):
//
// refs, _ := core.ScanAssets([]string{"main.go"})
// source, _ := core.GeneratePack(refs)
package core
import (
"bytes"
"compress/gzip"
"embed"
"encoding/base64"
"fmt"
"go/ast"
"go/parser"
"go/token"
"io"
"io/fs"
"os"
"path/filepath"
"sync"
"text/template"
)
// --- Runtime: Asset Registry ---
// AssetGroup holds a named collection of packed assets.
type AssetGroup struct {
assets map[string]string // name → compressed data
}
var (
assetGroups = make(map[string]*AssetGroup)
assetGroupsMu sync.RWMutex
)
// AddAsset registers a packed asset at runtime (called from generated init()).
func AddAsset(group, name, data string) {
assetGroupsMu.Lock()
defer assetGroupsMu.Unlock()
g, ok := assetGroups[group]
if !ok {
g = &AssetGroup{assets: make(map[string]string)}
assetGroups[group] = g
}
g.assets[name] = data
}
// GetAsset retrieves and decompresses a packed asset.
//
// r := core.GetAsset("mygroup", "greeting")
// if r.OK { content := r.Value.(string) }
func GetAsset(group, name string) Result {
assetGroupsMu.RLock()
g, ok := assetGroups[group]
if !ok {
assetGroupsMu.RUnlock()
return Result{}
}
data, ok := g.assets[name]
assetGroupsMu.RUnlock()
if !ok {
return Result{}
}
s, err := decompress(data)
if err != nil {
return Result{err, false}
}
return Result{s, true}
}
// GetAssetBytes retrieves a packed asset as bytes.
//
// r := core.GetAssetBytes("mygroup", "file")
// if r.OK { data := r.Value.([]byte) }
func GetAssetBytes(group, name string) Result {
r := GetAsset(group, name)
if !r.OK {
return r
}
return Result{[]byte(r.Value.(string)), true}
}
// --- Build-time: AST Scanner ---
// AssetRef is a reference to an asset found in source code.
type AssetRef struct {
Name string
Path string
Group string
FullPath string
}
// ScannedPackage holds all asset references from a set of source files.
type ScannedPackage struct {
PackageName string
BaseDirectory string
Groups []string
Assets []AssetRef
}
// ScanAssets parses Go source files and finds asset references.
// Looks for calls to: core.GetAsset("group", "name"), core.AddAsset, etc.
func ScanAssets(filenames []string) Result {
packageMap := make(map[string]*ScannedPackage)
var scanErr error
for _, filename := range filenames {
fset := token.NewFileSet()
node, err := parser.ParseFile(fset, filename, nil, parser.AllErrors)
if err != nil {
return Result{err, false}
}
baseDir := filepath.Dir(filename)
pkg, ok := packageMap[baseDir]
if !ok {
pkg = &ScannedPackage{BaseDirectory: baseDir}
packageMap[baseDir] = pkg
}
pkg.PackageName = node.Name.Name
ast.Inspect(node, func(n ast.Node) bool {
if scanErr != nil {
return false
}
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok {
return true
}
ident, ok := sel.X.(*ast.Ident)
if !ok {
return true
}
// Look for core.GetAsset or mewn.String patterns
if ident.Name == "core" || ident.Name == "mewn" {
switch sel.Sel.Name {
case "GetAsset", "GetAssetBytes", "String", "MustString", "Bytes", "MustBytes":
if len(call.Args) >= 1 {
if lit, ok := call.Args[len(call.Args)-1].(*ast.BasicLit); ok {
path := TrimPrefix(TrimSuffix(lit.Value, "\""), "\"")
group := "."
if len(call.Args) >= 2 {
if glit, ok := call.Args[0].(*ast.BasicLit); ok {
group = TrimPrefix(TrimSuffix(glit.Value, "\""), "\"")
}
}
fullPath, err := filepath.Abs(filepath.Join(baseDir, group, path))
if err != nil {
scanErr = Wrap(err, "core.ScanAssets", Join(" ", "could not determine absolute path for asset", path, "in group", group))
return false
}
pkg.Assets = append(pkg.Assets, AssetRef{
Name: path,
Group: group,
FullPath: fullPath,
})
}
}
case "Group":
// Variable assignment: g := core.Group("./assets")
if len(call.Args) == 1 {
if lit, ok := call.Args[0].(*ast.BasicLit); ok {
path := TrimPrefix(TrimSuffix(lit.Value, "\""), "\"")
fullPath, err := filepath.Abs(filepath.Join(baseDir, path))
if err != nil {
scanErr = Wrap(err, "core.ScanAssets", Join(" ", "could not determine absolute path for group", path))
return false
}
pkg.Groups = append(pkg.Groups, fullPath)
// Track for variable resolution
}
}
}
}
return true
})
if scanErr != nil {
return Result{scanErr, false}
}
}
var result []ScannedPackage
for _, pkg := range packageMap {
result = append(result, *pkg)
}
return Result{result, true}
}
// GeneratePack creates Go source code that embeds the scanned assets.
func GeneratePack(pkg ScannedPackage) Result {
b := NewBuilder()
b.WriteString(fmt.Sprintf("package %s\n\n", pkg.PackageName))
b.WriteString("// Code generated by core pack. DO NOT EDIT.\n\n")
if len(pkg.Assets) == 0 && len(pkg.Groups) == 0 {
return Result{b.String(), true}
}
b.WriteString("import \"dappco.re/go/core\"\n\n")
b.WriteString("func init() {\n")
// Pack groups (entire directories)
packed := make(map[string]bool)
for _, groupPath := range pkg.Groups {
files, err := getAllFiles(groupPath)
if err != nil {
return Result{err, false}
}
for _, file := range files {
if packed[file] {
continue
}
data, err := compressFile(file)
if err != nil {
return Result{err, false}
}
localPath := TrimPrefix(file, groupPath+"/")
relGroup, err := filepath.Rel(pkg.BaseDirectory, groupPath)
if err != nil {
return Result{err, false}
}
b.WriteString(fmt.Sprintf("\tcore.AddAsset(%q, %q, %q)\n", relGroup, localPath, data))
packed[file] = true
}
}
// Pack individual assets
for _, asset := range pkg.Assets {
if packed[asset.FullPath] {
continue
}
data, err := compressFile(asset.FullPath)
if err != nil {
return Result{err, false}
}
b.WriteString(fmt.Sprintf("\tcore.AddAsset(%q, %q, %q)\n", asset.Group, asset.Name, data))
packed[asset.FullPath] = true
}
b.WriteString("}\n")
return Result{b.String(), true}
}
// --- Compression ---
func compressFile(path string) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
return compress(string(data))
}
func compress(input string) (string, error) {
var buf bytes.Buffer
b64 := base64.NewEncoder(base64.StdEncoding, &buf)
gz, err := gzip.NewWriterLevel(b64, gzip.BestCompression)
if err != nil {
return "", err
}
if _, err := gz.Write([]byte(input)); err != nil {
_ = gz.Close()
_ = b64.Close()
return "", err
}
if err := gz.Close(); err != nil {
_ = b64.Close()
return "", err
}
if err := b64.Close(); err != nil {
return "", err
}
return buf.String(), nil
}
func decompress(input string) (string, error) {
b64 := base64.NewDecoder(base64.StdEncoding, NewReader(input))
gz, err := gzip.NewReader(b64)
if err != nil {
return "", err
}
data, err := io.ReadAll(gz)
if err != nil {
return "", err
}
if err := gz.Close(); err != nil {
return "", err
}
return string(data), nil
}
func getAllFiles(dir string) ([]string, error) {
var result []string
err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() {
result = append(result, path)
}
return nil
})
return result, err
}
// --- Embed: Scoped Filesystem Mount ---
// Embed wraps an fs.FS with a basedir for scoped access.
// All paths are relative to basedir.
type Embed struct {
basedir string
fsys fs.FS
embedFS *embed.FS // original embed.FS for type-safe access via EmbedFS()
}
// Mount creates a scoped view of an fs.FS anchored at basedir.
//
// r := core.Mount(myFS, "lib/prompts")
// if r.OK { emb := r.Value.(*Embed) }
func Mount(fsys fs.FS, basedir string) Result {
s := &Embed{fsys: fsys, basedir: basedir}
if efs, ok := fsys.(embed.FS); ok {
s.embedFS = &efs
}
if r := s.ReadDir("."); !r.OK {
return r
}
return Result{s, true}
}
// MountEmbed creates a scoped view of an embed.FS.
//
// r := core.MountEmbed(myFS, "testdata")
func MountEmbed(efs embed.FS, basedir string) Result {
return Mount(efs, basedir)
}
func (s *Embed) path(name string) Result {
joined := filepath.ToSlash(filepath.Join(s.basedir, name))
if HasPrefix(joined, "..") || Contains(joined, "/../") || HasSuffix(joined, "/..") {
return Result{E("embed.path", Concat("path traversal rejected: ", name), nil), false}
}
return Result{joined, true}
}
// Open opens the named file for reading.
//
// r := emb.Open("test.txt")
// if r.OK { file := r.Value.(fs.File) }
func (s *Embed) Open(name string) Result {
r := s.path(name)
if !r.OK {
return r
}
f, err := s.fsys.Open(r.Value.(string))
if err != nil {
return Result{err, false}
}
return Result{f, true}
}
// ReadDir reads the named directory.
func (s *Embed) ReadDir(name string) Result {
r := s.path(name)
if !r.OK {
return r
}
return Result{}.Result(fs.ReadDir(s.fsys, r.Value.(string)))
}
// ReadFile reads the named file.
//
// r := emb.ReadFile("test.txt")
// if r.OK { data := r.Value.([]byte) }
func (s *Embed) ReadFile(name string) Result {
r := s.path(name)
if !r.OK {
return r
}
data, err := fs.ReadFile(s.fsys, r.Value.(string))
if err != nil {
return Result{err, false}
}
return Result{data, true}
}
// ReadString reads the named file as a string.
//
// r := emb.ReadString("test.txt")
// if r.OK { content := r.Value.(string) }
func (s *Embed) ReadString(name string) Result {
r := s.ReadFile(name)
if !r.OK {
return r
}
return Result{string(r.Value.([]byte)), true}
}
// Sub returns a new Embed anchored at a subdirectory within this mount.
//
// r := emb.Sub("testdata")
// if r.OK { sub := r.Value.(*Embed) }
func (s *Embed) Sub(subDir string) Result {
r := s.path(subDir)
if !r.OK {
return r
}
sub, err := fs.Sub(s.fsys, r.Value.(string))
if err != nil {
return Result{err, false}
}
return Result{&Embed{fsys: sub, basedir: "."}, true}
}
// FS returns the underlying fs.FS.
func (s *Embed) FS() fs.FS {
return s.fsys
}
// EmbedFS returns the underlying embed.FS if mounted from one.
// Returns zero embed.FS if mounted from a non-embed source.
func (s *Embed) EmbedFS() embed.FS {
if s.embedFS != nil {
return *s.embedFS
}
return embed.FS{}
}
// BaseDirectory returns the base directory this Embed is anchored at.
func (s *Embed) BaseDirectory() string {
return s.basedir
}
// --- Template Extraction ---
// ExtractOptions configures template extraction.
type ExtractOptions struct {
// TemplateFilters identifies template files by substring match.
// Default: [".tmpl"]
TemplateFilters []string
// IgnoreFiles is a set of filenames to skip during extraction.
IgnoreFiles map[string]struct{}
// RenameFiles maps original filenames to new names.
RenameFiles map[string]string
}
// Extract copies a template directory from an fs.FS to targetDir,
// processing Go text/template in filenames and file contents.
//
// Files containing a template filter substring (default: ".tmpl") have
// their contents processed through text/template with the given data.
// The filter is stripped from the output filename.
//
// Directory and file names can contain Go template expressions:
// {{.Name}}/main.go → myproject/main.go
//
// Data can be any struct or map[string]string for template substitution.
func Extract(fsys fs.FS, targetDir string, data any, opts ...ExtractOptions) Result {
opt := ExtractOptions{
TemplateFilters: []string{".tmpl"},
IgnoreFiles: make(map[string]struct{}),
RenameFiles: make(map[string]string),
}
if len(opts) > 0 {
if len(opts[0].TemplateFilters) > 0 {
opt.TemplateFilters = opts[0].TemplateFilters
}
if opts[0].IgnoreFiles != nil {
opt.IgnoreFiles = opts[0].IgnoreFiles
}
if opts[0].RenameFiles != nil {
opt.RenameFiles = opts[0].RenameFiles
}
}
// Ensure target directory exists
targetDir, err := filepath.Abs(targetDir)
if err != nil {
return Result{err, false}
}
if err := os.MkdirAll(targetDir, 0755); err != nil {
return Result{err, false}
}
// Categorise files
var dirs []string
var templateFiles []string
var standardFiles []string
err = fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if path == "." {
return nil
}
if d.IsDir() {
dirs = append(dirs, path)
return nil
}
filename := filepath.Base(path)
if _, ignored := opt.IgnoreFiles[filename]; ignored {
return nil
}
if isTemplate(filename, opt.TemplateFilters) {
templateFiles = append(templateFiles, path)
} else {
standardFiles = append(standardFiles, path)
}
return nil
})
if err != nil {
return Result{err, false}
}
// safePath ensures a rendered path stays under targetDir.
safePath := func(rendered string) (string, error) {
abs, err := filepath.Abs(rendered)
if err != nil {
return "", err
}
if !HasPrefix(abs, targetDir+string(filepath.Separator)) && abs != targetDir {
return "", E("embed.Extract", Concat("path escapes target: ", abs), nil)
}
return abs, nil
}
// Create directories (names may contain templates)
for _, dir := range dirs {
target, err := safePath(renderPath(filepath.Join(targetDir, dir), data))
if err != nil {
return Result{err, false}
}
if err := os.MkdirAll(target, 0755); err != nil {
return Result{err, false}
}
}
// Process template files
for _, path := range templateFiles {
tmpl, err := template.ParseFS(fsys, path)
if err != nil {
return Result{err, false}
}
targetFile := renderPath(filepath.Join(targetDir, path), data)
// Strip template filters from filename
dir := filepath.Dir(targetFile)
name := filepath.Base(targetFile)
for _, filter := range opt.TemplateFilters {
name = Replace(name, filter, "")
}
if renamed := opt.RenameFiles[name]; renamed != "" {
name = renamed
}
targetFile, err = safePath(filepath.Join(dir, name))
if err != nil {
return Result{err, false}
}
f, err := os.Create(targetFile)
if err != nil {
return Result{err, false}
}
if err := tmpl.Execute(f, data); err != nil {
f.Close()
return Result{err, false}
}
f.Close()
}
// Copy standard files
for _, path := range standardFiles {
targetPath := path
name := filepath.Base(path)
if renamed := opt.RenameFiles[name]; renamed != "" {
targetPath = filepath.Join(filepath.Dir(path), renamed)
}
target, err := safePath(renderPath(filepath.Join(targetDir, targetPath), data))
if err != nil {
return Result{err, false}
}
if err := copyFile(fsys, path, target); err != nil {
return Result{err, false}
}
}
return Result{OK: true}
}
func isTemplate(filename string, filters []string) bool {
for _, f := range filters {
if Contains(filename, f) {
return true
}
}
return false
}
func renderPath(path string, data any) string {
if data == nil {
return path
}
tmpl, err := template.New("path").Parse(path)
if err != nil {
return path
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return path
}
return buf.String()
}
func copyFile(fsys fs.FS, source, target string) error {
s, err := fsys.Open(source)
if err != nil {
return err
}
defer s.Close()
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
return err
}
d, err := os.Create(target)
if err != nil {
return err
}
defer d.Close()
_, err = io.Copy(d, s)
return err
}

View file

@ -0,0 +1,395 @@
// SPDX-License-Identifier: EUPL-1.2
// Structured errors, crash recovery, and reporting for the Core framework.
// Provides E() for error creation, Wrap()/WrapCode() for chaining,
// and Err for panic recovery and crash reporting.
package core
import (
"encoding/json"
"errors"
"iter"
"maps"
"os"
"path/filepath"
"runtime"
"runtime/debug"
"sync"
"time"
)
// ErrorSink is the shared interface for error reporting.
// Implemented by ErrorLog (structured logging) and ErrorPanic (panic recovery).
type ErrorSink interface {
Error(msg string, keyvals ...any)
Warn(msg string, keyvals ...any)
}
var _ ErrorSink = (*Log)(nil)
// Err represents a structured error with operational context.
// It implements the error interface and supports unwrapping.
type Err struct {
Operation string // Operation being performed (e.g., "user.Save")
Message string // Human-readable message
Cause error // Underlying error (optional)
Code string // Error code (optional, e.g., "VALIDATION_FAILED")
}
// Error implements the error interface.
func (e *Err) Error() string {
var prefix string
if e.Operation != "" {
prefix = e.Operation + ": "
}
if e.Cause != nil {
if e.Code != "" {
return Concat(prefix, e.Message, " [", e.Code, "]: ", e.Cause.Error())
}
return Concat(prefix, e.Message, ": ", e.Cause.Error())
}
if e.Code != "" {
return Concat(prefix, e.Message, " [", e.Code, "]")
}
return Concat(prefix, e.Message)
}
// Unwrap returns the underlying error for use with errors.Is and errors.As.
func (e *Err) Unwrap() error {
return e.Cause
}
// --- Error Creation Functions ---
// E creates a new Err with operation context.
// The underlying error can be nil for creating errors without a cause.
//
// Example:
//
// return log.E("user.Save", "failed to save user", err)
// return log.E("api.Call", "rate limited", nil) // No underlying cause
func E(op, msg string, err error) error {
return &Err{Operation: op, Message: msg, Cause: err}
}
// Wrap wraps an error with operation context.
// Returns nil if err is nil, to support conditional wrapping.
// Preserves error Code if the wrapped error is an *Err.
//
// Example:
//
// return log.Wrap(err, "db.Query", "database query failed")
func Wrap(err error, op, msg string) error {
if err == nil {
return nil
}
// Preserve Code from wrapped *Err
var logErr *Err
if As(err, &logErr) && logErr.Code != "" {
return &Err{Operation: op, Message: msg, Cause: err, Code: logErr.Code}
}
return &Err{Operation: op, Message: msg, Cause: err}
}
// WrapCode wraps an error with operation context and error code.
// Returns nil only if both err is nil AND code is empty.
// Useful for API errors that need machine-readable codes.
//
// Example:
//
// return log.WrapCode(err, "VALIDATION_ERROR", "user.Validate", "invalid email")
func WrapCode(err error, code, op, msg string) error {
if err == nil && code == "" {
return nil
}
return &Err{Operation: op, Message: msg, Cause: err, Code: code}
}
// NewCode creates an error with just code and message (no underlying error).
// Useful for creating sentinel errors with codes.
//
// Example:
//
// var ErrNotFound = log.NewCode("NOT_FOUND", "resource not found")
func NewCode(code, msg string) error {
return &Err{Message: msg, Code: code}
}
// --- Standard Library Wrappers ---
// Is reports whether any error in err's tree matches target.
// Wrapper around errors.Is for convenience.
func Is(err, target error) bool {
return errors.Is(err, target)
}
// As finds the first error in err's tree that matches target.
// Wrapper around errors.As for convenience.
func As(err error, target any) bool {
return errors.As(err, target)
}
// NewError creates a simple error with the given text.
// Wrapper around errors.New for convenience.
func NewError(text string) error {
return errors.New(text)
}
// ErrorJoin combines multiple errors into one.
//
// core.ErrorJoin(err1, err2, err3)
func ErrorJoin(errs ...error) error {
return errors.Join(errs...)
}
// --- Error Introspection Helpers ---
// Operation extracts the operation name from an error.
// Returns empty string if the error is not an *Err.
func Operation(err error) string {
var e *Err
if As(err, &e) {
return e.Operation
}
return ""
}
// ErrorCode extracts the error code from an error.
// Returns empty string if the error is not an *Err or has no code.
func ErrorCode(err error) string {
var e *Err
if As(err, &e) {
return e.Code
}
return ""
}
// Message extracts the message from an error.
// Returns the error's Error() string if not an *Err.
func ErrorMessage(err error) string {
if err == nil {
return ""
}
var e *Err
if As(err, &e) {
return e.Message
}
return err.Error()
}
// Root returns the root cause of an error chain.
// Unwraps until no more wrapped errors are found.
func Root(err error) error {
if err == nil {
return nil
}
for {
unwrapped := errors.Unwrap(err)
if unwrapped == nil {
return err
}
err = unwrapped
}
}
// AllOperations returns an iterator over all operational contexts in the error chain.
// It traverses the error tree using errors.Unwrap.
func AllOperations(err error) iter.Seq[string] {
return func(yield func(string) bool) {
for err != nil {
if e, ok := err.(*Err); ok {
if e.Operation != "" {
if !yield(e.Operation) {
return
}
}
}
err = errors.Unwrap(err)
}
}
}
// StackTrace returns the logical stack trace (chain of operations) from an error.
// It returns an empty slice if no operational context is found.
func StackTrace(err error) []string {
var stack []string
for op := range AllOperations(err) {
stack = append(stack, op)
}
return stack
}
// FormatStackTrace returns a pretty-printed logical stack trace.
func FormatStackTrace(err error) string {
var ops []string
for op := range AllOperations(err) {
ops = append(ops, op)
}
if len(ops) == 0 {
return ""
}
return Join(" -> ", ops...)
}
// --- ErrorLog: Log-and-Return Error Helpers ---
// ErrorLog combines error creation with logging.
// Primary action: return an error. Secondary: log it.
type ErrorLog struct {
log *Log
}
func (el *ErrorLog) logger() *Log {
if el.log != nil {
return el.log
}
return Default()
}
// Error logs at Error level and returns a Result with the wrapped error.
func (el *ErrorLog) Error(err error, op, msg string) Result {
if err == nil {
return Result{OK: true}
}
wrapped := Wrap(err, op, msg)
el.logger().Error(msg, "op", op, "err", err)
return Result{wrapped, false}
}
// Warn logs at Warn level and returns a Result with the wrapped error.
func (el *ErrorLog) Warn(err error, op, msg string) Result {
if err == nil {
return Result{OK: true}
}
wrapped := Wrap(err, op, msg)
el.logger().Warn(msg, "op", op, "err", err)
return Result{wrapped, false}
}
// Must logs and panics if err is not nil.
func (el *ErrorLog) Must(err error, op, msg string) {
if err != nil {
el.logger().Error(msg, "op", op, "err", err)
panic(Wrap(err, op, msg))
}
}
// --- Crash Recovery & Reporting ---
// CrashReport represents a single crash event.
type CrashReport struct {
Timestamp time.Time `json:"timestamp"`
Error string `json:"error"`
Stack string `json:"stack"`
System CrashSystem `json:"system,omitempty"`
Meta map[string]string `json:"meta,omitempty"`
}
// CrashSystem holds system information at crash time.
type CrashSystem struct {
OperatingSystem string `json:"operatingsystem"`
Architecture string `json:"architecture"`
Version string `json:"go_version"`
}
// ErrorPanic manages panic recovery and crash reporting.
type ErrorPanic struct {
filePath string
meta map[string]string
onCrash func(CrashReport)
}
// Recover captures a panic and creates a crash report.
// Use as: defer c.Error().Recover()
func (h *ErrorPanic) Recover() {
if h == nil {
return
}
r := recover()
if r == nil {
return
}
err, ok := r.(error)
if !ok {
err = NewError(Sprint("panic: ", r))
}
report := CrashReport{
Timestamp: time.Now(),
Error: err.Error(),
Stack: string(debug.Stack()),
System: CrashSystem{
OperatingSystem: runtime.GOOS,
Architecture: runtime.GOARCH,
Version: runtime.Version(),
},
Meta: maps.Clone(h.meta),
}
if h.onCrash != nil {
h.onCrash(report)
}
if h.filePath != "" {
h.appendReport(report)
}
}
// SafeGo runs a function in a goroutine with panic recovery.
func (h *ErrorPanic) SafeGo(fn func()) {
go func() {
defer h.Recover()
fn()
}()
}
// Reports returns the last n crash reports from the file.
func (h *ErrorPanic) Reports(n int) Result {
if h.filePath == "" {
return Result{}
}
crashMu.Lock()
defer crashMu.Unlock()
data, err := os.ReadFile(h.filePath)
if err != nil {
return Result{err, false}
}
var reports []CrashReport
if err := json.Unmarshal(data, &reports); err != nil {
return Result{err, false}
}
if n <= 0 || len(reports) <= n {
return Result{reports, true}
}
return Result{reports[len(reports)-n:], true}
}
var crashMu sync.Mutex
func (h *ErrorPanic) appendReport(report CrashReport) {
crashMu.Lock()
defer crashMu.Unlock()
var reports []CrashReport
if data, err := os.ReadFile(h.filePath); err == nil {
if err := json.Unmarshal(data, &reports); err != nil {
reports = nil
}
}
reports = append(reports, report)
data, err := json.MarshalIndent(reports, "", " ")
if err != nil {
Default().Error(Concat("crash report marshal failed: ", err.Error()))
return
}
if err := os.MkdirAll(filepath.Dir(h.filePath), 0755); err != nil {
Default().Error(Concat("crash report dir failed: ", err.Error()))
return
}
if err := os.WriteFile(h.filePath, data, 0600); err != nil {
Default().Error(Concat("crash report write failed: ", err.Error()))
}
}

View file

@ -0,0 +1,287 @@
// Sandboxed local filesystem I/O for the Core framework.
package core
import (
"os"
"os/user"
"path/filepath"
"time"
)
// Fs is a sandboxed local filesystem backend.
type Fs struct {
root string
}
// path sanitises and returns the full path.
// Absolute paths are sandboxed under root (unless root is "/").
func (m *Fs) path(p string) string {
if p == "" {
return m.root
}
// If the path is relative and the medium is rooted at "/",
// treat it as relative to the current working directory.
// This makes io.Local behave more like the standard 'os' package.
if m.root == "/" && !filepath.IsAbs(p) {
cwd, _ := os.Getwd()
return filepath.Join(cwd, p)
}
// Use filepath.Clean with a leading slash to resolve all .. and . internally
// before joining with the root. This is a standard way to sandbox paths.
clean := filepath.Clean("/" + p)
// If root is "/", allow absolute paths through
if m.root == "/" {
return clean
}
// Strip leading "/" so Join works correctly with root
return filepath.Join(m.root, clean[1:])
}
// validatePath ensures the path is within the sandbox, following symlinks if they exist.
func (m *Fs) validatePath(p string) Result {
if m.root == "/" {
return Result{m.path(p), true}
}
// Split the cleaned path into components
parts := Split(filepath.Clean("/"+p), string(os.PathSeparator))
current := m.root
for _, part := range parts {
if part == "" {
continue
}
next := filepath.Join(current, part)
realNext, err := filepath.EvalSymlinks(next)
if err != nil {
if os.IsNotExist(err) {
// Part doesn't exist, we can't follow symlinks anymore.
// Since the path is already Cleaned and current is safe,
// appending a component to current will not escape.
current = next
continue
}
return Result{err, false}
}
// Verify the resolved part is still within the root
rel, err := filepath.Rel(m.root, realNext)
if err != nil || HasPrefix(rel, "..") {
// Security event: sandbox escape attempt
username := "unknown"
if u, err := user.Current(); err == nil {
username = u.Username
}
Print(os.Stderr, "[%s] SECURITY sandbox escape detected root=%s path=%s attempted=%s user=%s",
time.Now().Format(time.RFC3339), m.root, p, realNext, username)
if err == nil {
err = E("fs.validatePath", Concat("sandbox escape: ", p, " resolves outside ", m.root), nil)
}
return Result{err, false}
}
current = realNext
}
return Result{current, true}
}
// Read returns file contents as string.
func (m *Fs) Read(p string) Result {
vp := m.validatePath(p)
if !vp.OK {
return vp
}
data, err := os.ReadFile(vp.Value.(string))
if err != nil {
return Result{err, false}
}
return Result{string(data), true}
}
// Write saves content to file, creating parent directories as needed.
// Files are created with mode 0644. For sensitive files (keys, secrets),
// use WriteMode with 0600.
func (m *Fs) Write(p, content string) Result {
return m.WriteMode(p, content, 0644)
}
// WriteMode saves content to file with explicit permissions.
// Use 0600 for sensitive files (encryption output, private keys, auth hashes).
func (m *Fs) WriteMode(p, content string, mode os.FileMode) Result {
vp := m.validatePath(p)
if !vp.OK {
return vp
}
full := vp.Value.(string)
if err := os.MkdirAll(filepath.Dir(full), 0755); err != nil {
return Result{err, false}
}
if err := os.WriteFile(full, []byte(content), mode); err != nil {
return Result{err, false}
}
return Result{OK: true}
}
// EnsureDir creates directory if it doesn't exist.
func (m *Fs) EnsureDir(p string) Result {
vp := m.validatePath(p)
if !vp.OK {
return vp
}
if err := os.MkdirAll(vp.Value.(string), 0755); err != nil {
return Result{err, false}
}
return Result{OK: true}
}
// IsDir returns true if path is a directory.
func (m *Fs) IsDir(p string) bool {
if p == "" {
return false
}
vp := m.validatePath(p)
if !vp.OK {
return false
}
info, err := os.Stat(vp.Value.(string))
return err == nil && info.IsDir()
}
// IsFile returns true if path is a regular file.
func (m *Fs) IsFile(p string) bool {
if p == "" {
return false
}
vp := m.validatePath(p)
if !vp.OK {
return false
}
info, err := os.Stat(vp.Value.(string))
return err == nil && info.Mode().IsRegular()
}
// Exists returns true if path exists.
func (m *Fs) Exists(p string) bool {
vp := m.validatePath(p)
if !vp.OK {
return false
}
_, err := os.Stat(vp.Value.(string))
return err == nil
}
// List returns directory entries.
func (m *Fs) List(p string) Result {
vp := m.validatePath(p)
if !vp.OK {
return vp
}
return Result{}.Result(os.ReadDir(vp.Value.(string)))
}
// Stat returns file info.
func (m *Fs) Stat(p string) Result {
vp := m.validatePath(p)
if !vp.OK {
return vp
}
return Result{}.Result(os.Stat(vp.Value.(string)))
}
// Open opens the named file for reading.
func (m *Fs) Open(p string) Result {
vp := m.validatePath(p)
if !vp.OK {
return vp
}
return Result{}.Result(os.Open(vp.Value.(string)))
}
// Create creates or truncates the named file.
func (m *Fs) Create(p string) Result {
vp := m.validatePath(p)
if !vp.OK {
return vp
}
full := vp.Value.(string)
if err := os.MkdirAll(filepath.Dir(full), 0755); err != nil {
return Result{err, false}
}
return Result{}.Result(os.Create(full))
}
// Append opens the named file for appending, creating it if it doesn't exist.
func (m *Fs) Append(p string) Result {
vp := m.validatePath(p)
if !vp.OK {
return vp
}
full := vp.Value.(string)
if err := os.MkdirAll(filepath.Dir(full), 0755); err != nil {
return Result{err, false}
}
return Result{}.Result(os.OpenFile(full, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644))
}
// ReadStream returns a reader for the file content.
func (m *Fs) ReadStream(path string) Result {
return m.Open(path)
}
// WriteStream returns a writer for the file content.
func (m *Fs) WriteStream(path string) Result {
return m.Create(path)
}
// Delete removes a file or empty directory.
func (m *Fs) Delete(p string) Result {
vp := m.validatePath(p)
if !vp.OK {
return vp
}
full := vp.Value.(string)
if full == "/" || full == os.Getenv("HOME") {
return Result{E("fs.Delete", Concat("refusing to delete protected path: ", full), nil), false}
}
if err := os.Remove(full); err != nil {
return Result{err, false}
}
return Result{OK: true}
}
// DeleteAll removes a file or directory recursively.
func (m *Fs) DeleteAll(p string) Result {
vp := m.validatePath(p)
if !vp.OK {
return vp
}
full := vp.Value.(string)
if full == "/" || full == os.Getenv("HOME") {
return Result{E("fs.DeleteAll", Concat("refusing to delete protected path: ", full), nil), false}
}
if err := os.RemoveAll(full); err != nil {
return Result{err, false}
}
return Result{OK: true}
}
// Rename moves a file or directory.
func (m *Fs) Rename(oldPath, newPath string) Result {
oldVp := m.validatePath(oldPath)
if !oldVp.OK {
return oldVp
}
newVp := m.validatePath(newPath)
if !newVp.OK {
return newVp
}
if err := os.Rename(oldVp.Value.(string), newVp.Value.(string)); err != nil {
return Result{err, false}
}
return Result{OK: true}
}

View file

@ -0,0 +1,138 @@
// SPDX-License-Identifier: EUPL-1.2
// Internationalisation for the Core framework.
// I18n collects locale mounts from services and delegates
// translation to a registered Translator implementation (e.g., go-i18n).
package core
import (
"sync"
)
// Translator defines the interface for translation services.
// Implemented by go-i18n's Srv.
type Translator interface {
// Translate translates a message by its ID with optional arguments.
Translate(messageID string, args ...any) Result
// SetLanguage sets the active language (BCP47 tag, e.g., "en-GB", "de").
SetLanguage(lang string) error
// Language returns the current language code.
Language() string
// AvailableLanguages returns all loaded language codes.
AvailableLanguages() []string
}
// LocaleProvider is implemented by services that ship their own translation files.
// Core discovers this interface during service registration and collects the
// locale mounts. The i18n service loads them during startup.
//
// Usage in a service package:
//
// //go:embed locales
// var localeFS embed.FS
//
// func (s *MyService) Locales() *Embed {
// m, _ := Mount(localeFS, "locales")
// return m
// }
type LocaleProvider interface {
Locales() *Embed
}
// I18n manages locale collection and translation dispatch.
type I18n struct {
mu sync.RWMutex
locales []*Embed // collected from LocaleProvider services
locale string
translator Translator // registered implementation (nil until set)
}
// AddLocales adds locale mounts (called during service registration).
func (i *I18n) AddLocales(mounts ...*Embed) {
i.mu.Lock()
i.locales = append(i.locales, mounts...)
i.mu.Unlock()
}
// Locales returns all collected locale mounts.
func (i *I18n) Locales() Result {
i.mu.RLock()
out := make([]*Embed, len(i.locales))
copy(out, i.locales)
i.mu.RUnlock()
return Result{out, true}
}
// SetTranslator registers the translation implementation.
// Called by go-i18n's Srv during startup.
func (i *I18n) SetTranslator(t Translator) {
i.mu.Lock()
i.translator = t
locale := i.locale
i.mu.Unlock()
if t != nil && locale != "" {
_ = t.SetLanguage(locale)
}
}
// Translator returns the registered translation implementation, or nil.
func (i *I18n) Translator() Result {
i.mu.RLock()
t := i.translator
i.mu.RUnlock()
if t == nil {
return Result{}
}
return Result{t, true}
}
// Translate translates a message. Returns the key as-is if no translator is registered.
func (i *I18n) Translate(messageID string, args ...any) Result {
i.mu.RLock()
t := i.translator
i.mu.RUnlock()
if t != nil {
return t.Translate(messageID, args...)
}
return Result{messageID, true}
}
// SetLanguage sets the active language and forwards to the translator if registered.
func (i *I18n) SetLanguage(lang string) Result {
if lang == "" {
return Result{OK: true}
}
i.mu.Lock()
i.locale = lang
t := i.translator
i.mu.Unlock()
if t != nil {
if err := t.SetLanguage(lang); err != nil {
return Result{err, false}
}
}
return Result{OK: true}
}
// Language returns the current language code, or "en" if not set.
func (i *I18n) Language() string {
i.mu.RLock()
locale := i.locale
i.mu.RUnlock()
if locale != "" {
return locale
}
return "en"
}
// AvailableLanguages returns all loaded language codes.
func (i *I18n) AvailableLanguages() []string {
i.mu.RLock()
t := i.translator
i.mu.RUnlock()
if t != nil {
return t.AvailableLanguages()
}
return []string{"en"}
}

View file

@ -0,0 +1,72 @@
// SPDX-License-Identifier: EUPL-1.2
// Message bus for the Core framework.
// Dispatches actions (fire-and-forget), queries (first responder),
// and tasks (first executor) between registered handlers.
package core
import (
"slices"
"sync"
)
// Ipc holds IPC dispatch data.
type Ipc struct {
ipcMu sync.RWMutex
ipcHandlers []func(*Core, Message) Result
queryMu sync.RWMutex
queryHandlers []QueryHandler
taskMu sync.RWMutex
taskHandlers []TaskHandler
}
func (c *Core) Action(msg Message) Result {
c.ipc.ipcMu.RLock()
handlers := slices.Clone(c.ipc.ipcHandlers)
c.ipc.ipcMu.RUnlock()
for _, h := range handlers {
if r := h(c, msg); !r.OK {
return r
}
}
return Result{OK: true}
}
func (c *Core) Query(q Query) Result {
c.ipc.queryMu.RLock()
handlers := slices.Clone(c.ipc.queryHandlers)
c.ipc.queryMu.RUnlock()
for _, h := range handlers {
r := h(c, q)
if r.OK {
return r
}
}
return Result{}
}
func (c *Core) QueryAll(q Query) Result {
c.ipc.queryMu.RLock()
handlers := slices.Clone(c.ipc.queryHandlers)
c.ipc.queryMu.RUnlock()
var results []any
for _, h := range handlers {
r := h(c, q)
if r.OK && r.Value != nil {
results = append(results, r.Value)
}
}
return Result{results, true}
}
func (c *Core) RegisterQuery(handler QueryHandler) {
c.ipc.queryMu.Lock()
c.ipc.queryHandlers = append(c.ipc.queryHandlers, handler)
c.ipc.queryMu.Unlock()
}

View file

@ -0,0 +1,89 @@
// SPDX-License-Identifier: EUPL-1.2
// Synchronisation, locking, and lifecycle snapshots for the Core framework.
package core
import (
"sync"
)
// package-level mutex infrastructure
var (
lockMu sync.Mutex
lockMap = make(map[string]*sync.RWMutex)
)
// Lock is the DTO for a named mutex.
type Lock struct {
Name string
Mutex *sync.RWMutex
}
// Lock returns a named Lock, creating the mutex if needed.
func (c *Core) Lock(name string) *Lock {
lockMu.Lock()
m, ok := lockMap[name]
if !ok {
m = &sync.RWMutex{}
lockMap[name] = m
}
lockMu.Unlock()
return &Lock{Name: name, Mutex: m}
}
// LockEnable marks that the service lock should be applied after initialisation.
func (c *Core) LockEnable(name ...string) {
n := "srv"
if len(name) > 0 {
n = name[0]
}
c.Lock(n).Mutex.Lock()
defer c.Lock(n).Mutex.Unlock()
c.services.lockEnabled = true
}
// LockApply activates the service lock if it was enabled.
func (c *Core) LockApply(name ...string) {
n := "srv"
if len(name) > 0 {
n = name[0]
}
c.Lock(n).Mutex.Lock()
defer c.Lock(n).Mutex.Unlock()
if c.services.lockEnabled {
c.services.locked = true
}
}
// Startables returns services that have an OnStart function.
func (c *Core) Startables() Result {
if c.services == nil {
return Result{}
}
c.Lock("srv").Mutex.RLock()
defer c.Lock("srv").Mutex.RUnlock()
var out []*Service
for _, svc := range c.services.services {
if svc.OnStart != nil {
out = append(out, svc)
}
}
return Result{out, true}
}
// Stoppables returns services that have an OnStop function.
func (c *Core) Stoppables() Result {
if c.services == nil {
return Result{}
}
c.Lock("srv").Mutex.RLock()
defer c.Lock("srv").Mutex.RUnlock()
var out []*Service
for _, svc := range c.services.services {
if svc.OnStop != nil {
out = append(out, svc)
}
}
return Result{out, true}
}

View file

@ -0,0 +1,402 @@
// Structured logging for the Core framework.
//
// core.SetLevel(core.LevelDebug)
// core.Info("server started", "port", 8080)
// core.Error("failed to connect", "err", err)
package core
import (
goio "io"
"os"
"os/user"
"slices"
"sync"
"sync/atomic"
"time"
)
// Level defines logging verbosity.
type Level int
// Logging level constants ordered by increasing verbosity.
const (
// LevelQuiet suppresses all log output.
LevelQuiet Level = iota
// LevelError shows only error messages.
LevelError
// LevelWarn shows warnings and errors.
LevelWarn
// LevelInfo shows informational messages, warnings, and errors.
LevelInfo
// LevelDebug shows all messages including debug details.
LevelDebug
)
// String returns the level name.
func (l Level) String() string {
switch l {
case LevelQuiet:
return "quiet"
case LevelError:
return "error"
case LevelWarn:
return "warn"
case LevelInfo:
return "info"
case LevelDebug:
return "debug"
default:
return "unknown"
}
}
// Log provides structured logging.
type Log struct {
mu sync.RWMutex
level Level
output goio.Writer
// RedactKeys is a list of keys whose values should be masked in logs.
redactKeys []string
// Style functions for formatting (can be overridden)
StyleTimestamp func(string) string
StyleDebug func(string) string
StyleInfo func(string) string
StyleWarn func(string) string
StyleError func(string) string
StyleSecurity func(string) string
}
// RotationLogOptions defines the log rotation and retention policy.
type RotationLogOptions struct {
// Filename is the log file path. If empty, rotation is disabled.
Filename string
// MaxSize is the maximum size of the log file in megabytes before it gets rotated.
// It defaults to 100 megabytes.
MaxSize int
// MaxAge is the maximum number of days to retain old log files based on their
// file modification time. It defaults to 28 days.
// Note: set to a negative value to disable age-based retention.
MaxAge int
// MaxBackups is the maximum number of old log files to retain.
// It defaults to 5 backups.
MaxBackups int
// Compress determines if the rotated log files should be compressed using gzip.
// It defaults to true.
Compress bool
}
// LogOptions configures a Log.
type LogOptions struct {
Level Level
// Output is the destination for log messages. If Rotation is provided,
// Output is ignored and logs are written to the rotating file instead.
Output goio.Writer
// Rotation enables log rotation to file. If provided, Filename must be set.
Rotation *RotationLogOptions
// RedactKeys is a list of keys whose values should be masked in logs.
RedactKeys []string
}
// RotationWriterFactory creates a rotating writer from options.
// Set this to enable log rotation (provided by core/go-io integration).
var RotationWriterFactory func(RotationLogOptions) goio.WriteCloser
// New creates a new Log with the given options.
func NewLog(opts LogOptions) *Log {
output := opts.Output
if opts.Rotation != nil && opts.Rotation.Filename != "" && RotationWriterFactory != nil {
output = RotationWriterFactory(*opts.Rotation)
}
if output == nil {
output = os.Stderr
}
return &Log{
level: opts.Level,
output: output,
redactKeys: slices.Clone(opts.RedactKeys),
StyleTimestamp: identity,
StyleDebug: identity,
StyleInfo: identity,
StyleWarn: identity,
StyleError: identity,
StyleSecurity: identity,
}
}
func identity(s string) string { return s }
// SetLevel changes the log level.
func (l *Log) SetLevel(level Level) {
l.mu.Lock()
l.level = level
l.mu.Unlock()
}
// Level returns the current log level.
func (l *Log) Level() Level {
l.mu.RLock()
defer l.mu.RUnlock()
return l.level
}
// SetOutput changes the output writer.
func (l *Log) SetOutput(w goio.Writer) {
l.mu.Lock()
l.output = w
l.mu.Unlock()
}
// SetRedactKeys sets the keys to be redacted.
func (l *Log) SetRedactKeys(keys ...string) {
l.mu.Lock()
l.redactKeys = slices.Clone(keys)
l.mu.Unlock()
}
func (l *Log) shouldLog(level Level) bool {
l.mu.RLock()
defer l.mu.RUnlock()
return level <= l.level
}
func (l *Log) log(level Level, prefix, msg string, keyvals ...any) {
l.mu.RLock()
output := l.output
styleTimestamp := l.StyleTimestamp
redactKeys := l.redactKeys
l.mu.RUnlock()
timestamp := styleTimestamp(time.Now().Format("15:04:05"))
// Copy keyvals to avoid mutating the caller's slice
keyvals = append([]any(nil), keyvals...)
// Automatically extract context from error if present in keyvals
origLen := len(keyvals)
for i := 0; i < origLen; i += 2 {
if i+1 < origLen {
if err, ok := keyvals[i+1].(error); ok {
if op := Operation(err); op != "" {
// Check if op is already in keyvals
hasOp := false
for j := 0; j < len(keyvals); j += 2 {
if k, ok := keyvals[j].(string); ok && k == "op" {
hasOp = true
break
}
}
if !hasOp {
keyvals = append(keyvals, "op", op)
}
}
if stack := FormatStackTrace(err); stack != "" {
// Check if stack is already in keyvals
hasStack := false
for j := 0; j < len(keyvals); j += 2 {
if k, ok := keyvals[j].(string); ok && k == "stack" {
hasStack = true
break
}
}
if !hasStack {
keyvals = append(keyvals, "stack", stack)
}
}
}
}
}
// Format key-value pairs
var kvStr string
if len(keyvals) > 0 {
kvStr = " "
for i := 0; i < len(keyvals); i += 2 {
if i > 0 {
kvStr += " "
}
key := keyvals[i]
var val any
if i+1 < len(keyvals) {
val = keyvals[i+1]
}
// Redaction logic
keyStr := Sprint(key)
if slices.Contains(redactKeys, keyStr) {
val = "[REDACTED]"
}
// Secure formatting to prevent log injection
if s, ok := val.(string); ok {
kvStr += Sprintf("%v=%q", key, s)
} else {
kvStr += Sprintf("%v=%v", key, val)
}
}
}
Print(output, "%s %s %s%s", timestamp, prefix, msg, kvStr)
}
// Debug logs a debug message with optional key-value pairs.
func (l *Log) Debug(msg string, keyvals ...any) {
if l.shouldLog(LevelDebug) {
l.log(LevelDebug, l.StyleDebug("[DBG]"), msg, keyvals...)
}
}
// Info logs an info message with optional key-value pairs.
func (l *Log) Info(msg string, keyvals ...any) {
if l.shouldLog(LevelInfo) {
l.log(LevelInfo, l.StyleInfo("[INF]"), msg, keyvals...)
}
}
// Warn logs a warning message with optional key-value pairs.
func (l *Log) Warn(msg string, keyvals ...any) {
if l.shouldLog(LevelWarn) {
l.log(LevelWarn, l.StyleWarn("[WRN]"), msg, keyvals...)
}
}
// Error logs an error message with optional key-value pairs.
func (l *Log) Error(msg string, keyvals ...any) {
if l.shouldLog(LevelError) {
l.log(LevelError, l.StyleError("[ERR]"), msg, keyvals...)
}
}
// Security logs a security event with optional key-value pairs.
// It uses LevelError to ensure security events are visible even in restrictive
// log configurations.
func (l *Log) Security(msg string, keyvals ...any) {
if l.shouldLog(LevelError) {
l.log(LevelError, l.StyleSecurity("[SEC]"), msg, keyvals...)
}
}
// Username returns the current system username.
// It uses os/user for reliability and falls back to environment variables.
func Username() string {
if u, err := user.Current(); err == nil {
return u.Username
}
// Fallback for environments where user lookup might fail
if u := os.Getenv("USER"); u != "" {
return u
}
return os.Getenv("USERNAME")
}
// --- Default logger ---
var defaultLogPtr atomic.Pointer[Log]
func init() {
l := NewLog(LogOptions{Level: LevelInfo})
defaultLogPtr.Store(l)
}
// Default returns the default logger.
func Default() *Log {
return defaultLogPtr.Load()
}
// SetDefault sets the default logger.
func SetDefault(l *Log) {
defaultLogPtr.Store(l)
}
// SetLevel sets the default logger's level.
func SetLevel(level Level) {
Default().SetLevel(level)
}
// SetRedactKeys sets the default logger's redaction keys.
func SetRedactKeys(keys ...string) {
Default().SetRedactKeys(keys...)
}
// Debug logs to the default logger.
func Debug(msg string, keyvals ...any) {
Default().Debug(msg, keyvals...)
}
// Info logs to the default logger.
func Info(msg string, keyvals ...any) {
Default().Info(msg, keyvals...)
}
// Warn logs to the default logger.
func Warn(msg string, keyvals ...any) {
Default().Warn(msg, keyvals...)
}
// Error logs to the default logger.
func Error(msg string, keyvals ...any) {
Default().Error(msg, keyvals...)
}
// Security logs to the default logger.
func Security(msg string, keyvals ...any) {
Default().Security(msg, keyvals...)
}
// --- LogErr: Error-Aware Logger ---
// LogErr logs structured information extracted from errors.
// Primary action: log. Secondary: extract error context.
type LogErr struct {
log *Log
}
// NewLogErr creates a LogErr bound to the given logger.
func NewLogErr(log *Log) *LogErr {
return &LogErr{log: log}
}
// Log extracts context from an Err and logs it at Error level.
func (le *LogErr) Log(err error) {
if err == nil {
return
}
le.log.Error(ErrorMessage(err), "op", Operation(err), "code", ErrorCode(err), "stack", FormatStackTrace(err))
}
// --- LogPanic: Panic-Aware Logger ---
// LogPanic logs panic context without crash file management.
// Primary action: log. Secondary: recover panics.
type LogPanic struct {
log *Log
}
// NewLogPanic creates a LogPanic bound to the given logger.
func NewLogPanic(log *Log) *LogPanic {
return &LogPanic{log: log}
}
// Recover captures a panic and logs it. Does not write crash files.
// Use as: defer core.NewLogPanic(logger).Recover()
func (lp *LogPanic) Recover() {
r := recover()
if r == nil {
return
}
err, ok := r.(error)
if !ok {
err = NewError(Sprint("panic: ", r))
}
lp.log.Error("panic recovered",
"err", err,
"op", Operation(err),
"stack", FormatStackTrace(err),
)
}

View file

@ -0,0 +1,140 @@
// SPDX-License-Identifier: EUPL-1.2
// Core primitives: Option, Options, Result.
//
// Option is a single key-value pair. Options is a collection.
// Any function that returns Result can accept Options.
//
// Create options:
//
// opts := core.Options{
// {Key: "name", Value: "brain"},
// {Key: "path", Value: "prompts"},
// }
//
// Read options:
//
// name := opts.String("name")
// port := opts.Int("port")
// ok := opts.Has("debug")
//
// Use with subsystems:
//
// c.Drive().New(core.Options{
// {Key: "name", Value: "brain"},
// {Key: "source", Value: brainFS},
// {Key: "path", Value: "prompts"},
// })
//
// Use with New:
//
// c := core.New(core.Options{
// {Key: "name", Value: "myapp"},
// })
package core
// Result is the universal return type for Core operations.
// Replaces the (value, error) pattern — errors flow through Core internally.
//
// r := c.Data().New(core.Options{{Key: "name", Value: "brain"}})
// if r.OK { use(r.Result()) }
type Result struct {
Value any
OK bool
}
// Result gets or sets the value. Zero args returns Value. With args, maps
// Go (value, error) pairs to Result and returns self.
//
// r.Result(file, err) // OK = err == nil, Value = file
// r.Result(value) // OK = true, Value = value
// r.Result() // after set — returns the value
func (r Result) Result(args ...any) Result {
if len(args) == 0 {
return r
}
if len(args) == 1 {
return Result{args[0], true}
}
if err, ok := args[len(args)-1].(error); ok {
if err != nil {
return Result{err, false}
}
return Result{args[0], true}
}
return Result{args[0], true}
}
// Option is a single key-value configuration pair.
//
// core.Option{Key: "name", Value: "brain"}
// core.Option{Key: "port", Value: 8080}
type Option struct {
Key string
Value any
}
// Options is a collection of Option items.
// The universal input type for Core operations.
//
// opts := core.Options{{Key: "name", Value: "myapp"}}
// name := opts.String("name")
type Options []Option
// Get retrieves a value by key.
//
// r := opts.Get("name")
// if r.OK { name := r.Value.(string) }
func (o Options) Get(key string) Result {
for _, opt := range o {
if opt.Key == key {
return Result{opt.Value, true}
}
}
return Result{}
}
// Has returns true if a key exists.
//
// if opts.Has("debug") { ... }
func (o Options) Has(key string) bool {
return o.Get(key).OK
}
// String retrieves a string value, empty string if missing.
//
// name := opts.String("name")
func (o Options) String(key string) string {
r := o.Get(key)
if !r.OK {
return ""
}
s, _ := r.Value.(string)
return s
}
// Int retrieves an int value, 0 if missing.
//
// port := opts.Int("port")
func (o Options) Int(key string) int {
r := o.Get(key)
if !r.OK {
return 0
}
i, _ := r.Value.(int)
return i
}
// Bool retrieves a bool value, false if missing.
//
// debug := opts.Bool("debug")
func (o Options) Bool(key string) bool {
r := o.Get(key)
if !r.OK {
return false
}
b, _ := r.Value.(bool)
return b
}

View file

@ -0,0 +1,149 @@
// SPDX-License-Identifier: EUPL-1.2
// Runtime helpers for the Core framework.
// ServiceRuntime is embedded by consumer services.
// Runtime is the GUI binding container (e.g., Wails).
package core
import (
"context"
"maps"
"slices"
)
// --- ServiceRuntime (embedded by consumer services) ---
// ServiceRuntime is embedded in services to provide access to the Core and typed options.
type ServiceRuntime[T any] struct {
core *Core
opts T
}
// NewServiceRuntime creates a ServiceRuntime for a service constructor.
func NewServiceRuntime[T any](c *Core, opts T) *ServiceRuntime[T] {
return &ServiceRuntime[T]{core: c, opts: opts}
}
func (r *ServiceRuntime[T]) Core() *Core { return r.core }
func (r *ServiceRuntime[T]) Options() T { return r.opts }
func (r *ServiceRuntime[T]) Config() *Config { return r.core.Config() }
// --- Lifecycle ---
// ServiceStartup runs OnStart for all registered services that have one.
func (c *Core) ServiceStartup(ctx context.Context, options any) Result {
c.shutdown.Store(false)
c.context, c.cancel = context.WithCancel(ctx)
startables := c.Startables()
if startables.OK {
for _, s := range startables.Value.([]*Service) {
if err := ctx.Err(); err != nil {
return Result{err, false}
}
r := s.OnStart()
if !r.OK {
return r
}
}
}
c.ACTION(ActionServiceStartup{})
return Result{OK: true}
}
// ServiceShutdown drains background tasks, then stops all registered services.
func (c *Core) ServiceShutdown(ctx context.Context) Result {
c.shutdown.Store(true)
c.cancel() // signal all context-aware tasks to stop
c.ACTION(ActionServiceShutdown{})
// Drain background tasks before stopping services.
done := make(chan struct{})
go func() {
c.waitGroup.Wait()
close(done)
}()
select {
case <-done:
case <-ctx.Done():
return Result{ctx.Err(), false}
}
// Stop services
var firstErr error
stoppables := c.Stoppables()
if stoppables.OK {
for _, s := range stoppables.Value.([]*Service) {
if err := ctx.Err(); err != nil {
return Result{err, false}
}
r := s.OnStop()
if !r.OK && firstErr == nil {
if e, ok := r.Value.(error); ok {
firstErr = e
} else {
firstErr = E("core.ServiceShutdown", Sprint("service OnStop failed: ", r.Value), nil)
}
}
}
}
if firstErr != nil {
return Result{firstErr, false}
}
return Result{OK: true}
}
// --- Runtime DTO (GUI binding) ---
// Runtime is the container for GUI runtimes (e.g., Wails).
type Runtime struct {
app any
Core *Core
}
// ServiceFactory defines a function that creates a Service.
type ServiceFactory func() Result
// NewWithFactories creates a Runtime with the provided service factories.
func NewWithFactories(app any, factories map[string]ServiceFactory) Result {
c := New(Options{{Key: "name", Value: "core"}})
c.app.Runtime = app
names := slices.Sorted(maps.Keys(factories))
for _, name := range names {
factory := factories[name]
if factory == nil {
continue
}
r := factory()
if !r.OK {
cause, _ := r.Value.(error)
return Result{E("core.NewWithFactories", Concat("factory \"", name, "\" failed"), cause), false}
}
svc, ok := r.Value.(Service)
if !ok {
return Result{E("core.NewWithFactories", Concat("factory \"", name, "\" returned non-Service type"), nil), false}
}
sr := c.Service(name, svc)
if !sr.OK {
return sr
}
}
return Result{&Runtime{app: app, Core: c}, true}
}
// NewRuntime creates a Runtime with no custom services.
func NewRuntime(app any) Result {
return NewWithFactories(app, map[string]ServiceFactory{})
}
func (r *Runtime) ServiceName() string { return "Core" }
func (r *Runtime) ServiceStartup(ctx context.Context, options any) Result {
return r.Core.ServiceStartup(ctx, options)
}
func (r *Runtime) ServiceShutdown(ctx context.Context) Result {
if r.Core != nil {
return r.Core.ServiceShutdown(ctx)
}
return Result{OK: true}
}

View file

@ -0,0 +1,83 @@
// SPDX-License-Identifier: EUPL-1.2
// Service registry for the Core framework.
//
// Register a service:
//
// c.Service("auth", core.Service{})
//
// Get a service:
//
// r := c.Service("auth")
// if r.OK { svc := r.Value }
package core
// No imports needed — uses package-level string helpers.
// Service is a managed component with optional lifecycle.
type Service struct {
Name string
Options Options
OnStart func() Result
OnStop func() Result
OnReload func() Result
}
// serviceRegistry holds registered services.
type serviceRegistry struct {
services map[string]*Service
lockEnabled bool
locked bool
}
// --- Core service methods ---
// Service gets or registers a service by name.
//
// c.Service("auth", core.Service{OnStart: startFn})
// r := c.Service("auth")
func (c *Core) Service(name string, service ...Service) Result {
if len(service) == 0 {
c.Lock("srv").Mutex.RLock()
v, ok := c.services.services[name]
c.Lock("srv").Mutex.RUnlock()
return Result{v, ok}
}
if name == "" {
return Result{E("core.Service", "service name cannot be empty", nil), false}
}
c.Lock("srv").Mutex.Lock()
defer c.Lock("srv").Mutex.Unlock()
if c.services.locked {
return Result{E("core.Service", Concat("service \"", name, "\" not permitted — registry locked"), nil), false}
}
if _, exists := c.services.services[name]; exists {
return Result{E("core.Service", Join(" ", "service", name, "already registered"), nil), false}
}
srv := &service[0]
srv.Name = name
c.services.services[name] = srv
return Result{OK: true}
}
// Services returns all registered service names.
//
// names := c.Services()
func (c *Core) Services() []string {
if c.services == nil {
return nil
}
c.Lock("srv").Mutex.RLock()
defer c.Lock("srv").Mutex.RUnlock()
var names []string
for k := range c.services.services {
names = append(names, k)
}
return names
}

View file

@ -0,0 +1,157 @@
// SPDX-License-Identifier: EUPL-1.2
// String operations for the Core framework.
// Provides safe, predictable string helpers that downstream packages
// use directly — same pattern as Array[T] for slices.
package core
import (
"fmt"
"strings"
"unicode/utf8"
)
// HasPrefix returns true if s starts with prefix.
//
// core.HasPrefix("--verbose", "--") // true
func HasPrefix(s, prefix string) bool {
return strings.HasPrefix(s, prefix)
}
// HasSuffix returns true if s ends with suffix.
//
// core.HasSuffix("test.go", ".go") // true
func HasSuffix(s, suffix string) bool {
return strings.HasSuffix(s, suffix)
}
// TrimPrefix removes prefix from s.
//
// core.TrimPrefix("--verbose", "--") // "verbose"
func TrimPrefix(s, prefix string) string {
return strings.TrimPrefix(s, prefix)
}
// TrimSuffix removes suffix from s.
//
// core.TrimSuffix("test.go", ".go") // "test"
func TrimSuffix(s, suffix string) string {
return strings.TrimSuffix(s, suffix)
}
// Contains returns true if s contains substr.
//
// core.Contains("hello world", "world") // true
func Contains(s, substr string) bool {
return strings.Contains(s, substr)
}
// Split splits s by separator.
//
// core.Split("a/b/c", "/") // ["a", "b", "c"]
func Split(s, sep string) []string {
return strings.Split(s, sep)
}
// SplitN splits s by separator into at most n parts.
//
// core.SplitN("key=value=extra", "=", 2) // ["key", "value=extra"]
func SplitN(s, sep string, n int) []string {
return strings.SplitN(s, sep, n)
}
// Join joins parts with a separator, building via Concat.
//
// core.Join("/", "deploy", "to", "homelab") // "deploy/to/homelab"
// core.Join(".", "cmd", "deploy", "description") // "cmd.deploy.description"
func Join(sep string, parts ...string) string {
if len(parts) == 0 {
return ""
}
result := parts[0]
for _, p := range parts[1:] {
result = Concat(result, sep, p)
}
return result
}
// Replace replaces all occurrences of old with new in s.
//
// core.Replace("deploy/to/homelab", "/", ".") // "deploy.to.homelab"
func Replace(s, old, new string) string {
return strings.ReplaceAll(s, old, new)
}
// Lower returns s in lowercase.
//
// core.Lower("HELLO") // "hello"
func Lower(s string) string {
return strings.ToLower(s)
}
// Upper returns s in uppercase.
//
// core.Upper("hello") // "HELLO"
func Upper(s string) string {
return strings.ToUpper(s)
}
// Trim removes leading and trailing whitespace.
//
// core.Trim(" hello ") // "hello"
func Trim(s string) string {
return strings.TrimSpace(s)
}
// RuneCount returns the number of runes (unicode characters) in s.
//
// core.RuneCount("hello") // 5
// core.RuneCount("🔥") // 1
func RuneCount(s string) int {
return utf8.RuneCountInString(s)
}
// NewBuilder returns a new strings.Builder.
//
// b := core.NewBuilder()
// b.WriteString("hello")
// b.String() // "hello"
func NewBuilder() *strings.Builder {
return &strings.Builder{}
}
// NewReader returns a strings.NewReader for the given string.
//
// r := core.NewReader("hello world")
func NewReader(s string) *strings.Reader {
return strings.NewReader(s)
}
// Sprint converts any value to its string representation.
//
// core.Sprint(42) // "42"
// core.Sprint(err) // "connection refused"
func Sprint(args ...any) string {
return fmt.Sprint(args...)
}
// Sprintf formats a string with the given arguments.
//
// core.Sprintf("%v=%q", "key", "value") // `key="value"`
func Sprintf(format string, args ...any) string {
return fmt.Sprintf(format, args...)
}
// Concat joins variadic string parts into one string.
// Hook point for validation, sanitisation, and security checks.
//
// core.Concat("cmd.", "deploy.to.homelab", ".description")
// core.Concat("https://", host, "/api/v1")
func Concat(parts ...string) string {
b := NewBuilder()
for _, p := range parts {
b.WriteString(p)
}
return b.String()
}

View file

@ -0,0 +1,92 @@
// SPDX-License-Identifier: EUPL-1.2
// Background task dispatch for the Core framework.
package core
import (
"reflect"
"slices"
"strconv"
)
// TaskState holds background task state.
type TaskState struct {
Identifier string
Task Task
Result any
Error error
}
// PerformAsync dispatches a task in a background goroutine.
func (c *Core) PerformAsync(t Task) Result {
if c.shutdown.Load() {
return Result{}
}
taskID := Concat("task-", strconv.FormatUint(c.taskIDCounter.Add(1), 10))
if tid, ok := t.(TaskWithIdentifier); ok {
tid.SetTaskIdentifier(taskID)
}
c.ACTION(ActionTaskStarted{TaskIdentifier: taskID, Task: t})
c.waitGroup.Go(func() {
defer func() {
if rec := recover(); rec != nil {
err := E("core.PerformAsync", Sprint("panic: ", rec), nil)
c.ACTION(ActionTaskCompleted{TaskIdentifier: taskID, Task: t, Result: nil, Error: err})
}
}()
r := c.PERFORM(t)
var err error
if !r.OK {
if e, ok := r.Value.(error); ok {
err = e
} else {
taskType := reflect.TypeOf(t)
typeName := "<nil>"
if taskType != nil {
typeName = taskType.String()
}
err = E("core.PerformAsync", Join(" ", "no handler found for task type", typeName), nil)
}
}
c.ACTION(ActionTaskCompleted{TaskIdentifier: taskID, Task: t, Result: r.Value, Error: err})
})
return Result{taskID, true}
}
// Progress broadcasts a progress update for a background task.
func (c *Core) Progress(taskID string, progress float64, message string, t Task) {
c.ACTION(ActionTaskProgress{TaskIdentifier: taskID, Task: t, Progress: progress, Message: message})
}
func (c *Core) Perform(t Task) Result {
c.ipc.taskMu.RLock()
handlers := slices.Clone(c.ipc.taskHandlers)
c.ipc.taskMu.RUnlock()
for _, h := range handlers {
r := h(c, t)
if r.OK {
return r
}
}
return Result{}
}
func (c *Core) RegisterAction(handler func(*Core, Message) Result) {
c.ipc.ipcMu.Lock()
c.ipc.ipcHandlers = append(c.ipc.ipcHandlers, handler)
c.ipc.ipcMu.Unlock()
}
func (c *Core) RegisterActions(handlers ...func(*Core, Message) Result) {
c.ipc.ipcMu.Lock()
c.ipc.ipcHandlers = append(c.ipc.ipcHandlers, handlers...)
c.ipc.ipcMu.Unlock()
}
func (c *Core) RegisterTask(handler TaskHandler) {
c.ipc.taskMu.Lock()
c.ipc.taskHandlers = append(c.ipc.taskHandlers, handler)
c.ipc.taskMu.Unlock()
}

View file

@ -0,0 +1,159 @@
// SPDX-License-Identifier: EUPL-1.2
// Utility functions for the Core framework.
// Built on core string.go primitives.
package core
import (
"fmt"
"io"
"os"
)
// Print writes a formatted line to a writer, defaulting to os.Stdout.
//
// core.Print(nil, "hello %s", "world") // → stdout
// core.Print(w, "port: %d", 8080) // → w
func Print(w io.Writer, format string, args ...any) {
if w == nil {
w = os.Stdout
}
fmt.Fprintf(w, format+"\n", args...)
}
// JoinPath joins string segments into a path with "/" separator.
//
// core.JoinPath("deploy", "to", "homelab") // → "deploy/to/homelab"
func JoinPath(segments ...string) string {
return Join("/", segments...)
}
// IsFlag returns true if the argument starts with a dash.
//
// core.IsFlag("--verbose") // true
// core.IsFlag("-v") // true
// core.IsFlag("deploy") // false
func IsFlag(arg string) bool {
return HasPrefix(arg, "-")
}
// Arg extracts a value from variadic args at the given index.
// Type-checks and delegates to the appropriate typed extractor.
// Returns Result — OK is false if index is out of bounds.
//
// r := core.Arg(0, args...)
// if r.OK { path = r.Value.(string) }
func Arg(index int, args ...any) Result {
if index >= len(args) {
return Result{}
}
v := args[index]
switch v.(type) {
case string:
return Result{ArgString(index, args...), true}
case int:
return Result{ArgInt(index, args...), true}
case bool:
return Result{ArgBool(index, args...), true}
default:
return Result{v, true}
}
}
// ArgString extracts a string at the given index.
//
// name := core.ArgString(0, args...)
func ArgString(index int, args ...any) string {
if index >= len(args) {
return ""
}
s, ok := args[index].(string)
if !ok {
return ""
}
return s
}
// ArgInt extracts an int at the given index.
//
// port := core.ArgInt(1, args...)
func ArgInt(index int, args ...any) int {
if index >= len(args) {
return 0
}
i, ok := args[index].(int)
if !ok {
return 0
}
return i
}
// ArgBool extracts a bool at the given index.
//
// debug := core.ArgBool(2, args...)
func ArgBool(index int, args ...any) bool {
if index >= len(args) {
return false
}
b, ok := args[index].(bool)
if !ok {
return false
}
return b
}
// FilterArgs removes empty strings and Go test runner flags from an argument list.
//
// clean := core.FilterArgs(os.Args[1:])
func FilterArgs(args []string) []string {
var clean []string
for _, a := range args {
if a == "" || HasPrefix(a, "-test.") {
continue
}
clean = append(clean, a)
}
return clean
}
// ParseFlag parses a single flag argument into key, value, and validity.
// Single dash (-) requires exactly 1 character (letter, emoji, unicode).
// Double dash (--) requires 2+ characters.
//
// "-v" → "v", "", true
// "-🔥" → "🔥", "", true
// "--verbose" → "verbose", "", true
// "--port=8080" → "port", "8080", true
// "-verbose" → "", "", false (single dash, 2+ chars)
// "--v" → "", "", false (double dash, 1 char)
// "hello" → "", "", false (not a flag)
func ParseFlag(arg string) (key, value string, valid bool) {
if HasPrefix(arg, "--") {
rest := TrimPrefix(arg, "--")
parts := SplitN(rest, "=", 2)
name := parts[0]
if RuneCount(name) < 2 {
return "", "", false
}
if len(parts) == 2 {
return name, parts[1], true
}
return name, "", true
}
if HasPrefix(arg, "-") {
rest := TrimPrefix(arg, "-")
parts := SplitN(rest, "=", 2)
name := parts[0]
if RuneCount(name) != 1 {
return "", "", false
}
if len(parts) == 2 {
return name, parts[1], true
}
return name, "", true
}
return "", "", false
}