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:
parent
4f66eb4cca
commit
6f5b239da6
23 changed files with 4300 additions and 0 deletions
|
|
@ -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
|
||||
53
pkg/lib/workspace/default/.core/reference/app.go
Normal file
53
pkg/lib/workspace/default/.core/reference/app.go
Normal 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}
|
||||
}
|
||||
101
pkg/lib/workspace/default/.core/reference/array.go
Normal file
101
pkg/lib/workspace/default/.core/reference/array.go
Normal 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
|
||||
}
|
||||
169
pkg/lib/workspace/default/.core/reference/cli.go
Normal file
169
pkg/lib/workspace/default/.core/reference/cli.go
Normal 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 ""
|
||||
}
|
||||
208
pkg/lib/workspace/default/.core/reference/command.go
Normal file
208
pkg/lib/workspace/default/.core/reference/command.go
Normal 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]
|
||||
}
|
||||
135
pkg/lib/workspace/default/.core/reference/config.go
Normal file
135
pkg/lib/workspace/default/.core/reference/config.go
Normal 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
|
||||
}
|
||||
105
pkg/lib/workspace/default/.core/reference/contract.go
Normal file
105
pkg/lib/workspace/default/.core/reference/contract.go
Normal 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
|
||||
}
|
||||
81
pkg/lib/workspace/default/.core/reference/core.go
Normal file
81
pkg/lib/workspace/default/.core/reference/core.go
Normal 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 ---
|
||||
202
pkg/lib/workspace/default/.core/reference/data.go
Normal file
202
pkg/lib/workspace/default/.core/reference/data.go
Normal 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
|
||||
}
|
||||
112
pkg/lib/workspace/default/.core/reference/drive.go
Normal file
112
pkg/lib/workspace/default/.core/reference/drive.go
Normal 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
|
||||
}
|
||||
668
pkg/lib/workspace/default/.core/reference/embed.go
Normal file
668
pkg/lib/workspace/default/.core/reference/embed.go
Normal 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
|
||||
}
|
||||
395
pkg/lib/workspace/default/.core/reference/error.go
Normal file
395
pkg/lib/workspace/default/.core/reference/error.go
Normal 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()))
|
||||
}
|
||||
}
|
||||
287
pkg/lib/workspace/default/.core/reference/fs.go
Normal file
287
pkg/lib/workspace/default/.core/reference/fs.go
Normal 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}
|
||||
}
|
||||
138
pkg/lib/workspace/default/.core/reference/i18n.go
Normal file
138
pkg/lib/workspace/default/.core/reference/i18n.go
Normal 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"}
|
||||
}
|
||||
72
pkg/lib/workspace/default/.core/reference/ipc.go
Normal file
72
pkg/lib/workspace/default/.core/reference/ipc.go
Normal 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()
|
||||
}
|
||||
89
pkg/lib/workspace/default/.core/reference/lock.go
Normal file
89
pkg/lib/workspace/default/.core/reference/lock.go
Normal 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}
|
||||
}
|
||||
402
pkg/lib/workspace/default/.core/reference/log.go
Normal file
402
pkg/lib/workspace/default/.core/reference/log.go
Normal 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),
|
||||
)
|
||||
}
|
||||
140
pkg/lib/workspace/default/.core/reference/options.go
Normal file
140
pkg/lib/workspace/default/.core/reference/options.go
Normal 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
|
||||
}
|
||||
149
pkg/lib/workspace/default/.core/reference/runtime.go
Normal file
149
pkg/lib/workspace/default/.core/reference/runtime.go
Normal 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}
|
||||
}
|
||||
83
pkg/lib/workspace/default/.core/reference/service.go
Normal file
83
pkg/lib/workspace/default/.core/reference/service.go
Normal 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
|
||||
}
|
||||
157
pkg/lib/workspace/default/.core/reference/string.go
Normal file
157
pkg/lib/workspace/default/.core/reference/string.go
Normal 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()
|
||||
}
|
||||
92
pkg/lib/workspace/default/.core/reference/task.go
Normal file
92
pkg/lib/workspace/default/.core/reference/task.go
Normal 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()
|
||||
}
|
||||
159
pkg/lib/workspace/default/.core/reference/utils.go
Normal file
159
pkg/lib/workspace/default/.core/reference/utils.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue