feat(lib): embed Core documentation in workspace template
18 doc files from core/go/docs — getting-started, primitives, services, commands, configuration, errors, lifecycle, messaging, subsystems, testing. Agents can read full Core documentation without network access. Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
5417f547ca
commit
e8ca0d856f
18 changed files with 4782 additions and 0 deletions
177
pkg/lib/workspace/default/.core/reference/docs/commands.md
Normal file
177
pkg/lib/workspace/default/.core/reference/docs/commands.md
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
---
|
||||
title: Commands
|
||||
description: Path-based command registration and CLI execution.
|
||||
---
|
||||
|
||||
# Commands
|
||||
|
||||
Commands are one of the most AX-native parts of CoreGO. The path is the identity.
|
||||
|
||||
## Register a Command
|
||||
|
||||
```go
|
||||
c.Command("deploy/to/homelab", core.Command{
|
||||
Action: func(opts core.Options) core.Result {
|
||||
target := opts.String("target")
|
||||
return core.Result{Value: "deploying to " + target, OK: true}
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Command Paths
|
||||
|
||||
Paths must be clean:
|
||||
|
||||
- no empty path
|
||||
- no leading slash
|
||||
- no trailing slash
|
||||
- no double slash
|
||||
|
||||
These paths are valid:
|
||||
|
||||
```text
|
||||
deploy
|
||||
deploy/to/homelab
|
||||
workspace/create
|
||||
```
|
||||
|
||||
These are rejected:
|
||||
|
||||
```text
|
||||
/deploy
|
||||
deploy/
|
||||
deploy//to
|
||||
```
|
||||
|
||||
## Parent Commands Are Auto-Created
|
||||
|
||||
When you register `deploy/to/homelab`, CoreGO also creates placeholder parents if they do not already exist:
|
||||
|
||||
- `deploy`
|
||||
- `deploy/to`
|
||||
|
||||
This makes the path tree navigable without extra setup.
|
||||
|
||||
## Read a Command Back
|
||||
|
||||
```go
|
||||
r := c.Command("deploy/to/homelab")
|
||||
if r.OK {
|
||||
cmd := r.Value.(*core.Command)
|
||||
_ = cmd
|
||||
}
|
||||
```
|
||||
|
||||
## Run a Command Directly
|
||||
|
||||
```go
|
||||
cmd := c.Command("deploy/to/homelab").Value.(*core.Command)
|
||||
|
||||
r := cmd.Run(core.Options{
|
||||
{Key: "target", Value: "uk-prod"},
|
||||
})
|
||||
```
|
||||
|
||||
If `Action` is nil, `Run` returns `Result{OK:false}` with a structured error.
|
||||
|
||||
## Run Through the CLI Surface
|
||||
|
||||
```go
|
||||
r := c.Cli().Run("deploy", "to", "homelab", "--target=uk-prod", "--debug")
|
||||
```
|
||||
|
||||
`Cli.Run` resolves the longest matching command path from the arguments, then converts the remaining args into `core.Options`.
|
||||
|
||||
## Flag Parsing Rules
|
||||
|
||||
### Double Dash
|
||||
|
||||
```text
|
||||
--target=uk-prod -> key "target", value "uk-prod"
|
||||
--debug -> key "debug", value true
|
||||
```
|
||||
|
||||
### Single Dash
|
||||
|
||||
```text
|
||||
-v -> key "v", value true
|
||||
-n=4 -> key "n", value "4"
|
||||
```
|
||||
|
||||
### Positional Arguments
|
||||
|
||||
Non-flag arguments after the command path are stored as repeated `_arg` options.
|
||||
|
||||
```go
|
||||
r := c.Cli().Run("workspace", "open", "alpha")
|
||||
```
|
||||
|
||||
That produces an option like:
|
||||
|
||||
```go
|
||||
core.Option{Key: "_arg", Value: "alpha"}
|
||||
```
|
||||
|
||||
### Important Details
|
||||
|
||||
- flag values stay as strings
|
||||
- `opts.Int("port")` only works if some code stored an actual `int`
|
||||
- invalid flags such as `-verbose` and `--v` are ignored
|
||||
|
||||
## Help Output
|
||||
|
||||
`Cli.PrintHelp()` prints executable commands:
|
||||
|
||||
```go
|
||||
c.Cli().PrintHelp()
|
||||
```
|
||||
|
||||
It skips:
|
||||
|
||||
- hidden commands
|
||||
- placeholder parents with no `Action` and no `Lifecycle`
|
||||
|
||||
Descriptions are resolved through `cmd.I18nKey()`.
|
||||
|
||||
## I18n Description Keys
|
||||
|
||||
If `Description` is empty, CoreGO derives a key from the path.
|
||||
|
||||
```text
|
||||
deploy -> cmd.deploy.description
|
||||
deploy/to/homelab -> cmd.deploy.to.homelab.description
|
||||
workspace/create -> cmd.workspace.create.description
|
||||
```
|
||||
|
||||
If `Description` is already set, CoreGO uses it as-is.
|
||||
|
||||
## Lifecycle Commands
|
||||
|
||||
Commands can also delegate to a lifecycle implementation.
|
||||
|
||||
```go
|
||||
type daemonCommand struct{}
|
||||
|
||||
func (d *daemonCommand) Start(opts core.Options) core.Result { return core.Result{OK: true} }
|
||||
func (d *daemonCommand) Stop() core.Result { return core.Result{OK: true} }
|
||||
func (d *daemonCommand) Restart() core.Result { return core.Result{OK: true} }
|
||||
func (d *daemonCommand) Reload() core.Result { return core.Result{OK: true} }
|
||||
func (d *daemonCommand) Signal(sig string) core.Result { return core.Result{Value: sig, OK: true} }
|
||||
|
||||
c.Command("agent/serve", core.Command{
|
||||
Lifecycle: &daemonCommand{},
|
||||
})
|
||||
```
|
||||
|
||||
Important behavior:
|
||||
|
||||
- `Start` falls back to `Run` when `Lifecycle` is nil
|
||||
- `Stop`, `Restart`, `Reload`, and `Signal` return an empty `Result` when `Lifecycle` is nil
|
||||
|
||||
## List Command Paths
|
||||
|
||||
```go
|
||||
paths := c.Commands()
|
||||
```
|
||||
|
||||
Like the service registry, the command registry is map-backed, so iteration order is not guaranteed.
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
---
|
||||
title: Configuration
|
||||
description: Constructor options, runtime settings, and feature flags.
|
||||
---
|
||||
|
||||
# Configuration
|
||||
|
||||
CoreGO uses two different configuration layers:
|
||||
|
||||
- constructor-time `core.Options`
|
||||
- runtime `c.Config()`
|
||||
|
||||
## Constructor-Time Options
|
||||
|
||||
```go
|
||||
c := core.New(core.Options{
|
||||
{Key: "name", Value: "agent-workbench"},
|
||||
})
|
||||
```
|
||||
|
||||
### Current Behavior
|
||||
|
||||
- `New` accepts `opts ...Options`
|
||||
- the current implementation copies only the first `Options` slice
|
||||
- the `name` key is applied to `c.App().Name`
|
||||
|
||||
If you need more constructor data, put it in the first `core.Options` slice.
|
||||
|
||||
## Runtime Settings with `Config`
|
||||
|
||||
Use `c.Config()` for mutable process settings.
|
||||
|
||||
```go
|
||||
c.Config().Set("workspace.root", "/srv/workspaces")
|
||||
c.Config().Set("max_agents", 8)
|
||||
c.Config().Set("debug", true)
|
||||
```
|
||||
|
||||
Read them back with:
|
||||
|
||||
```go
|
||||
root := c.Config().String("workspace.root")
|
||||
maxAgents := c.Config().Int("max_agents")
|
||||
debug := c.Config().Bool("debug")
|
||||
raw := c.Config().Get("workspace.root")
|
||||
```
|
||||
|
||||
### Important Details
|
||||
|
||||
- missing keys return zero values
|
||||
- typed accessors do not coerce strings into ints or bools
|
||||
- `Get` returns `core.Result`
|
||||
|
||||
## Feature Flags
|
||||
|
||||
`Config` also tracks named feature flags.
|
||||
|
||||
```go
|
||||
c.Config().Enable("workspace.templates")
|
||||
c.Config().Enable("agent.review")
|
||||
c.Config().Disable("agent.review")
|
||||
```
|
||||
|
||||
Read them with:
|
||||
|
||||
```go
|
||||
enabled := c.Config().Enabled("workspace.templates")
|
||||
features := c.Config().EnabledFeatures()
|
||||
```
|
||||
|
||||
Feature names are case-sensitive.
|
||||
|
||||
## `ConfigVar[T]`
|
||||
|
||||
Use `ConfigVar[T]` when you need a typed value that can also represent “set versus unset”.
|
||||
|
||||
```go
|
||||
theme := core.NewConfigVar("amber")
|
||||
|
||||
if theme.IsSet() {
|
||||
fmt.Println(theme.Get())
|
||||
}
|
||||
|
||||
theme.Unset()
|
||||
```
|
||||
|
||||
This is useful for package-local state where zero values are not enough to describe configuration presence.
|
||||
|
||||
## Recommended Pattern
|
||||
|
||||
Use the two layers for different jobs:
|
||||
|
||||
- put startup identity such as `name` into `core.Options`
|
||||
- put mutable runtime values and feature switches into `c.Config()`
|
||||
|
||||
That keeps constructor intent separate from live process state.
|
||||
120
pkg/lib/workspace/default/.core/reference/docs/errors.md
Normal file
120
pkg/lib/workspace/default/.core/reference/docs/errors.md
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
---
|
||||
title: Errors
|
||||
description: Structured errors, logging helpers, and panic recovery.
|
||||
---
|
||||
|
||||
# Errors
|
||||
|
||||
CoreGO treats failures as structured operational data.
|
||||
|
||||
Repository convention: use `E()` instead of `fmt.Errorf` for framework and service errors.
|
||||
|
||||
## `Err`
|
||||
|
||||
The structured error type is:
|
||||
|
||||
```go
|
||||
type Err struct {
|
||||
Operation string
|
||||
Message string
|
||||
Cause error
|
||||
Code string
|
||||
}
|
||||
```
|
||||
|
||||
## Create Errors
|
||||
|
||||
### `E`
|
||||
|
||||
```go
|
||||
err := core.E("workspace.Load", "failed to read workspace manifest", cause)
|
||||
```
|
||||
|
||||
### `Wrap`
|
||||
|
||||
```go
|
||||
err := core.Wrap(cause, "workspace.Load", "manifest parse failed")
|
||||
```
|
||||
|
||||
### `WrapCode`
|
||||
|
||||
```go
|
||||
err := core.WrapCode(cause, "WORKSPACE_INVALID", "workspace.Load", "manifest parse failed")
|
||||
```
|
||||
|
||||
### `NewCode`
|
||||
|
||||
```go
|
||||
err := core.NewCode("NOT_FOUND", "workspace not found")
|
||||
```
|
||||
|
||||
## Inspect Errors
|
||||
|
||||
```go
|
||||
op := core.Operation(err)
|
||||
code := core.ErrorCode(err)
|
||||
msg := core.ErrorMessage(err)
|
||||
root := core.Root(err)
|
||||
stack := core.StackTrace(err)
|
||||
pretty := core.FormatStackTrace(err)
|
||||
```
|
||||
|
||||
These helpers keep the operational chain visible without extra type assertions.
|
||||
|
||||
## Join and Standard Wrappers
|
||||
|
||||
```go
|
||||
combined := core.ErrorJoin(err1, err2)
|
||||
same := core.Is(combined, err1)
|
||||
```
|
||||
|
||||
`core.As` and `core.NewError` mirror the standard library for convenience.
|
||||
|
||||
## Log-and-Return Helpers
|
||||
|
||||
`Core` exposes two convenience wrappers:
|
||||
|
||||
```go
|
||||
r1 := c.LogError(err, "workspace.Load", "workspace load failed")
|
||||
r2 := c.LogWarn(err, "workspace.Load", "workspace load degraded")
|
||||
```
|
||||
|
||||
These log through the default logger and return `core.Result`.
|
||||
|
||||
You can also use the underlying `ErrorLog` directly:
|
||||
|
||||
```go
|
||||
r := c.Log().Error(err, "workspace.Load", "workspace load failed")
|
||||
```
|
||||
|
||||
`Must` logs and then panics when the error is non-nil:
|
||||
|
||||
```go
|
||||
c.Must(err, "workspace.Load", "workspace load failed")
|
||||
```
|
||||
|
||||
## Panic Recovery
|
||||
|
||||
`ErrorPanic` handles process-safe panic capture.
|
||||
|
||||
```go
|
||||
defer c.Error().Recover()
|
||||
```
|
||||
|
||||
Run background work with recovery:
|
||||
|
||||
```go
|
||||
c.Error().SafeGo(func() {
|
||||
panic("captured")
|
||||
})
|
||||
```
|
||||
|
||||
If `ErrorPanic` has a configured crash file path, it appends JSON crash reports and `Reports(n)` reads them back.
|
||||
|
||||
That crash file path is currently internal state on `ErrorPanic`, not a public constructor option on `Core.New()`.
|
||||
|
||||
## Logging and Error Context
|
||||
|
||||
The logging subsystem automatically extracts `op` and logical stack information from structured errors when those values are present in the key-value list.
|
||||
|
||||
That makes errors created with `E`, `Wrap`, or `WrapCode` much easier to follow in logs.
|
||||
|
|
@ -0,0 +1,208 @@
|
|||
---
|
||||
title: Getting Started
|
||||
description: Build a first CoreGO application with the current API.
|
||||
---
|
||||
|
||||
# Getting Started
|
||||
|
||||
This page shows the shortest path to a useful CoreGO application using the API that exists in this repository today.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
go get dappco.re/go/core
|
||||
```
|
||||
|
||||
## Create a Core
|
||||
|
||||
`New` takes zero or more `core.Options` slices, but the current implementation only reads the first one. In practice, treat the constructor as `core.New(core.Options{...})`.
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import "dappco.re/go/core"
|
||||
|
||||
func main() {
|
||||
c := core.New(core.Options{
|
||||
{Key: "name", Value: "agent-workbench"},
|
||||
})
|
||||
|
||||
_ = c
|
||||
}
|
||||
```
|
||||
|
||||
The `name` option is copied into `c.App().Name`.
|
||||
|
||||
## Register a Service
|
||||
|
||||
Services are registered explicitly with a name and a `core.Service` DTO.
|
||||
|
||||
```go
|
||||
c.Service("audit", core.Service{
|
||||
OnStart: func() core.Result {
|
||||
core.Info("audit service started", "app", c.App().Name)
|
||||
return core.Result{OK: true}
|
||||
},
|
||||
OnStop: func() core.Result {
|
||||
core.Info("audit service stopped", "app", c.App().Name)
|
||||
return core.Result{OK: true}
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
This registry stores `core.Service` values. It is a lifecycle registry, not a typed object container.
|
||||
|
||||
## Register a Query, Task, and Command
|
||||
|
||||
```go
|
||||
type workspaceCountQuery struct{}
|
||||
|
||||
type createWorkspaceTask struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result {
|
||||
switch q.(type) {
|
||||
case workspaceCountQuery:
|
||||
return core.Result{Value: 1, OK: true}
|
||||
}
|
||||
return core.Result{}
|
||||
})
|
||||
|
||||
c.RegisterTask(func(_ *core.Core, t core.Task) core.Result {
|
||||
switch task := t.(type) {
|
||||
case createWorkspaceTask:
|
||||
path := "/tmp/agent-workbench/" + task.Name
|
||||
return core.Result{Value: path, OK: true}
|
||||
}
|
||||
return core.Result{}
|
||||
})
|
||||
|
||||
c.Command("workspace/create", core.Command{
|
||||
Action: func(opts core.Options) core.Result {
|
||||
return c.PERFORM(createWorkspaceTask{
|
||||
Name: opts.String("name"),
|
||||
})
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Start the Runtime
|
||||
|
||||
```go
|
||||
if !c.ServiceStartup(context.Background(), nil).OK {
|
||||
panic("startup failed")
|
||||
}
|
||||
```
|
||||
|
||||
`ServiceStartup` returns `core.Result`, not `error`.
|
||||
|
||||
## Run Through the CLI Surface
|
||||
|
||||
```go
|
||||
r := c.Cli().Run("workspace", "create", "--name=alpha")
|
||||
if r.OK {
|
||||
fmt.Println("created:", r.Value)
|
||||
}
|
||||
```
|
||||
|
||||
For flags with values, the CLI stores the value as a string. `--name=alpha` becomes `opts.String("name") == "alpha"`.
|
||||
|
||||
## Query the System
|
||||
|
||||
```go
|
||||
count := c.QUERY(workspaceCountQuery{})
|
||||
if count.OK {
|
||||
fmt.Println("workspace count:", count.Value)
|
||||
}
|
||||
```
|
||||
|
||||
## Shut Down Cleanly
|
||||
|
||||
```go
|
||||
_ = c.ServiceShutdown(context.Background())
|
||||
```
|
||||
|
||||
Shutdown cancels `c.Context()`, broadcasts `ActionServiceShutdown{}`, waits for background tasks to finish, and then runs service stop hooks.
|
||||
|
||||
## Full Example
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"dappco.re/go/core"
|
||||
)
|
||||
|
||||
type workspaceCountQuery struct{}
|
||||
|
||||
type createWorkspaceTask struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
func main() {
|
||||
c := core.New(core.Options{
|
||||
{Key: "name", Value: "agent-workbench"},
|
||||
})
|
||||
|
||||
c.Config().Set("workspace.root", "/tmp/agent-workbench")
|
||||
c.Config().Enable("workspace.templates")
|
||||
|
||||
c.Service("audit", core.Service{
|
||||
OnStart: func() core.Result {
|
||||
core.Info("service started", "service", "audit")
|
||||
return core.Result{OK: true}
|
||||
},
|
||||
OnStop: func() core.Result {
|
||||
core.Info("service stopped", "service", "audit")
|
||||
return core.Result{OK: true}
|
||||
},
|
||||
})
|
||||
|
||||
c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result {
|
||||
switch q.(type) {
|
||||
case workspaceCountQuery:
|
||||
return core.Result{Value: 1, OK: true}
|
||||
}
|
||||
return core.Result{}
|
||||
})
|
||||
|
||||
c.RegisterTask(func(_ *core.Core, t core.Task) core.Result {
|
||||
switch task := t.(type) {
|
||||
case createWorkspaceTask:
|
||||
path := c.Config().String("workspace.root") + "/" + task.Name
|
||||
return core.Result{Value: path, OK: true}
|
||||
}
|
||||
return core.Result{}
|
||||
})
|
||||
|
||||
c.Command("workspace/create", core.Command{
|
||||
Action: func(opts core.Options) core.Result {
|
||||
return c.PERFORM(createWorkspaceTask{
|
||||
Name: opts.String("name"),
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
if !c.ServiceStartup(context.Background(), nil).OK {
|
||||
panic("startup failed")
|
||||
}
|
||||
|
||||
created := c.Cli().Run("workspace", "create", "--name=alpha")
|
||||
fmt.Println("created:", created.Value)
|
||||
|
||||
count := c.QUERY(workspaceCountQuery{})
|
||||
fmt.Println("workspace count:", count.Value)
|
||||
|
||||
_ = c.ServiceShutdown(context.Background())
|
||||
}
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
- Read [primitives.md](primitives.md) next so the repeated shapes are clear.
|
||||
- Read [commands.md](commands.md) if you are building a CLI-first system.
|
||||
- Read [messaging.md](messaging.md) if services need to collaborate without direct imports.
|
||||
112
pkg/lib/workspace/default/.core/reference/docs/index.md
Normal file
112
pkg/lib/workspace/default/.core/reference/docs/index.md
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
---
|
||||
title: CoreGO
|
||||
description: AX-first documentation for the CoreGO framework.
|
||||
---
|
||||
|
||||
# CoreGO
|
||||
|
||||
CoreGO is the foundation layer for the Core ecosystem. It gives you one container, one command tree, one message bus, and a small set of shared primitives that repeat across the whole framework.
|
||||
|
||||
The current module path is `dappco.re/go/core`.
|
||||
|
||||
## AX View
|
||||
|
||||
CoreGO already follows the main AX ideas from RFC-025:
|
||||
|
||||
- predictable names such as `Core`, `Service`, `Command`, `Options`, `Result`, `Message`
|
||||
- path-shaped command registration such as `deploy/to/homelab`
|
||||
- one repeated input shape (`Options`) and one repeated return shape (`Result`)
|
||||
- comments and examples that show real usage instead of restating the type signature
|
||||
|
||||
## What CoreGO Owns
|
||||
|
||||
| Surface | Purpose |
|
||||
|---------|---------|
|
||||
| `Core` | Central container and access point |
|
||||
| `Service` | Managed lifecycle component |
|
||||
| `Command` | Path-based command tree node |
|
||||
| `ACTION`, `QUERY`, `PERFORM` | Decoupled communication between components |
|
||||
| `Data`, `Drive`, `Fs`, `Config`, `I18n`, `Cli` | Built-in subsystems for common runtime work |
|
||||
| `E`, `Wrap`, `ErrorLog`, `ErrorPanic` | Structured failures and panic recovery |
|
||||
|
||||
## Quick Example
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"dappco.re/go/core"
|
||||
)
|
||||
|
||||
type flushCacheTask struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
func main() {
|
||||
c := core.New(core.Options{
|
||||
{Key: "name", Value: "agent-workbench"},
|
||||
})
|
||||
|
||||
c.Service("cache", core.Service{
|
||||
OnStart: func() core.Result {
|
||||
core.Info("cache ready", "app", c.App().Name)
|
||||
return core.Result{OK: true}
|
||||
},
|
||||
OnStop: func() core.Result {
|
||||
core.Info("cache stopped", "app", c.App().Name)
|
||||
return core.Result{OK: true}
|
||||
},
|
||||
})
|
||||
|
||||
c.RegisterTask(func(_ *core.Core, task core.Task) core.Result {
|
||||
switch task.(type) {
|
||||
case flushCacheTask:
|
||||
return core.Result{Value: "cache flushed", OK: true}
|
||||
}
|
||||
return core.Result{}
|
||||
})
|
||||
|
||||
c.Command("cache/flush", core.Command{
|
||||
Action: func(opts core.Options) core.Result {
|
||||
return c.PERFORM(flushCacheTask{Name: opts.String("name")})
|
||||
},
|
||||
})
|
||||
|
||||
if !c.ServiceStartup(context.Background(), nil).OK {
|
||||
panic("startup failed")
|
||||
}
|
||||
|
||||
r := c.Cli().Run("cache", "flush", "--name=session-store")
|
||||
fmt.Println(r.Value)
|
||||
|
||||
_ = c.ServiceShutdown(context.Background())
|
||||
}
|
||||
```
|
||||
|
||||
## Documentation Paths
|
||||
|
||||
| Path | Covers |
|
||||
|------|--------|
|
||||
| [getting-started.md](getting-started.md) | First runnable CoreGO app |
|
||||
| [primitives.md](primitives.md) | `Options`, `Result`, `Service`, `Message`, `Query`, `Task` |
|
||||
| [services.md](services.md) | Service registry, service locks, runtime helpers |
|
||||
| [commands.md](commands.md) | Path-based commands and CLI execution |
|
||||
| [messaging.md](messaging.md) | `ACTION`, `QUERY`, `QUERYALL`, `PERFORM`, `PerformAsync` |
|
||||
| [lifecycle.md](lifecycle.md) | Startup, shutdown, context, background task draining |
|
||||
| [configuration.md](configuration.md) | Constructor options, config state, feature flags |
|
||||
| [subsystems.md](subsystems.md) | `App`, `Data`, `Drive`, `Fs`, `I18n`, `Cli` |
|
||||
| [errors.md](errors.md) | Structured errors, logging helpers, panic recovery |
|
||||
| [testing.md](testing.md) | Test naming and framework-level testing patterns |
|
||||
| [pkg/core.md](pkg/core.md) | Package-level reference summary |
|
||||
| [pkg/log.md](pkg/log.md) | Logging reference for the root package |
|
||||
| [pkg/PACKAGE_STANDARDS.md](pkg/PACKAGE_STANDARDS.md) | AX package-authoring guidance |
|
||||
|
||||
## Good Reading Order
|
||||
|
||||
1. Start with [getting-started.md](getting-started.md).
|
||||
2. Learn the repeated shapes in [primitives.md](primitives.md).
|
||||
3. Pick the integration path you need next: [services.md](services.md), [commands.md](commands.md), or [messaging.md](messaging.md).
|
||||
4. Use [subsystems.md](subsystems.md), [errors.md](errors.md), and [testing.md](testing.md) as reference pages while building.
|
||||
111
pkg/lib/workspace/default/.core/reference/docs/lifecycle.md
Normal file
111
pkg/lib/workspace/default/.core/reference/docs/lifecycle.md
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
---
|
||||
title: Lifecycle
|
||||
description: Startup, shutdown, context ownership, and background task draining.
|
||||
---
|
||||
|
||||
# Lifecycle
|
||||
|
||||
CoreGO manages lifecycle through `core.Service` callbacks, not through reflection or implicit interfaces.
|
||||
|
||||
## Service Hooks
|
||||
|
||||
```go
|
||||
c.Service("cache", core.Service{
|
||||
OnStart: func() core.Result {
|
||||
return core.Result{OK: true}
|
||||
},
|
||||
OnStop: func() core.Result {
|
||||
return core.Result{OK: true}
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Only services with `OnStart` appear in `Startables()`. Only services with `OnStop` appear in `Stoppables()`.
|
||||
|
||||
## `ServiceStartup`
|
||||
|
||||
```go
|
||||
r := c.ServiceStartup(context.Background(), nil)
|
||||
```
|
||||
|
||||
### What It Does
|
||||
|
||||
1. clears the shutdown flag
|
||||
2. stores a new cancellable context on `c.Context()`
|
||||
3. runs each `OnStart`
|
||||
4. broadcasts `ActionServiceStartup{}`
|
||||
|
||||
### Failure Behavior
|
||||
|
||||
- if the input context is already cancelled, startup returns that error
|
||||
- if any `OnStart` returns `OK:false`, startup stops immediately and returns that result
|
||||
|
||||
## `ServiceShutdown`
|
||||
|
||||
```go
|
||||
r := c.ServiceShutdown(context.Background())
|
||||
```
|
||||
|
||||
### What It Does
|
||||
|
||||
1. sets the shutdown flag
|
||||
2. cancels `c.Context()`
|
||||
3. broadcasts `ActionServiceShutdown{}`
|
||||
4. waits for background tasks created by `PerformAsync`
|
||||
5. runs each `OnStop`
|
||||
|
||||
### Failure Behavior
|
||||
|
||||
- if draining background tasks hits the shutdown context deadline, shutdown returns that context error
|
||||
- when service stop hooks fail, CoreGO returns the first error it sees
|
||||
|
||||
## Ordering
|
||||
|
||||
The current implementation builds `Startables()` and `Stoppables()` by iterating over a map-backed registry.
|
||||
|
||||
That means lifecycle order is not guaranteed today.
|
||||
|
||||
If your application needs strict startup or shutdown ordering, orchestrate it explicitly inside a smaller number of service callbacks instead of relying on registry order.
|
||||
|
||||
## `c.Context()`
|
||||
|
||||
`ServiceStartup` creates the context returned by `c.Context()`.
|
||||
|
||||
Use it for background work that should stop when the application shuts down:
|
||||
|
||||
```go
|
||||
c.Service("watcher", core.Service{
|
||||
OnStart: func() core.Result {
|
||||
go func(ctx context.Context) {
|
||||
<-ctx.Done()
|
||||
}(c.Context())
|
||||
return core.Result{OK: true}
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Built-In Lifecycle Actions
|
||||
|
||||
You can listen for lifecycle state changes through the action bus.
|
||||
|
||||
```go
|
||||
c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result {
|
||||
switch msg.(type) {
|
||||
case core.ActionServiceStartup:
|
||||
core.Info("core startup completed")
|
||||
case core.ActionServiceShutdown:
|
||||
core.Info("core shutdown started")
|
||||
}
|
||||
return core.Result{OK: true}
|
||||
})
|
||||
```
|
||||
|
||||
## Background Task Draining
|
||||
|
||||
`ServiceShutdown` waits for the internal task waitgroup to finish before calling stop hooks.
|
||||
|
||||
This is what makes `PerformAsync` safe for long-running work that should complete before teardown.
|
||||
|
||||
## `OnReload`
|
||||
|
||||
`Service` includes an `OnReload` callback field, but CoreGO does not currently expose a top-level lifecycle runner for reload operations.
|
||||
171
pkg/lib/workspace/default/.core/reference/docs/messaging.md
Normal file
171
pkg/lib/workspace/default/.core/reference/docs/messaging.md
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
---
|
||||
title: Messaging
|
||||
description: ACTION, QUERY, QUERYALL, PERFORM, and async task flow.
|
||||
---
|
||||
|
||||
# Messaging
|
||||
|
||||
CoreGO uses one message bus for broadcasts, lookups, and work dispatch.
|
||||
|
||||
## Message Types
|
||||
|
||||
```go
|
||||
type Message any
|
||||
type Query any
|
||||
type Task any
|
||||
```
|
||||
|
||||
Your own structs define the protocol.
|
||||
|
||||
```go
|
||||
type repositoryIndexed struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
type repositoryCountQuery struct{}
|
||||
|
||||
type syncRepositoryTask struct {
|
||||
Name string
|
||||
}
|
||||
```
|
||||
|
||||
## `ACTION`
|
||||
|
||||
`ACTION` is a broadcast.
|
||||
|
||||
```go
|
||||
c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result {
|
||||
switch m := msg.(type) {
|
||||
case repositoryIndexed:
|
||||
core.Info("repository indexed", "name", m.Name)
|
||||
return core.Result{OK: true}
|
||||
}
|
||||
return core.Result{OK: true}
|
||||
})
|
||||
|
||||
r := c.ACTION(repositoryIndexed{Name: "core-go"})
|
||||
```
|
||||
|
||||
### Behavior
|
||||
|
||||
- all registered action handlers are called in their current registration order
|
||||
- if a handler returns `OK:false`, dispatch stops and that `Result` is returned
|
||||
- if no handler fails, `ACTION` returns `Result{OK:true}`
|
||||
|
||||
## `QUERY`
|
||||
|
||||
`QUERY` is first-match request-response.
|
||||
|
||||
```go
|
||||
c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result {
|
||||
switch q.(type) {
|
||||
case repositoryCountQuery:
|
||||
return core.Result{Value: 42, OK: true}
|
||||
}
|
||||
return core.Result{}
|
||||
})
|
||||
|
||||
r := c.QUERY(repositoryCountQuery{})
|
||||
```
|
||||
|
||||
### Behavior
|
||||
|
||||
- handlers run until one returns `OK:true`
|
||||
- the first successful result wins
|
||||
- if nothing handles the query, CoreGO returns an empty `Result`
|
||||
|
||||
## `QUERYALL`
|
||||
|
||||
`QUERYALL` collects every successful non-nil response.
|
||||
|
||||
```go
|
||||
r := c.QUERYALL(repositoryCountQuery{})
|
||||
results := r.Value.([]any)
|
||||
```
|
||||
|
||||
### Behavior
|
||||
|
||||
- every query handler is called
|
||||
- only `OK:true` results with non-nil `Value` are collected
|
||||
- the call itself returns `OK:true` even when the result list is empty
|
||||
|
||||
## `PERFORM`
|
||||
|
||||
`PERFORM` dispatches a task to the first handler that accepts it.
|
||||
|
||||
```go
|
||||
c.RegisterTask(func(_ *core.Core, t core.Task) core.Result {
|
||||
switch task := t.(type) {
|
||||
case syncRepositoryTask:
|
||||
return core.Result{Value: "synced " + task.Name, OK: true}
|
||||
}
|
||||
return core.Result{}
|
||||
})
|
||||
|
||||
r := c.PERFORM(syncRepositoryTask{Name: "core-go"})
|
||||
```
|
||||
|
||||
### Behavior
|
||||
|
||||
- handlers run until one returns `OK:true`
|
||||
- the first successful result wins
|
||||
- if nothing handles the task, CoreGO returns an empty `Result`
|
||||
|
||||
## `PerformAsync`
|
||||
|
||||
`PerformAsync` runs a task in a background goroutine and returns a generated task identifier.
|
||||
|
||||
```go
|
||||
r := c.PerformAsync(syncRepositoryTask{Name: "core-go"})
|
||||
taskID := r.Value.(string)
|
||||
```
|
||||
|
||||
### Generated Events
|
||||
|
||||
Async execution emits three action messages:
|
||||
|
||||
| Message | When |
|
||||
|---------|------|
|
||||
| `ActionTaskStarted` | just before background execution begins |
|
||||
| `ActionTaskProgress` | whenever `Progress` is called |
|
||||
| `ActionTaskCompleted` | after the task finishes or panics |
|
||||
|
||||
Example listener:
|
||||
|
||||
```go
|
||||
c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result {
|
||||
switch m := msg.(type) {
|
||||
case core.ActionTaskCompleted:
|
||||
core.Info("task completed", "task", m.TaskIdentifier, "err", m.Error)
|
||||
}
|
||||
return core.Result{OK: true}
|
||||
})
|
||||
```
|
||||
|
||||
## Progress Updates
|
||||
|
||||
```go
|
||||
c.Progress(taskID, 0.5, "indexing commits", syncRepositoryTask{Name: "core-go"})
|
||||
```
|
||||
|
||||
That broadcasts `ActionTaskProgress`.
|
||||
|
||||
## `TaskWithIdentifier`
|
||||
|
||||
Tasks that implement `TaskWithIdentifier` receive the generated ID before dispatch.
|
||||
|
||||
```go
|
||||
type trackedTask struct {
|
||||
ID string
|
||||
Name string
|
||||
}
|
||||
|
||||
func (t *trackedTask) SetTaskIdentifier(id string) { t.ID = id }
|
||||
func (t *trackedTask) GetTaskIdentifier() string { return t.ID }
|
||||
```
|
||||
|
||||
## Shutdown Interaction
|
||||
|
||||
When shutdown has started, `PerformAsync` returns an empty `Result` instead of scheduling more work.
|
||||
|
||||
This is why `ServiceShutdown` can safely drain the outstanding background tasks before stopping services.
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
# AX Package Standards
|
||||
|
||||
This page describes how to build packages on top of CoreGO in the style described by RFC-025.
|
||||
|
||||
## 1. Prefer Predictable Names
|
||||
|
||||
Use names that tell an agent what the thing is without translation.
|
||||
|
||||
Good:
|
||||
|
||||
- `RepositoryService`
|
||||
- `RepositoryServiceOptions`
|
||||
- `WorkspaceCountQuery`
|
||||
- `SyncRepositoryTask`
|
||||
|
||||
Avoid shortening names unless the abbreviation is already universal.
|
||||
|
||||
## 2. Put Real Usage in Comments
|
||||
|
||||
Write comments that show a real call with realistic values.
|
||||
|
||||
Good:
|
||||
|
||||
```go
|
||||
// Sync a repository into the local workspace cache.
|
||||
// svc.SyncRepository("core-go", "/srv/repos/core-go")
|
||||
```
|
||||
|
||||
Avoid comments that only repeat the signature.
|
||||
|
||||
## 3. Keep Paths Semantic
|
||||
|
||||
If a command or template lives at a path, let the path explain the intent.
|
||||
|
||||
Good:
|
||||
|
||||
```text
|
||||
deploy/to/homelab
|
||||
workspace/create
|
||||
template/workspace/go
|
||||
```
|
||||
|
||||
That keeps the CLI, tests, docs, and message vocabulary aligned.
|
||||
|
||||
## 4. Reuse CoreGO Primitives
|
||||
|
||||
At Core boundaries, prefer the shared shapes:
|
||||
|
||||
- `core.Options` for lightweight input
|
||||
- `core.Result` for output
|
||||
- `core.Service` for lifecycle registration
|
||||
- `core.Message`, `core.Query`, `core.Task` for bus protocols
|
||||
|
||||
Inside your package, typed structs are still good. Use `ServiceRuntime[T]` when you want typed package options plus a `Core` reference.
|
||||
|
||||
```go
|
||||
type repositoryServiceOptions struct {
|
||||
BaseDirectory string
|
||||
}
|
||||
|
||||
type repositoryService struct {
|
||||
*core.ServiceRuntime[repositoryServiceOptions]
|
||||
}
|
||||
```
|
||||
|
||||
## 5. Prefer Explicit Registration
|
||||
|
||||
Register services and commands with names and paths that stay readable in grep results.
|
||||
|
||||
```go
|
||||
c.Service("repository", core.Service{...})
|
||||
c.Command("repository/sync", core.Command{...})
|
||||
```
|
||||
|
||||
## 6. Use the Bus for Decoupling
|
||||
|
||||
When one package needs another package’s behavior, prefer queries and tasks over tight package coupling.
|
||||
|
||||
```go
|
||||
type repositoryCountQuery struct{}
|
||||
type syncRepositoryTask struct {
|
||||
Name string
|
||||
}
|
||||
```
|
||||
|
||||
That keeps the protocol visible in code and easy for agents to follow.
|
||||
|
||||
## 7. Use Structured Errors
|
||||
|
||||
Use `core.E`, `core.Wrap`, and `core.WrapCode`.
|
||||
|
||||
```go
|
||||
return core.Result{
|
||||
Value: core.E("repository.Sync", "git fetch failed", err),
|
||||
OK: false,
|
||||
}
|
||||
```
|
||||
|
||||
Do not introduce free-form `fmt.Errorf` chains in framework code.
|
||||
|
||||
## 8. Keep Testing Names Predictable
|
||||
|
||||
Follow the repository pattern:
|
||||
|
||||
- `_Good`
|
||||
- `_Bad`
|
||||
- `_Ugly`
|
||||
|
||||
Example:
|
||||
|
||||
```go
|
||||
func TestRepositorySync_Good(t *testing.T) {}
|
||||
func TestRepositorySync_Bad(t *testing.T) {}
|
||||
func TestRepositorySync_Ugly(t *testing.T) {}
|
||||
```
|
||||
|
||||
## 9. Prefer Stable Shapes Over Clever APIs
|
||||
|
||||
For package APIs, avoid patterns that force an agent to infer too much hidden control flow.
|
||||
|
||||
Prefer:
|
||||
|
||||
- clear structs
|
||||
- explicit names
|
||||
- path-based commands
|
||||
- visible message types
|
||||
|
||||
Avoid:
|
||||
|
||||
- implicit global state unless it is truly a default service
|
||||
- panic-hiding constructors
|
||||
- dense option chains when a small explicit struct would do
|
||||
|
||||
## 10. Document the Current Reality
|
||||
|
||||
If the implementation is in transition, document what the code does now, not the API shape you plan to have later.
|
||||
|
||||
That keeps agents correct on first pass, which is the real AX metric.
|
||||
81
pkg/lib/workspace/default/.core/reference/docs/pkg/core.md
Normal file
81
pkg/lib/workspace/default/.core/reference/docs/pkg/core.md
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
# Package Reference: `core`
|
||||
|
||||
Import path:
|
||||
|
||||
```go
|
||||
import "dappco.re/go/core"
|
||||
```
|
||||
|
||||
This repository exposes one root package. The main areas are:
|
||||
|
||||
## Constructors and Accessors
|
||||
|
||||
| Name | Purpose |
|
||||
|------|---------|
|
||||
| `New` | Create a `*Core` |
|
||||
| `NewRuntime` | Create an empty runtime wrapper |
|
||||
| `NewWithFactories` | Create a runtime wrapper from named service factories |
|
||||
| `Options`, `App`, `Data`, `Drive`, `Fs`, `Config`, `Error`, `Log`, `Cli`, `IPC`, `I18n`, `Context` | Access the built-in subsystems |
|
||||
|
||||
## Core Primitives
|
||||
|
||||
| Name | Purpose |
|
||||
|------|---------|
|
||||
| `Option`, `Options` | Input configuration and metadata |
|
||||
| `Result` | Shared output shape |
|
||||
| `Service` | Lifecycle DTO |
|
||||
| `Command` | Command tree node |
|
||||
| `Message`, `Query`, `Task` | Message bus payload types |
|
||||
|
||||
## Service and Runtime APIs
|
||||
|
||||
| Name | Purpose |
|
||||
|------|---------|
|
||||
| `Service` | Register or read a named service |
|
||||
| `Services` | List registered service names |
|
||||
| `Startables`, `Stoppables` | Snapshot lifecycle-capable services |
|
||||
| `LockEnable`, `LockApply` | Activate the service registry lock |
|
||||
| `ServiceRuntime[T]` | Helper for package authors |
|
||||
|
||||
## Command and CLI APIs
|
||||
|
||||
| Name | Purpose |
|
||||
|------|---------|
|
||||
| `Command` | Register or read a command by path |
|
||||
| `Commands` | List command paths |
|
||||
| `Cli().Run` | Resolve arguments to a command and execute it |
|
||||
| `Cli().PrintHelp` | Show executable commands |
|
||||
|
||||
## Messaging APIs
|
||||
|
||||
| Name | Purpose |
|
||||
|------|---------|
|
||||
| `ACTION`, `Action` | Broadcast a message |
|
||||
| `QUERY`, `Query` | Return the first successful query result |
|
||||
| `QUERYALL`, `QueryAll` | Collect all successful query results |
|
||||
| `PERFORM`, `Perform` | Run the first task handler that accepts the task |
|
||||
| `PerformAsync` | Run a task in the background |
|
||||
| `Progress` | Broadcast async task progress |
|
||||
| `RegisterAction`, `RegisterActions`, `RegisterQuery`, `RegisterTask` | Register bus handlers |
|
||||
|
||||
## Subsystems
|
||||
|
||||
| Name | Purpose |
|
||||
|------|---------|
|
||||
| `Config` | Runtime settings and feature flags |
|
||||
| `Data` | Embedded filesystem mounts |
|
||||
| `Drive` | Named transport handles |
|
||||
| `Fs` | Local filesystem operations |
|
||||
| `I18n` | Locale collection and translation delegation |
|
||||
| `App`, `Find` | Application identity and executable lookup |
|
||||
|
||||
## Errors and Logging
|
||||
|
||||
| Name | Purpose |
|
||||
|------|---------|
|
||||
| `E`, `Wrap`, `WrapCode`, `NewCode` | Structured error creation |
|
||||
| `Operation`, `ErrorCode`, `ErrorMessage`, `Root`, `StackTrace`, `FormatStackTrace` | Error inspection |
|
||||
| `NewLog`, `Default`, `SetDefault`, `SetLevel`, `SetRedactKeys` | Logger creation and defaults |
|
||||
| `LogErr`, `LogPanic`, `ErrorLog`, `ErrorPanic` | Error-aware logging and panic recovery |
|
||||
|
||||
Use the top-level docs in `docs/` for task-oriented guidance, then use this page as a compact reference.
|
||||
83
pkg/lib/workspace/default/.core/reference/docs/pkg/log.md
Normal file
83
pkg/lib/workspace/default/.core/reference/docs/pkg/log.md
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
# Logging Reference
|
||||
|
||||
Logging lives in the root `core` package in this repository. There is no separate `pkg/log` import path here.
|
||||
|
||||
## Create a Logger
|
||||
|
||||
```go
|
||||
logger := core.NewLog(core.LogOptions{
|
||||
Level: core.LevelInfo,
|
||||
})
|
||||
```
|
||||
|
||||
## Levels
|
||||
|
||||
| Level | Meaning |
|
||||
|-------|---------|
|
||||
| `LevelQuiet` | no output |
|
||||
| `LevelError` | errors and security events |
|
||||
| `LevelWarn` | warnings, errors, security events |
|
||||
| `LevelInfo` | informational, warnings, errors, security events |
|
||||
| `LevelDebug` | everything |
|
||||
|
||||
## Log Methods
|
||||
|
||||
```go
|
||||
logger.Debug("workspace discovered", "path", "/srv/workspaces")
|
||||
logger.Info("service started", "service", "audit")
|
||||
logger.Warn("retrying fetch", "attempt", 2)
|
||||
logger.Error("fetch failed", "err", err)
|
||||
logger.Security("sandbox escape detected", "path", attemptedPath)
|
||||
```
|
||||
|
||||
## Default Logger
|
||||
|
||||
The package owns a default logger.
|
||||
|
||||
```go
|
||||
core.SetLevel(core.LevelDebug)
|
||||
core.SetRedactKeys("token", "password")
|
||||
|
||||
core.Info("service started", "service", "audit")
|
||||
```
|
||||
|
||||
## Redaction
|
||||
|
||||
Values for keys listed in `RedactKeys` are replaced with `[REDACTED]`.
|
||||
|
||||
```go
|
||||
logger.SetRedactKeys("token")
|
||||
logger.Info("login", "user", "cladius", "token", "secret-value")
|
||||
```
|
||||
|
||||
## Output and Rotation
|
||||
|
||||
```go
|
||||
logger := core.NewLog(core.LogOptions{
|
||||
Level: core.LevelInfo,
|
||||
Output: os.Stderr,
|
||||
})
|
||||
```
|
||||
|
||||
If you provide `Rotation` and set `RotationWriterFactory`, the logger writes to the rotating writer instead of the plain output stream.
|
||||
|
||||
## Error-Aware Logging
|
||||
|
||||
`LogErr` extracts structured error context before logging:
|
||||
|
||||
```go
|
||||
le := core.NewLogErr(logger)
|
||||
le.Log(err)
|
||||
```
|
||||
|
||||
`ErrorLog` is the log-and-return wrapper exposed through `c.Log()`.
|
||||
|
||||
## Panic-Aware Logging
|
||||
|
||||
`LogPanic` is the lightweight panic logger:
|
||||
|
||||
```go
|
||||
defer core.NewLogPanic(logger).Recover()
|
||||
```
|
||||
|
||||
It logs the recovered panic but does not manage crash files. For crash reports, use `c.Error().Recover()`.
|
||||
|
|
@ -0,0 +1,261 @@
|
|||
# Lint Pattern Catalog & Polish Skill Design
|
||||
|
||||
> **Partial implementation (14 Mar 2026):** Layer 1 (`core/lint` -- catalog, matcher, scanner, CLI) is fully implemented and documented at `docs/tools/lint/index.md`. Layer 2 (MCP subsystem in `go-ai`) and Layer 3 (Claude Code polish skill in `core/agent`) are NOT implemented. This plan is retained for those remaining layers.
|
||||
|
||||
**Goal:** A structured pattern catalog (`core/lint`) that captures recurring code quality findings as regex rules, exposes them via MCP tools in `go-ai`, and orchestrates multi-AI code review via a Claude Code skill in `core/agent`.
|
||||
|
||||
**Architecture:** Three layers — a standalone catalog+matcher library (`core/lint`), an MCP subsystem in `go-ai` that exposes lint tools to agents, and a Claude Code plugin in `core/agent` that orchestrates the "polish" workflow (deterministic checks + AI reviewers + feedback loop into the catalog).
|
||||
|
||||
**Tech Stack:** Go (catalog, matcher, CLI, MCP subsystem), YAML (rule definitions), JSONL (findings output, compatible with `~/.core/ai/metrics/`), Claude Code plugin format (hooks.json, commands/*.md, plugin.json).
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
During a code review sweep of 18 Go repos (March 2026), AI reviewers (Gemini, Claude) found ~20 recurring patterns: SQL injection, path traversal, XSS, missing constant-time comparison, goroutine leaks, Go 1.26 modernisation opportunities, and more. Many of these patterns repeat across repos.
|
||||
|
||||
Currently these findings exist only as commit messages. This design captures them as a reusable, machine-readable catalog that:
|
||||
1. Deterministic tools can run immediately (regex matching)
|
||||
2. MCP-connected agents can query and apply
|
||||
3. LEM models can train on for "does this comply with CoreGo standards?" judgements
|
||||
4. Grows automatically as AI reviewers find new patterns
|
||||
|
||||
## Layer 1: `core/lint` — Pattern Catalog & Matcher
|
||||
|
||||
### Repository Structure
|
||||
|
||||
```
|
||||
core/lint/
|
||||
├── go.mod # forge.lthn.ai/core/lint
|
||||
├── catalog/
|
||||
│ ├── go-security.yaml # SQL injection, path traversal, XSS, constant-time
|
||||
│ ├── go-modernise.yaml # Go 1.26: slices.Clone, wg.Go, maps.Keys, range-over-int
|
||||
│ ├── go-correctness.yaml # Deadlocks, goroutine leaks, nil guards, error handling
|
||||
│ ├── php-security.yaml # XSS, CSRF, mass assignment, SQL injection
|
||||
│ ├── ts-security.yaml # DOM XSS, prototype pollution
|
||||
│ └── cpp-safety.yaml # Buffer overflow, use-after-free
|
||||
├── pkg/lint/
|
||||
│ ├── catalog.go # Load + parse YAML catalog files
|
||||
│ ├── rule.go # Rule struct definition
|
||||
│ ├── matcher.go # Regex matcher against file contents
|
||||
│ ├── report.go # Structured findings output (JSON/JSONL/text)
|
||||
│ ├── catalog_test.go
|
||||
│ ├── matcher_test.go
|
||||
│ └── report_test.go
|
||||
├── cmd/core-lint/
|
||||
│ └── main.go # `core-lint check ./...` CLI
|
||||
└── .core/
|
||||
└── build.yaml # Produces core-lint binary
|
||||
```
|
||||
|
||||
### Rule Schema (YAML)
|
||||
|
||||
```yaml
|
||||
- id: go-sec-001
|
||||
title: "SQL wildcard injection in LIKE clauses"
|
||||
severity: high # critical, high, medium, low, info
|
||||
languages: [go]
|
||||
tags: [security, injection, owasp-a03]
|
||||
pattern: 'LIKE\s+\?\s*,\s*["\x60]%\s*\+'
|
||||
exclude_pattern: 'EscapeLike' # suppress if this also matches
|
||||
fix: "Use parameterised LIKE with explicit escaping of % and _ characters"
|
||||
found_in: [go-store] # repos where first discovered
|
||||
example_bad: |
|
||||
db.Where("name LIKE ?", "%"+input+"%")
|
||||
example_good: |
|
||||
db.Where("name LIKE ?", EscapeLike(input))
|
||||
first_seen: "2026-03-09"
|
||||
detection: regex # future: ast, semantic
|
||||
auto_fixable: false # future: true when we add codemods
|
||||
```
|
||||
|
||||
### Rule Struct (Go)
|
||||
|
||||
```go
|
||||
type Rule struct {
|
||||
ID string `yaml:"id"`
|
||||
Title string `yaml:"title"`
|
||||
Severity string `yaml:"severity"`
|
||||
Languages []string `yaml:"languages"`
|
||||
Tags []string `yaml:"tags"`
|
||||
Pattern string `yaml:"pattern"`
|
||||
ExcludePattern string `yaml:"exclude_pattern,omitempty"`
|
||||
Fix string `yaml:"fix"`
|
||||
FoundIn []string `yaml:"found_in,omitempty"`
|
||||
ExampleBad string `yaml:"example_bad,omitempty"`
|
||||
ExampleGood string `yaml:"example_good,omitempty"`
|
||||
FirstSeen string `yaml:"first_seen"`
|
||||
Detection string `yaml:"detection"` // regex | ast | semantic
|
||||
AutoFixable bool `yaml:"auto_fixable"`
|
||||
}
|
||||
```
|
||||
|
||||
### Finding Struct (Go)
|
||||
|
||||
Designed to align with go-ai's `ScanAlert` shape and `~/.core/ai/metrics/` JSONL format:
|
||||
|
||||
```go
|
||||
type Finding struct {
|
||||
RuleID string `json:"rule_id"`
|
||||
Title string `json:"title"`
|
||||
Severity string `json:"severity"`
|
||||
File string `json:"file"`
|
||||
Line int `json:"line"`
|
||||
Match string `json:"match"` // matched text
|
||||
Fix string `json:"fix"`
|
||||
Repo string `json:"repo,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
### CLI Interface
|
||||
|
||||
```bash
|
||||
# Check current directory against all catalogs for detected languages
|
||||
core-lint check ./...
|
||||
|
||||
# Check specific languages/catalogs
|
||||
core-lint check --lang go --catalog go-security ./pkg/...
|
||||
|
||||
# Output as JSON (for piping to other tools)
|
||||
core-lint check --format json ./...
|
||||
|
||||
# List available rules
|
||||
core-lint catalog list
|
||||
core-lint catalog list --lang go --severity high
|
||||
|
||||
# Show a specific rule with examples
|
||||
core-lint catalog show go-sec-001
|
||||
```
|
||||
|
||||
## Layer 2: `go-ai` Lint MCP Subsystem
|
||||
|
||||
New subsystem registered alongside files/rag/ml/brain:
|
||||
|
||||
```go
|
||||
type LintSubsystem struct {
|
||||
catalog *lint.Catalog
|
||||
root string // workspace root for scanning
|
||||
}
|
||||
|
||||
func (s *LintSubsystem) Name() string { return "lint" }
|
||||
|
||||
func (s *LintSubsystem) RegisterTools(server *mcp.Server) {
|
||||
// lint_check - run rules against workspace files
|
||||
// lint_catalog - list/search available rules
|
||||
// lint_report - get findings summary for a path
|
||||
}
|
||||
```
|
||||
|
||||
### MCP Tools
|
||||
|
||||
| Tool | Input | Output | Group |
|
||||
|------|-------|--------|-------|
|
||||
| `lint_check` | `{path: string, lang?: string, severity?: string}` | `{findings: []Finding}` | lint |
|
||||
| `lint_catalog` | `{lang?: string, tags?: []string, severity?: string}` | `{rules: []Rule}` | lint |
|
||||
| `lint_report` | `{path: string, format?: "summary" or "detailed"}` | `{summary: ReportSummary}` | lint |
|
||||
|
||||
This means any MCP-connected agent (Claude, LEM, Codex) can call `lint_check` to scan code against the catalog.
|
||||
|
||||
## Layer 3: `core/agent` Polish Skill
|
||||
|
||||
Claude Code plugin at `core/agent/claude/polish/`:
|
||||
|
||||
```
|
||||
core/agent/claude/polish/
|
||||
├── plugin.json
|
||||
├── hooks.json # optional: PostToolUse after git commit
|
||||
├── commands/
|
||||
│ └── polish.md # /polish slash command
|
||||
└── scripts/
|
||||
└── run-lint.sh # shells out to core-lint
|
||||
```
|
||||
|
||||
### `/polish` Command Flow
|
||||
|
||||
1. Run `core-lint check ./...` for fast deterministic findings
|
||||
2. Report findings to user
|
||||
3. Optionally run AI reviewers (Gemini CLI, Codex) for deeper analysis
|
||||
4. Deduplicate AI findings against catalog (already-known patterns)
|
||||
5. Propose new patterns as catalog additions (PR to core/lint)
|
||||
|
||||
### Subagent Configuration (`.core/agents/`)
|
||||
|
||||
Repos can configure polish behaviour:
|
||||
|
||||
```yaml
|
||||
# any-repo/.core/agents/polish.yaml
|
||||
languages: [go]
|
||||
catalogs: [go-security, go-modernise, go-correctness]
|
||||
reviewers: [gemini] # which AI tools to invoke
|
||||
exclude: [vendor/, testdata/, *_test.go]
|
||||
severity_threshold: medium # only report medium+ findings
|
||||
```
|
||||
|
||||
## Findings to LEM Pipeline
|
||||
|
||||
```
|
||||
core-lint check -> findings.json
|
||||
|
|
||||
v
|
||||
~/.core/ai/metrics/YYYY-MM-DD.jsonl (audit trail)
|
||||
|
|
||||
v
|
||||
LEM training data:
|
||||
- Rule examples (bad/good pairs) -> supervised training signal
|
||||
- Finding frequency -> pattern importance weighting
|
||||
- Rule descriptions -> natural language understanding of "why"
|
||||
|
|
||||
v
|
||||
LEM tool: "does this code comply with CoreGo standards?"
|
||||
-> queries lint_catalog via MCP
|
||||
-> applies learned pattern recognition
|
||||
-> reports violations with rule IDs and fixes
|
||||
```
|
||||
|
||||
## Initial Catalog Seed
|
||||
|
||||
From the March 2026 ecosystem sweep:
|
||||
|
||||
| ID | Title | Severity | Language | Found In |
|
||||
|----|-------|----------|----------|----------|
|
||||
| go-sec-001 | SQL wildcard injection | high | go | go-store |
|
||||
| go-sec-002 | Path traversal in cache keys | high | go | go-cache |
|
||||
| go-sec-003 | XSS in HTML output | high | go | go-html |
|
||||
| go-sec-004 | Non-constant-time auth comparison | high | go | go-crypt |
|
||||
| go-sec-005 | Log injection via unescaped input | medium | go | go-log |
|
||||
| go-sec-006 | Key material in log output | high | go | go-log |
|
||||
| go-cor-001 | Goroutine leak (no WaitGroup) | high | go | core/go |
|
||||
| go-cor-002 | Shutdown deadlock (wg.Wait no timeout) | high | go | core/go |
|
||||
| go-cor-003 | Silent error swallowing | medium | go | go-process, go-ratelimit |
|
||||
| go-cor-004 | Panic in library code | medium | go | go-i18n |
|
||||
| go-cor-005 | Delete without path validation | high | go | go-io |
|
||||
| go-mod-001 | Manual slice clone (append nil pattern) | low | go | core/go |
|
||||
| go-mod-002 | Manual sort instead of slices.Sorted | low | go | core/go |
|
||||
| go-mod-003 | Manual reverse loop instead of slices.Backward | low | go | core/go |
|
||||
| go-mod-004 | sync.WaitGroup Add+Done instead of Go() | low | go | core/go |
|
||||
| go-mod-005 | Manual map key collection instead of maps.Keys | low | go | core/go |
|
||||
| go-cor-006 | Missing error return from API calls | medium | go | go-forge, go-git |
|
||||
| go-cor-007 | Signal handler uses wrong type | medium | go | go-process |
|
||||
|
||||
## Dependencies
|
||||
|
||||
```
|
||||
core/lint (standalone, zero core deps)
|
||||
^
|
||||
|
|
||||
go-ai/mcp/lint/ (imports core/lint for catalog + matcher)
|
||||
^
|
||||
|
|
||||
core/agent/claude/polish/ (shells out to core-lint CLI)
|
||||
```
|
||||
|
||||
`core/lint` has no dependency on `core/go` or any other framework module. It is a standalone library + CLI, like `core/go-io`.
|
||||
|
||||
## Future Extensions (Not Built Now)
|
||||
|
||||
- **AST-based detection** (layer 2): Parse Go/PHP AST, match structural patterns
|
||||
- **Semantic detection** (layer 3): LEM judges code against rule descriptions
|
||||
- **Auto-fix codemods**: `core-lint fix` applies known fixes automatically
|
||||
- **CI integration**: GitHub Actions workflow runs `core-lint check` on PRs
|
||||
- **CodeRabbit integration**: Import CodeRabbit findings as catalog entries
|
||||
- **Cross-repo dashboard**: Aggregate findings across all repos in workspace
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,160 @@
|
|||
# AltumCode Update Checker — Design
|
||||
|
||||
> **Note:** Layer 1 (version detection via PHP artisan) is implemented and documented at `docs/docs/php/packages/uptelligence.md`. Layer 2 (browser-automated downloads via Claude Code skill) is NOT yet implemented.
|
||||
|
||||
## Problem
|
||||
|
||||
Host UK runs 4 AltumCode SaaS products and 13 plugins across two marketplaces (CodeCanyon + LemonSqueezy). Checking for updates and downloading them is a manual process: ~50 clicks across two marketplace UIs, moving 16+ zip files, extracting to the right directories. This eats a morning of momentum every update cycle.
|
||||
|
||||
## Solution
|
||||
|
||||
Two-layer system: lightweight version detection (PHP artisan command) + browser-automated download (Claude Code skill).
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Layer 1: Detection (core/php-uptelligence)
|
||||
artisan uptelligence:check-updates
|
||||
5 HTTP GETs, no auth, schedulable
|
||||
Compares remote vs deployed versions
|
||||
|
||||
Layer 2: Download (Claude Code skill)
|
||||
Playwright → LemonSqueezy (16 items)
|
||||
Claude in Chrome → CodeCanyon (2 items)
|
||||
Downloads zips to staging folder
|
||||
Extracts to saas/services/{product}/package/
|
||||
|
||||
Layer 3: Deploy (existing — manual)
|
||||
docker build → scp → deploy_saas.yml
|
||||
Human in the loop
|
||||
```
|
||||
|
||||
## Layer 1: Version Detection
|
||||
|
||||
### Public Endpoints (no auth required)
|
||||
|
||||
| Endpoint | Returns |
|
||||
|----------|---------|
|
||||
| `GET https://66analytics.com/info.php` | `{"latest_release_version": "66.0.0", "latest_release_version_code": 6600}` |
|
||||
| `GET https://66biolinks.com/info.php` | Same format |
|
||||
| `GET https://66pusher.com/info.php` | Same format |
|
||||
| `GET https://66socialproof.com/info.php` | Same format |
|
||||
| `GET https://dev.altumcode.com/plugins-versions` | `{"affiliate": {"version": "2.0.1"}, "ultimate-blocks": {"version": "9.1.0"}, ...}` |
|
||||
|
||||
### Deployed Version Sources
|
||||
|
||||
- **Product version**: `PRODUCT_CODE` constant in deployed source `config.php`
|
||||
- **Plugin versions**: `version` field in each plugin's `config.php` or `config.json`
|
||||
|
||||
### Artisan Command
|
||||
|
||||
`php artisan uptelligence:check-updates`
|
||||
|
||||
Output:
|
||||
```
|
||||
Product Deployed Latest Status
|
||||
──────────────────────────────────────────────
|
||||
66analytics 65.0.0 66.0.0 UPDATE AVAILABLE
|
||||
66biolinks 65.0.0 66.0.0 UPDATE AVAILABLE
|
||||
66pusher 65.0.0 65.0.0 ✓ current
|
||||
66socialproof 65.0.0 66.0.0 UPDATE AVAILABLE
|
||||
|
||||
Plugin Deployed Latest Status
|
||||
──────────────────────────────────────────────
|
||||
affiliate 2.0.0 2.0.1 UPDATE AVAILABLE
|
||||
ultimate-blocks 9.1.0 9.1.0 ✓ current
|
||||
...
|
||||
```
|
||||
|
||||
Lives in `core/php-uptelligence` as a scheduled check or on-demand command.
|
||||
|
||||
## Layer 2: Browser-Automated Download
|
||||
|
||||
### Claude Code Skill: `/update-altum`
|
||||
|
||||
Workflow:
|
||||
1. Run version check (Layer 1) — show what needs updating
|
||||
2. Ask for confirmation before downloading
|
||||
3. Download from both marketplaces
|
||||
4. Extract to staging directories
|
||||
5. Report what changed
|
||||
|
||||
### Marketplace Access
|
||||
|
||||
**LemonSqueezy (Playwright)**
|
||||
- Auth: Magic link email to `snider@lt.hn` — user taps on phone
|
||||
- Flow per item: Navigate to order detail → click "Download" button
|
||||
- 16 items across 2 pages of orders
|
||||
- Session persists for the skill invocation
|
||||
|
||||
**CodeCanyon (Claude in Chrome)**
|
||||
- Auth: Saved browser session cookies (user `snidered`)
|
||||
- Flow per item: Click "Download" dropdown → "All files & documentation"
|
||||
- 2 items on downloads page
|
||||
|
||||
### Product-to-Marketplace Mapping
|
||||
|
||||
| Product | CodeCanyon | LemonSqueezy |
|
||||
|---------|-----------|--------------|
|
||||
| 66biolinks | Regular licence | Extended licence (66biolinks custom, $359.28) |
|
||||
| 66socialproof | Regular licence | — |
|
||||
| 66analytics | — | Regular licence |
|
||||
| 66pusher | — | Regular licence |
|
||||
|
||||
### Plugin Inventory (all LemonSqueezy)
|
||||
|
||||
| Plugin | Price | Applies To |
|
||||
|--------|-------|------------|
|
||||
| Pro Notifications | $58.80 | 66socialproof |
|
||||
| Teams Plugin | $58.80 | All products |
|
||||
| Push Notifications Plugin | $46.80 | All products |
|
||||
| Ultimate Blocks | $32.40 | 66biolinks |
|
||||
| Pro Blocks | $32.40 | 66biolinks |
|
||||
| Payment Blocks | $32.40 | 66biolinks |
|
||||
| Affiliate Plugin | $32.40 | All products |
|
||||
| PWA Plugin | $25.20 | All products |
|
||||
| Image Optimizer Plugin | $19.20 | All products |
|
||||
| Email Shield Plugin | FREE | All products |
|
||||
| Dynamic OG images plugin | FREE | 66biolinks |
|
||||
| Offload & CDN Plugin | FREE | All products (gift from Altum) |
|
||||
|
||||
### Staging & Extraction
|
||||
|
||||
- Download to: `~/Code/lthn/saas/updates/YYYY-MM-DD/`
|
||||
- Products extract to: `~/Code/lthn/saas/services/{product}/package/product/`
|
||||
- Plugins extract to: `~/Code/lthn/saas/services/{product}/package/product/plugins/{plugin_id}/`
|
||||
|
||||
## LemonSqueezy Order UUIDs
|
||||
|
||||
Stable order URLs for direct navigation:
|
||||
|
||||
| Product | Order URL |
|
||||
|---------|-----------|
|
||||
| 66analytics | `/my-orders/2972471f-abac-4165-b78d-541b176de180` |
|
||||
|
||||
(Remaining UUIDs to be captured on first full run of the skill.)
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- No auto-deploy to production (human runs `deploy_saas.yml`)
|
||||
- No licence key handling or financial transactions
|
||||
- No AltumCode Club membership management
|
||||
- No Blesta updates (different vendor)
|
||||
- No update SQL migration execution (handled by AltumCode's own update scripts)
|
||||
|
||||
## Key Technical Details
|
||||
|
||||
- AltumCode products use Unirest HTTP client for API calls
|
||||
- Product `info.php` endpoints are public, no rate limiting observed
|
||||
- Plugin versions endpoint (`dev.altumcode.com`) is also public
|
||||
- Production Docker images have `/install/` and `/update/` directories stripped
|
||||
- Updates require full Docker image rebuild and redeployment via Ansible
|
||||
- CodeCanyon download URLs contain stable purchase UUIDs
|
||||
- LemonSqueezy uses magic link auth (no password, email-based)
|
||||
- Playwright can access LemonSqueezy; Claude in Chrome cannot (payment platform safety block)
|
||||
|
||||
## Workflow Summary
|
||||
|
||||
**Before**: Get email from AltumCode → log into 2 marketplaces → click through 18 products/plugins → download 16+ zips → extract to right directories → rebuild Docker images → deploy. Half a morning.
|
||||
|
||||
**After**: Run `artisan uptelligence:check-updates` → see what's behind → invoke `/update-altum` → tap magic link on phone → go make coffee → come back to staged files → `deploy_saas.yml`. 10 minutes of human time.
|
||||
|
|
@ -0,0 +1,799 @@
|
|||
# AltumCode Update Checker Implementation Plan
|
||||
|
||||
> **Note:** Layer 1 (Tasks 1-2, 4: version checking + seeder + sync command) is implemented and documented at `docs/docs/php/packages/uptelligence.md`. Task 3 (Claude Code browser skill for Layer 2 downloads) is NOT yet implemented.
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Add AltumCode product + plugin version checking to uptelligence, and create a Claude Code skill for browser-automated downloads from LemonSqueezy and CodeCanyon.
|
||||
|
||||
**Architecture:** Extend the existing `VendorUpdateCheckerService` to handle `PLATFORM_ALTUM` vendors via 5 public HTTP endpoints. Seed the vendors table with all 4 products and 13 plugins. Create a Claude Code plugin skill that uses Playwright (LemonSqueezy) and Chrome (CodeCanyon) to download updates.
|
||||
|
||||
**Tech Stack:** PHP 8.4, Laravel, Pest, Claude Code plugins (Playwright MCP + Chrome MCP)
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add AltumCode check to VendorUpdateCheckerService
|
||||
|
||||
**Files:**
|
||||
- Modify: `/Users/snider/Code/core/php-uptelligence/Services/VendorUpdateCheckerService.php`
|
||||
- Test: `/Users/snider/Code/core/php-uptelligence/tests/Unit/AltumCodeCheckerTest.php`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
Create `/Users/snider/Code/core/php-uptelligence/tests/Unit/AltumCodeCheckerTest.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Core\Mod\Uptelligence\Models\Vendor;
|
||||
use Core\Mod\Uptelligence\Services\VendorUpdateCheckerService;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->service = app(VendorUpdateCheckerService::class);
|
||||
});
|
||||
|
||||
it('checks altum product version via info.php', function () {
|
||||
Http::fake([
|
||||
'https://66analytics.com/info.php' => Http::response([
|
||||
'latest_release_version' => '66.0.0',
|
||||
'latest_release_version_code' => 6600,
|
||||
]),
|
||||
]);
|
||||
|
||||
$vendor = Vendor::factory()->create([
|
||||
'slug' => '66analytics',
|
||||
'name' => '66analytics',
|
||||
'source_type' => Vendor::SOURCE_LICENSED,
|
||||
'plugin_platform' => Vendor::PLATFORM_ALTUM,
|
||||
'current_version' => '65.0.0',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$result = $this->service->checkVendor($vendor);
|
||||
|
||||
expect($result['status'])->toBe('success')
|
||||
->and($result['current'])->toBe('65.0.0')
|
||||
->and($result['latest'])->toBe('66.0.0')
|
||||
->and($result['has_update'])->toBeTrue();
|
||||
});
|
||||
|
||||
it('reports no update when altum product is current', function () {
|
||||
Http::fake([
|
||||
'https://66analytics.com/info.php' => Http::response([
|
||||
'latest_release_version' => '65.0.0',
|
||||
'latest_release_version_code' => 6500,
|
||||
]),
|
||||
]);
|
||||
|
||||
$vendor = Vendor::factory()->create([
|
||||
'slug' => '66analytics',
|
||||
'name' => '66analytics',
|
||||
'source_type' => Vendor::SOURCE_LICENSED,
|
||||
'plugin_platform' => Vendor::PLATFORM_ALTUM,
|
||||
'current_version' => '65.0.0',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$result = $this->service->checkVendor($vendor);
|
||||
|
||||
expect($result['has_update'])->toBeFalse();
|
||||
});
|
||||
|
||||
it('checks altum plugin versions via plugins-versions endpoint', function () {
|
||||
Http::fake([
|
||||
'https://dev.altumcode.com/plugins-versions' => Http::response([
|
||||
'affiliate' => ['version' => '2.0.1'],
|
||||
'teams' => ['version' => '3.0.0'],
|
||||
]),
|
||||
]);
|
||||
|
||||
$vendor = Vendor::factory()->create([
|
||||
'slug' => 'altum-plugin-affiliate',
|
||||
'name' => 'Affiliate Plugin',
|
||||
'source_type' => Vendor::SOURCE_PLUGIN,
|
||||
'plugin_platform' => Vendor::PLATFORM_ALTUM,
|
||||
'current_version' => '2.0.0',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$result = $this->service->checkVendor($vendor);
|
||||
|
||||
expect($result['status'])->toBe('success')
|
||||
->and($result['latest'])->toBe('2.0.1')
|
||||
->and($result['has_update'])->toBeTrue();
|
||||
});
|
||||
|
||||
it('handles altum info.php timeout gracefully', function () {
|
||||
Http::fake([
|
||||
'https://66analytics.com/info.php' => Http::response('', 500),
|
||||
]);
|
||||
|
||||
$vendor = Vendor::factory()->create([
|
||||
'slug' => '66analytics',
|
||||
'name' => '66analytics',
|
||||
'source_type' => Vendor::SOURCE_LICENSED,
|
||||
'plugin_platform' => Vendor::PLATFORM_ALTUM,
|
||||
'current_version' => '65.0.0',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$result = $this->service->checkVendor($vendor);
|
||||
|
||||
expect($result['status'])->toBe('error')
|
||||
->and($result['has_update'])->toBeFalse();
|
||||
});
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `cd /Users/snider/Code/core/php-uptelligence && composer test -- --filter=AltumCodeChecker`
|
||||
Expected: FAIL — altum vendors still hit `skipCheck()`
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
In `/Users/snider/Code/core/php-uptelligence/Services/VendorUpdateCheckerService.php`, modify `checkVendor()` to route altum vendors:
|
||||
|
||||
```php
|
||||
public function checkVendor(Vendor $vendor): array
|
||||
{
|
||||
$result = match (true) {
|
||||
$this->isAltumPlatform($vendor) && $vendor->isLicensed() => $this->checkAltumProduct($vendor),
|
||||
$this->isAltumPlatform($vendor) && $vendor->isPlugin() => $this->checkAltumPlugin($vendor),
|
||||
$vendor->isOss() && $this->isGitHubUrl($vendor->git_repo_url) => $this->checkGitHub($vendor),
|
||||
$vendor->isOss() && $this->isGiteaUrl($vendor->git_repo_url) => $this->checkGitea($vendor),
|
||||
default => $this->skipCheck($vendor),
|
||||
};
|
||||
|
||||
// ... rest unchanged
|
||||
}
|
||||
```
|
||||
|
||||
Add the three new methods:
|
||||
|
||||
```php
|
||||
/**
|
||||
* Check if vendor is on the AltumCode platform.
|
||||
*/
|
||||
protected function isAltumPlatform(Vendor $vendor): bool
|
||||
{
|
||||
return $vendor->plugin_platform === Vendor::PLATFORM_ALTUM;
|
||||
}
|
||||
|
||||
/**
|
||||
* AltumCode product info endpoint mapping.
|
||||
*/
|
||||
protected function getAltumProductInfoUrl(Vendor $vendor): ?string
|
||||
{
|
||||
$urls = [
|
||||
'66analytics' => 'https://66analytics.com/info.php',
|
||||
'66biolinks' => 'https://66biolinks.com/info.php',
|
||||
'66pusher' => 'https://66pusher.com/info.php',
|
||||
'66socialproof' => 'https://66socialproof.com/info.php',
|
||||
];
|
||||
|
||||
return $urls[$vendor->slug] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check an AltumCode product for updates via its info.php endpoint.
|
||||
*/
|
||||
protected function checkAltumProduct(Vendor $vendor): array
|
||||
{
|
||||
$url = $this->getAltumProductInfoUrl($vendor);
|
||||
if (! $url) {
|
||||
return $this->errorResult("No info.php URL mapped for {$vendor->slug}");
|
||||
}
|
||||
|
||||
try {
|
||||
$response = Http::timeout(5)->get($url);
|
||||
|
||||
if (! $response->successful()) {
|
||||
return $this->errorResult("AltumCode info.php returned {$response->status()}");
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
$latestVersion = $data['latest_release_version'] ?? null;
|
||||
|
||||
if (! $latestVersion) {
|
||||
return $this->errorResult('No version in info.php response');
|
||||
}
|
||||
|
||||
return $this->buildResult(
|
||||
vendor: $vendor,
|
||||
latestVersion: $this->normaliseVersion($latestVersion),
|
||||
releaseInfo: [
|
||||
'version_code' => $data['latest_release_version_code'] ?? null,
|
||||
'source' => $url,
|
||||
]
|
||||
);
|
||||
} catch (\Exception $e) {
|
||||
return $this->errorResult("AltumCode check failed: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check an AltumCode plugin for updates via the central plugins-versions endpoint.
|
||||
*/
|
||||
protected function checkAltumPlugin(Vendor $vendor): array
|
||||
{
|
||||
try {
|
||||
$allPlugins = $this->getAltumPluginVersions();
|
||||
|
||||
if ($allPlugins === null) {
|
||||
return $this->errorResult('Failed to fetch AltumCode plugin versions');
|
||||
}
|
||||
|
||||
// Extract the plugin_id from the vendor slug (strip 'altum-plugin-' prefix)
|
||||
$pluginId = str_replace('altum-plugin-', '', $vendor->slug);
|
||||
|
||||
if (! isset($allPlugins[$pluginId])) {
|
||||
return $this->errorResult("Plugin '{$pluginId}' not found in AltumCode registry");
|
||||
}
|
||||
|
||||
$latestVersion = $allPlugins[$pluginId]['version'] ?? null;
|
||||
|
||||
return $this->buildResult(
|
||||
vendor: $vendor,
|
||||
latestVersion: $this->normaliseVersion($latestVersion),
|
||||
releaseInfo: ['source' => 'dev.altumcode.com/plugins-versions']
|
||||
);
|
||||
} catch (\Exception $e) {
|
||||
return $this->errorResult("AltumCode plugin check failed: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all AltumCode plugin versions (cached for 1 hour within a check run).
|
||||
*/
|
||||
protected ?array $altumPluginVersionsCache = null;
|
||||
|
||||
protected function getAltumPluginVersions(): ?array
|
||||
{
|
||||
if ($this->altumPluginVersionsCache !== null) {
|
||||
return $this->altumPluginVersionsCache;
|
||||
}
|
||||
|
||||
$response = Http::timeout(5)->get('https://dev.altumcode.com/plugins-versions');
|
||||
|
||||
if (! $response->successful()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->altumPluginVersionsCache = $response->json();
|
||||
|
||||
return $this->altumPluginVersionsCache;
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `cd /Users/snider/Code/core/php-uptelligence && composer test -- --filter=AltumCodeChecker`
|
||||
Expected: PASS (4 tests)
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
cd /Users/snider/Code/core/php-uptelligence
|
||||
git add Services/VendorUpdateCheckerService.php tests/Unit/AltumCodeCheckerTest.php
|
||||
git commit -m "feat: add AltumCode product + plugin version checking
|
||||
|
||||
Extends VendorUpdateCheckerService to check AltumCode products via
|
||||
their info.php endpoints and plugins via dev.altumcode.com/plugins-versions.
|
||||
No auth required — all endpoints are public.
|
||||
|
||||
Co-Authored-By: Virgil <virgil@lethean.io>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Seed AltumCode vendors
|
||||
|
||||
**Files:**
|
||||
- Create: `/Users/snider/Code/core/php-uptelligence/database/seeders/AltumCodeVendorSeeder.php`
|
||||
- Test: `/Users/snider/Code/core/php-uptelligence/tests/Unit/AltumCodeVendorSeederTest.php`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
Create `/Users/snider/Code/core/php-uptelligence/tests/Unit/AltumCodeVendorSeederTest.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Core\Mod\Uptelligence\Models\Vendor;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('seeds 4 altum products', function () {
|
||||
$this->artisan('db:seed', ['--class' => 'Core\\Mod\\Uptelligence\\Database\\Seeders\\AltumCodeVendorSeeder']);
|
||||
|
||||
expect(Vendor::where('source_type', Vendor::SOURCE_LICENSED)
|
||||
->where('plugin_platform', Vendor::PLATFORM_ALTUM)
|
||||
->count()
|
||||
)->toBe(4);
|
||||
});
|
||||
|
||||
it('seeds 13 altum plugins', function () {
|
||||
$this->artisan('db:seed', ['--class' => 'Core\\Mod\\Uptelligence\\Database\\Seeders\\AltumCodeVendorSeeder']);
|
||||
|
||||
expect(Vendor::where('source_type', Vendor::SOURCE_PLUGIN)
|
||||
->where('plugin_platform', Vendor::PLATFORM_ALTUM)
|
||||
->count()
|
||||
)->toBe(13);
|
||||
});
|
||||
|
||||
it('is idempotent', function () {
|
||||
$this->artisan('db:seed', ['--class' => 'Core\\Mod\\Uptelligence\\Database\\Seeders\\AltumCodeVendorSeeder']);
|
||||
$this->artisan('db:seed', ['--class' => 'Core\\Mod\\Uptelligence\\Database\\Seeders\\AltumCodeVendorSeeder']);
|
||||
|
||||
expect(Vendor::where('plugin_platform', Vendor::PLATFORM_ALTUM)->count())->toBe(17);
|
||||
});
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `cd /Users/snider/Code/core/php-uptelligence && composer test -- --filter=AltumCodeVendorSeeder`
|
||||
Expected: FAIL — seeder class not found
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
Create `/Users/snider/Code/core/php-uptelligence/database/seeders/AltumCodeVendorSeeder.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Uptelligence\Database\Seeders;
|
||||
|
||||
use Core\Mod\Uptelligence\Models\Vendor;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class AltumCodeVendorSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
$products = [
|
||||
['slug' => '66analytics', 'name' => '66analytics', 'vendor_name' => 'AltumCode', 'current_version' => '65.0.0'],
|
||||
['slug' => '66biolinks', 'name' => '66biolinks', 'vendor_name' => 'AltumCode', 'current_version' => '65.0.0'],
|
||||
['slug' => '66pusher', 'name' => '66pusher', 'vendor_name' => 'AltumCode', 'current_version' => '65.0.0'],
|
||||
['slug' => '66socialproof', 'name' => '66socialproof', 'vendor_name' => 'AltumCode', 'current_version' => '65.0.0'],
|
||||
];
|
||||
|
||||
foreach ($products as $product) {
|
||||
Vendor::updateOrCreate(
|
||||
['slug' => $product['slug']],
|
||||
[
|
||||
...$product,
|
||||
'source_type' => Vendor::SOURCE_LICENSED,
|
||||
'plugin_platform' => Vendor::PLATFORM_ALTUM,
|
||||
'is_active' => true,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
$plugins = [
|
||||
['slug' => 'altum-plugin-affiliate', 'name' => 'Affiliate Plugin', 'current_version' => '2.0.0'],
|
||||
['slug' => 'altum-plugin-push-notifications', 'name' => 'Push Notifications Plugin', 'current_version' => '1.0.0'],
|
||||
['slug' => 'altum-plugin-teams', 'name' => 'Teams Plugin', 'current_version' => '1.0.0'],
|
||||
['slug' => 'altum-plugin-pwa', 'name' => 'PWA Plugin', 'current_version' => '1.0.0'],
|
||||
['slug' => 'altum-plugin-image-optimizer', 'name' => 'Image Optimizer Plugin', 'current_version' => '3.1.0'],
|
||||
['slug' => 'altum-plugin-email-shield', 'name' => 'Email Shield Plugin', 'current_version' => '1.0.0'],
|
||||
['slug' => 'altum-plugin-dynamic-og-images', 'name' => 'Dynamic OG Images Plugin', 'current_version' => '1.0.0'],
|
||||
['slug' => 'altum-plugin-offload', 'name' => 'Offload & CDN Plugin', 'current_version' => '1.0.0'],
|
||||
['slug' => 'altum-plugin-payment-blocks', 'name' => 'Payment Blocks Plugin', 'current_version' => '1.0.0'],
|
||||
['slug' => 'altum-plugin-ultimate-blocks', 'name' => 'Ultimate Blocks Plugin', 'current_version' => '9.1.0'],
|
||||
['slug' => 'altum-plugin-pro-blocks', 'name' => 'Pro Blocks Plugin', 'current_version' => '1.0.0'],
|
||||
['slug' => 'altum-plugin-pro-notifications', 'name' => 'Pro Notifications Plugin', 'current_version' => '1.0.0'],
|
||||
['slug' => 'altum-plugin-aix', 'name' => 'AIX Plugin', 'current_version' => '1.0.0'],
|
||||
];
|
||||
|
||||
foreach ($plugins as $plugin) {
|
||||
Vendor::updateOrCreate(
|
||||
['slug' => $plugin['slug']],
|
||||
[
|
||||
...$plugin,
|
||||
'vendor_name' => 'AltumCode',
|
||||
'source_type' => Vendor::SOURCE_PLUGIN,
|
||||
'plugin_platform' => Vendor::PLATFORM_ALTUM,
|
||||
'is_active' => true,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `cd /Users/snider/Code/core/php-uptelligence && composer test -- --filter=AltumCodeVendorSeeder`
|
||||
Expected: PASS (3 tests)
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
cd /Users/snider/Code/core/php-uptelligence
|
||||
git add database/seeders/AltumCodeVendorSeeder.php tests/Unit/AltumCodeVendorSeederTest.php
|
||||
git commit -m "feat: seed AltumCode vendors — 4 products + 13 plugins
|
||||
|
||||
Idempotent seeder using updateOrCreate. Products are SOURCE_LICENSED,
|
||||
plugins are SOURCE_PLUGIN, all PLATFORM_ALTUM. Version numbers will
|
||||
need updating to match actual deployed versions.
|
||||
|
||||
Co-Authored-By: Virgil <virgil@lethean.io>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Create Claude Code plugin skill for downloads
|
||||
|
||||
**Files:**
|
||||
- Create: `/Users/snider/.claude/plugins/altum-updater/plugin.json`
|
||||
- Create: `/Users/snider/.claude/plugins/altum-updater/skills/update-altum.md`
|
||||
|
||||
**Step 1: Create plugin manifest**
|
||||
|
||||
Create `/Users/snider/.claude/plugins/altum-updater/plugin.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "altum-updater",
|
||||
"description": "Download AltumCode product and plugin updates from LemonSqueezy and CodeCanyon",
|
||||
"version": "0.1.0",
|
||||
"skills": [
|
||||
{
|
||||
"name": "update-altum",
|
||||
"path": "skills/update-altum.md",
|
||||
"description": "Download AltumCode product and plugin updates from marketplaces. Use when the user mentions updating AltumCode products, downloading from LemonSqueezy or CodeCanyon, or running the update checker."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Create skill file**
|
||||
|
||||
Create `/Users/snider/.claude/plugins/altum-updater/skills/update-altum.md`:
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: update-altum
|
||||
description: Download AltumCode product and plugin updates from LemonSqueezy and CodeCanyon
|
||||
---
|
||||
|
||||
# AltumCode Update Downloader
|
||||
|
||||
## Overview
|
||||
|
||||
Downloads updated AltumCode products and plugins from two marketplaces:
|
||||
- **LemonSqueezy** (Playwright): 66analytics, 66pusher, 66biolinks (extended), 13 plugins
|
||||
- **CodeCanyon** (Claude in Chrome): 66biolinks (regular), 66socialproof
|
||||
|
||||
## Pre-flight
|
||||
|
||||
1. Run `php artisan uptelligence:check-updates --vendor=66analytics` (or check all) to see what needs updating
|
||||
2. Show the user the version comparison table
|
||||
3. Ask which products/plugins to download
|
||||
|
||||
## LemonSqueezy Download Flow (Playwright)
|
||||
|
||||
LemonSqueezy uses magic link auth. The user will need to tap the link on their phone.
|
||||
|
||||
1. Navigate to `https://app.lemonsqueezy.com/my-orders`
|
||||
2. If on login page, fill email `snider@lt.hn` and click Sign In
|
||||
3. Tell user: "Magic link sent — tap the link on your phone"
|
||||
4. Wait for redirect to orders page
|
||||
5. For each product/plugin that needs updating:
|
||||
a. Click the product link on the orders page (paginated — 10 per page, 2 pages)
|
||||
b. In the order detail, find the "Download" button under "Files"
|
||||
c. Click Download — file saves to default downloads folder
|
||||
6. Move downloaded zips to staging: `~/Code/lthn/saas/updates/YYYY-MM-DD/`
|
||||
|
||||
### LemonSqueezy Product Names (as shown on orders page)
|
||||
|
||||
| Our Name | LemonSqueezy Order Name |
|
||||
|----------|------------------------|
|
||||
| 66analytics | "66analytics - Regular License" |
|
||||
| 66pusher | "66pusher - Regular License" |
|
||||
| 66biolinks (extended) | "66biolinks custom" |
|
||||
| Affiliate Plugin | "Affiliate Plugin" |
|
||||
| Push Notifications Plugin | "Push Notifications Plugin" |
|
||||
| Teams Plugin | "Teams Plugin" |
|
||||
| PWA Plugin | "PWA Plugin" |
|
||||
| Image Optimizer Plugin | "Image Optimizer Plugin" |
|
||||
| Email Shield Plugin | "Email Shield Plugin" |
|
||||
| Dynamic OG Images | "Dynamic OG images plugin" |
|
||||
| Offload & CDN | "Offload & CDN Plugin" |
|
||||
| Payment Blocks | "Payment Blocks - 66biolinks plugin" |
|
||||
| Ultimate Blocks | "Ultimate Blocks - 66biolinks plugin" |
|
||||
| Pro Blocks | "Pro Blocks - 66biolinks plugin" |
|
||||
| Pro Notifications | "Pro Notifications - 66socialproof plugin" |
|
||||
| AltumCode Club | "The AltumCode Club" |
|
||||
|
||||
## CodeCanyon Download Flow (Claude in Chrome)
|
||||
|
||||
CodeCanyon uses saved browser session cookies (user: snidered).
|
||||
|
||||
1. Navigate to `https://codecanyon.net/downloads`
|
||||
2. Dismiss cookie banner if present (click "Reject all")
|
||||
3. For 66socialproof:
|
||||
a. Find "66socialproof" Download button
|
||||
b. Click the dropdown arrow
|
||||
c. Click "All files & documentation"
|
||||
4. For 66biolinks:
|
||||
a. Find "66biolinks" Download button (scroll down)
|
||||
b. Click the dropdown arrow
|
||||
c. Click "All files & documentation"
|
||||
5. Move downloaded zips to staging
|
||||
|
||||
### CodeCanyon Download URLs (stable)
|
||||
|
||||
- 66socialproof: `/user/snidered/download_purchase/8d8ef4c1-5add-4eba-9a89-4261a9c87e0b`
|
||||
- 66biolinks: `/user/snidered/download_purchase/38d79f4e-19cd-480a-b068-4332629b5206`
|
||||
|
||||
## Post-Download
|
||||
|
||||
1. List all zips in staging folder
|
||||
2. For each product zip:
|
||||
- Extract to `~/Code/lthn/saas/services/{product}/package/product/`
|
||||
3. For each plugin zip:
|
||||
- Extract to the correct product's `plugins/{plugin_id}/` directory
|
||||
- Note: Some plugins apply to multiple products (affiliate, teams, etc.)
|
||||
4. Show summary of what was updated
|
||||
5. Remind user: "Files staged. Run `deploy_saas.yml` when ready to deploy."
|
||||
|
||||
## Important Notes
|
||||
|
||||
- Never make purchases or enter financial information
|
||||
- LemonSqueezy session expires — if Playwright gets a login page mid-flow, re-trigger magic link
|
||||
- CodeCanyon session depends on Chrome cookies — if logged out, tell user to log in manually
|
||||
- The AltumCode Club subscription is NOT a downloadable product — skip it
|
||||
- Plugin `aix` may not appear on LemonSqueezy (bundled with products) — skip if not found
|
||||
```
|
||||
|
||||
**Step 3: Verify plugin loads**
|
||||
|
||||
Run: `claude` in a new terminal, then type `/update-altum` to verify the skill is discovered.
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
cd /Users/snider/.claude/plugins/altum-updater
|
||||
git init
|
||||
git add plugin.json skills/update-altum.md
|
||||
git commit -m "feat: altum-updater Claude Code plugin — marketplace download skill
|
||||
|
||||
Playwright for LemonSqueezy, Chrome for CodeCanyon. Includes full
|
||||
product/plugin mapping and download flow documentation.
|
||||
|
||||
Co-Authored-By: Virgil <virgil@lethean.io>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Sync deployed plugin versions from source
|
||||
|
||||
**Files:**
|
||||
- Create: `/Users/snider/Code/core/php-uptelligence/Console/SyncAltumVersionsCommand.php`
|
||||
- Modify: `/Users/snider/Code/core/php-uptelligence/Boot.php` (register command)
|
||||
- Test: `/Users/snider/Code/core/php-uptelligence/tests/Unit/SyncAltumVersionsCommandTest.php`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
it('reads product version from saas service config', function () {
|
||||
$this->artisan('uptelligence:sync-altum-versions', ['--dry-run' => true])
|
||||
->assertExitCode(0);
|
||||
});
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `cd /Users/snider/Code/core/php-uptelligence && composer test -- --filter=SyncAltumVersions`
|
||||
Expected: FAIL — command not found
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
Create `/Users/snider/Code/core/php-uptelligence/Console/SyncAltumVersionsCommand.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Uptelligence\Console;
|
||||
|
||||
use Core\Mod\Uptelligence\Models\Vendor;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Sync deployed AltumCode product/plugin versions from local source files.
|
||||
*
|
||||
* Reads PRODUCT_CODE from each product's source and plugin versions
|
||||
* from config.php files, then updates the vendors table.
|
||||
*/
|
||||
class SyncAltumVersionsCommand extends Command
|
||||
{
|
||||
protected $signature = 'uptelligence:sync-altum-versions
|
||||
{--dry-run : Show what would be updated without writing}
|
||||
{--path= : Base path to saas services (default: ~/Code/lthn/saas/services)}';
|
||||
|
||||
protected $description = 'Sync deployed AltumCode product and plugin versions from source files';
|
||||
|
||||
protected array $productPaths = [
|
||||
'66analytics' => '66analytics/package/product',
|
||||
'66biolinks' => '66biolinks/package/product',
|
||||
'66pusher' => '66pusher/package/product',
|
||||
'66socialproof' => '66socialproof/package/product',
|
||||
];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$basePath = $this->option('path')
|
||||
?? env('SAAS_SERVICES_PATH', base_path('../lthn/saas/services'));
|
||||
$dryRun = $this->option('dry-run');
|
||||
|
||||
$this->info('Syncing AltumCode versions from source...');
|
||||
$this->newLine();
|
||||
|
||||
$updates = [];
|
||||
|
||||
// Sync product versions
|
||||
foreach ($this->productPaths as $slug => $relativePath) {
|
||||
$productPath = rtrim($basePath, '/') . '/' . $relativePath;
|
||||
$version = $this->readProductVersion($productPath);
|
||||
|
||||
if ($version) {
|
||||
$updates[] = $this->syncVendorVersion($slug, $version, $dryRun);
|
||||
} else {
|
||||
$this->warn(" Could not read version for {$slug} at {$productPath}");
|
||||
}
|
||||
}
|
||||
|
||||
// Sync plugin versions — read from biolinks as canonical source
|
||||
$biolinkPluginsPath = rtrim($basePath, '/') . '/66biolinks/package/product/plugins';
|
||||
if (is_dir($biolinkPluginsPath)) {
|
||||
foreach (glob($biolinkPluginsPath . '/*/config.php') as $configFile) {
|
||||
$pluginId = basename(dirname($configFile));
|
||||
$version = $this->readPluginVersion($configFile);
|
||||
|
||||
if ($version) {
|
||||
$slug = "altum-plugin-{$pluginId}";
|
||||
$updates[] = $this->syncVendorVersion($slug, $version, $dryRun);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Output table
|
||||
$this->table(
|
||||
['Vendor', 'Old Version', 'New Version', 'Status'],
|
||||
array_filter($updates)
|
||||
);
|
||||
|
||||
if ($dryRun) {
|
||||
$this->warn('Dry run — no changes written.');
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
protected function readProductVersion(string $productPath): ?string
|
||||
{
|
||||
// Read version from app/init.php or similar — look for PRODUCT_VERSION define
|
||||
$initFile = $productPath . '/app/init.php';
|
||||
if (! file_exists($initFile)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$content = file_get_contents($initFile);
|
||||
if (preg_match("/define\('PRODUCT_VERSION',\s*'([^']+)'\)/", $content, $matches)) {
|
||||
return $matches[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function readPluginVersion(string $configFile): ?string
|
||||
{
|
||||
if (! file_exists($configFile)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$content = file_get_contents($configFile);
|
||||
|
||||
// PHP config format: 'version' => '2.0.0'
|
||||
if (preg_match("/'version'\s*=>\s*'([^']+)'/", $content, $matches)) {
|
||||
return $matches[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function syncVendorVersion(string $slug, string $version, bool $dryRun): ?array
|
||||
{
|
||||
$vendor = Vendor::where('slug', $slug)->first();
|
||||
if (! $vendor) {
|
||||
return [$slug, '(not in DB)', $version, 'SKIPPED'];
|
||||
}
|
||||
|
||||
$oldVersion = $vendor->current_version;
|
||||
if ($oldVersion === $version) {
|
||||
return [$slug, $oldVersion, $version, 'current'];
|
||||
}
|
||||
|
||||
if (! $dryRun) {
|
||||
$vendor->update(['current_version' => $version]);
|
||||
}
|
||||
|
||||
return [$slug, $oldVersion ?? '(none)', $version, $dryRun ? 'WOULD UPDATE' : 'UPDATED'];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Register in Boot.php — add to `onConsole()`:
|
||||
|
||||
```php
|
||||
$event->command(Console\SyncAltumVersionsCommand::class);
|
||||
```
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `cd /Users/snider/Code/core/php-uptelligence && composer test -- --filter=SyncAltumVersions`
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
cd /Users/snider/Code/core/php-uptelligence
|
||||
git add Console/SyncAltumVersionsCommand.php Boot.php tests/Unit/SyncAltumVersionsCommandTest.php
|
||||
git commit -m "feat: sync deployed AltumCode versions from source files
|
||||
|
||||
Reads PRODUCT_VERSION from product init.php and plugin versions from
|
||||
config.php files. Updates uptelligence_vendors table so check-updates
|
||||
knows what's actually deployed.
|
||||
|
||||
Co-Authored-By: Virgil <virgil@lethean.io>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: End-to-end verification
|
||||
|
||||
**Step 1: Seed vendors on local dev**
|
||||
|
||||
```bash
|
||||
cd /Users/snider/Code/lab/host.uk.com
|
||||
php artisan db:seed --class="Core\Mod\Uptelligence\Database\Seeders\AltumCodeVendorSeeder"
|
||||
```
|
||||
|
||||
**Step 2: Sync actual deployed versions**
|
||||
|
||||
```bash
|
||||
php artisan uptelligence:sync-altum-versions --path=/Users/snider/Code/lthn/saas/services
|
||||
```
|
||||
|
||||
**Step 3: Run the update check**
|
||||
|
||||
```bash
|
||||
php artisan uptelligence:check-updates
|
||||
```
|
||||
|
||||
Expected: Table showing current vs latest versions for all 17 AltumCode vendors.
|
||||
|
||||
**Step 4: Test the skill**
|
||||
|
||||
Open a new Claude Code session and run `/update-altum` to verify the skill loads and shows the workflow.
|
||||
|
||||
**Step 5: Commit any fixes**
|
||||
|
||||
```bash
|
||||
git add -A && git commit -m "fix: adjustments from end-to-end testing"
|
||||
```
|
||||
169
pkg/lib/workspace/default/.core/reference/docs/primitives.md
Normal file
169
pkg/lib/workspace/default/.core/reference/docs/primitives.md
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
---
|
||||
title: Core Primitives
|
||||
description: The repeated shapes that make CoreGO easy to navigate.
|
||||
---
|
||||
|
||||
# Core Primitives
|
||||
|
||||
CoreGO is easiest to use when you read it as a small vocabulary repeated everywhere. Most of the framework is built from the same handful of types.
|
||||
|
||||
## Primitive Map
|
||||
|
||||
| Type | Used For |
|
||||
|------|----------|
|
||||
| `Options` | Input values and lightweight metadata |
|
||||
| `Result` | Output values and success state |
|
||||
| `Service` | Lifecycle-managed components |
|
||||
| `Message` | Broadcast events |
|
||||
| `Query` | Request-response lookups |
|
||||
| `Task` | Side-effecting work items |
|
||||
|
||||
## `Option` and `Options`
|
||||
|
||||
`Option` is one key-value pair. `Options` is an ordered slice of them.
|
||||
|
||||
```go
|
||||
opts := core.Options{
|
||||
{Key: "name", Value: "brain"},
|
||||
{Key: "path", Value: "prompts"},
|
||||
{Key: "debug", Value: true},
|
||||
}
|
||||
```
|
||||
|
||||
Use the helpers to read values:
|
||||
|
||||
```go
|
||||
name := opts.String("name")
|
||||
path := opts.String("path")
|
||||
debug := opts.Bool("debug")
|
||||
hasPath := opts.Has("path")
|
||||
raw := opts.Get("name")
|
||||
```
|
||||
|
||||
### Important Details
|
||||
|
||||
- `Get` returns the first matching key.
|
||||
- `String`, `Int`, and `Bool` do not convert between types.
|
||||
- Missing keys return zero values.
|
||||
- CLI flags with values are stored as strings, so `--port=8080` should be read with `opts.String("port")`, not `opts.Int("port")`.
|
||||
|
||||
## `Result`
|
||||
|
||||
`Result` is the universal return shape.
|
||||
|
||||
```go
|
||||
r := core.Result{Value: "ready", OK: true}
|
||||
|
||||
if r.OK {
|
||||
fmt.Println(r.Value)
|
||||
}
|
||||
```
|
||||
|
||||
It has two jobs:
|
||||
|
||||
- carry a value when work succeeds
|
||||
- carry either an error or an empty state when work does not succeed
|
||||
|
||||
### `Result.Result(...)`
|
||||
|
||||
The `Result()` method adapts plain Go values and `(value, error)` pairs into a `core.Result`.
|
||||
|
||||
```go
|
||||
r1 := core.Result{}.Result("hello")
|
||||
r2 := core.Result{}.Result(file, err)
|
||||
```
|
||||
|
||||
This is how several built-in helpers bridge standard-library calls.
|
||||
|
||||
## `Service`
|
||||
|
||||
`Service` is the managed lifecycle DTO stored in the registry.
|
||||
|
||||
```go
|
||||
svc := core.Service{
|
||||
Name: "cache",
|
||||
Options: core.Options{
|
||||
{Key: "backend", Value: "memory"},
|
||||
},
|
||||
OnStart: func() core.Result {
|
||||
return core.Result{OK: true}
|
||||
},
|
||||
OnStop: func() core.Result {
|
||||
return core.Result{OK: true}
|
||||
},
|
||||
OnReload: func() core.Result {
|
||||
return core.Result{OK: true}
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Important Details
|
||||
|
||||
- `OnStart` and `OnStop` are used by the framework lifecycle.
|
||||
- `OnReload` is stored on the service DTO, but CoreGO does not currently call it automatically.
|
||||
- The registry stores `*core.Service`, not arbitrary typed service instances.
|
||||
|
||||
## `Message`, `Query`, and `Task`
|
||||
|
||||
These are simple aliases to `any`.
|
||||
|
||||
```go
|
||||
type Message any
|
||||
type Query any
|
||||
type Task any
|
||||
```
|
||||
|
||||
That means your own structs become the protocol:
|
||||
|
||||
```go
|
||||
type deployStarted struct {
|
||||
Environment string
|
||||
}
|
||||
|
||||
type workspaceCountQuery struct{}
|
||||
|
||||
type syncRepositoryTask struct {
|
||||
Name string
|
||||
}
|
||||
```
|
||||
|
||||
## `TaskWithIdentifier`
|
||||
|
||||
Long-running tasks can opt into task identifiers.
|
||||
|
||||
```go
|
||||
type indexedTask struct {
|
||||
ID string
|
||||
}
|
||||
|
||||
func (t *indexedTask) SetTaskIdentifier(id string) { t.ID = id }
|
||||
func (t *indexedTask) GetTaskIdentifier() string { return t.ID }
|
||||
```
|
||||
|
||||
If a task implements `TaskWithIdentifier`, `PerformAsync` injects the generated `task-N` identifier before dispatch.
|
||||
|
||||
## `ServiceRuntime[T]`
|
||||
|
||||
`ServiceRuntime[T]` is the small helper for packages that want to keep a Core reference and a typed options struct together.
|
||||
|
||||
```go
|
||||
type agentServiceOptions struct {
|
||||
WorkspacePath string
|
||||
}
|
||||
|
||||
type agentService struct {
|
||||
*core.ServiceRuntime[agentServiceOptions]
|
||||
}
|
||||
|
||||
runtime := core.NewServiceRuntime(c, agentServiceOptions{
|
||||
WorkspacePath: "/srv/agent-workspaces",
|
||||
})
|
||||
```
|
||||
|
||||
It exposes:
|
||||
|
||||
- `Core()`
|
||||
- `Options()`
|
||||
- `Config()`
|
||||
|
||||
This helper does not register anything by itself. It is a composition aid for package authors.
|
||||
152
pkg/lib/workspace/default/.core/reference/docs/services.md
Normal file
152
pkg/lib/workspace/default/.core/reference/docs/services.md
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
---
|
||||
title: Services
|
||||
description: Register, inspect, and lock CoreGO services.
|
||||
---
|
||||
|
||||
# Services
|
||||
|
||||
In CoreGO, a service is a named lifecycle entry stored in the Core registry.
|
||||
|
||||
## Register a Service
|
||||
|
||||
```go
|
||||
c := core.New()
|
||||
|
||||
r := c.Service("audit", core.Service{
|
||||
OnStart: func() core.Result {
|
||||
core.Info("audit started")
|
||||
return core.Result{OK: true}
|
||||
},
|
||||
OnStop: func() core.Result {
|
||||
core.Info("audit stopped")
|
||||
return core.Result{OK: true}
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Registration succeeds when:
|
||||
|
||||
- the name is not empty
|
||||
- the registry is not locked
|
||||
- the name is not already in use
|
||||
|
||||
## Read a Service Back
|
||||
|
||||
```go
|
||||
r := c.Service("audit")
|
||||
if r.OK {
|
||||
svc := r.Value.(*core.Service)
|
||||
_ = svc
|
||||
}
|
||||
```
|
||||
|
||||
The returned value is `*core.Service`.
|
||||
|
||||
## List Registered Services
|
||||
|
||||
```go
|
||||
names := c.Services()
|
||||
```
|
||||
|
||||
### Important Detail
|
||||
|
||||
The current registry is map-backed. `Services()`, `Startables()`, and `Stoppables()` do not promise a stable order.
|
||||
|
||||
## Lifecycle Snapshots
|
||||
|
||||
Use these helpers when you want the current set of startable or stoppable services:
|
||||
|
||||
```go
|
||||
startables := c.Startables()
|
||||
stoppables := c.Stoppables()
|
||||
```
|
||||
|
||||
They return `[]*core.Service` inside `Result.Value`.
|
||||
|
||||
## Lock the Registry
|
||||
|
||||
CoreGO has a service-lock mechanism, but it is explicit.
|
||||
|
||||
```go
|
||||
c := core.New()
|
||||
|
||||
c.LockEnable()
|
||||
c.Service("audit", core.Service{})
|
||||
c.Service("cache", core.Service{})
|
||||
c.LockApply()
|
||||
```
|
||||
|
||||
After `LockApply`, new registrations fail:
|
||||
|
||||
```go
|
||||
r := c.Service("late", core.Service{})
|
||||
fmt.Println(r.OK) // false
|
||||
```
|
||||
|
||||
The default lock name is `"srv"`. You can pass a different name if you need a custom lock namespace.
|
||||
|
||||
For the service registry itself, use the default `"srv"` lock path. That is the path used by `Core.Service(...)`.
|
||||
|
||||
## `NewWithFactories`
|
||||
|
||||
For GUI runtimes or factory-driven setup, CoreGO provides `NewWithFactories`.
|
||||
|
||||
```go
|
||||
r := core.NewWithFactories(nil, map[string]core.ServiceFactory{
|
||||
"audit": func() core.Result {
|
||||
return core.Result{Value: core.Service{
|
||||
OnStart: func() core.Result {
|
||||
return core.Result{OK: true}
|
||||
},
|
||||
}, OK: true}
|
||||
},
|
||||
"cache": func() core.Result {
|
||||
return core.Result{Value: core.Service{}, OK: true}
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Important Details
|
||||
|
||||
- each factory must return a `core.Service` in `Result.Value`
|
||||
- factories are executed in sorted key order
|
||||
- nil factories are skipped
|
||||
- the return value is `*core.Runtime`
|
||||
|
||||
## `Runtime`
|
||||
|
||||
`Runtime` is a small wrapper used for external runtimes such as GUI bindings.
|
||||
|
||||
```go
|
||||
r := core.NewRuntime(nil)
|
||||
rt := r.Value.(*core.Runtime)
|
||||
|
||||
_ = rt.ServiceStartup(context.Background(), nil)
|
||||
_ = rt.ServiceShutdown(context.Background())
|
||||
```
|
||||
|
||||
`Runtime.ServiceName()` returns `"Core"`.
|
||||
|
||||
## `ServiceRuntime[T]` for Package Authors
|
||||
|
||||
If you are writing a package on top of CoreGO, use `ServiceRuntime[T]` to keep a typed options struct and the parent `Core` together.
|
||||
|
||||
```go
|
||||
type repositoryServiceOptions struct {
|
||||
BaseDirectory string
|
||||
}
|
||||
|
||||
type repositoryService struct {
|
||||
*core.ServiceRuntime[repositoryServiceOptions]
|
||||
}
|
||||
|
||||
func newRepositoryService(c *core.Core) *repositoryService {
|
||||
return &repositoryService{
|
||||
ServiceRuntime: core.NewServiceRuntime(c, repositoryServiceOptions{
|
||||
BaseDirectory: "/srv/repos",
|
||||
}),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This is a package-authoring helper. It does not replace the `core.Service` registry entry.
|
||||
158
pkg/lib/workspace/default/.core/reference/docs/subsystems.md
Normal file
158
pkg/lib/workspace/default/.core/reference/docs/subsystems.md
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
---
|
||||
title: Subsystems
|
||||
description: Built-in accessors for app metadata, embedded data, filesystem, transport handles, i18n, and CLI.
|
||||
---
|
||||
|
||||
# Subsystems
|
||||
|
||||
`Core` gives you a set of built-in subsystems so small applications do not need extra plumbing before they can do useful work.
|
||||
|
||||
## Accessor Map
|
||||
|
||||
| Accessor | Purpose |
|
||||
|----------|---------|
|
||||
| `App()` | Application identity and external runtime |
|
||||
| `Data()` | Named embedded filesystem mounts |
|
||||
| `Drive()` | Named transport handles |
|
||||
| `Fs()` | Local filesystem access |
|
||||
| `I18n()` | Locale collection and translation delegation |
|
||||
| `Cli()` | Command-line surface over the command tree |
|
||||
|
||||
## `App`
|
||||
|
||||
`App` stores process identity and optional GUI runtime state.
|
||||
|
||||
```go
|
||||
app := c.App()
|
||||
app.Name = "agent-workbench"
|
||||
app.Version = "0.25.0"
|
||||
app.Description = "workspace runner"
|
||||
app.Runtime = myRuntime
|
||||
```
|
||||
|
||||
`Find` resolves an executable on `PATH` and returns an `*App`.
|
||||
|
||||
```go
|
||||
r := core.Find("go", "Go toolchain")
|
||||
```
|
||||
|
||||
## `Data`
|
||||
|
||||
`Data` mounts named embedded filesystems and makes them addressable through paths like `mount-name/path/to/file`.
|
||||
|
||||
```go
|
||||
c.Data().New(core.Options{
|
||||
{Key: "name", Value: "app"},
|
||||
{Key: "source", Value: appFS},
|
||||
{Key: "path", Value: "templates"},
|
||||
})
|
||||
```
|
||||
|
||||
Read content:
|
||||
|
||||
```go
|
||||
text := c.Data().ReadString("app/agent.md")
|
||||
bytes := c.Data().ReadFile("app/agent.md")
|
||||
list := c.Data().List("app")
|
||||
names := c.Data().ListNames("app")
|
||||
```
|
||||
|
||||
Extract a mounted directory:
|
||||
|
||||
```go
|
||||
r := c.Data().Extract("app/workspace", "/tmp/workspace", nil)
|
||||
```
|
||||
|
||||
### Path Rule
|
||||
|
||||
The first path segment is always the mount name.
|
||||
|
||||
## `Drive`
|
||||
|
||||
`Drive` is a registry for named transport handles.
|
||||
|
||||
```go
|
||||
c.Drive().New(core.Options{
|
||||
{Key: "name", Value: "api"},
|
||||
{Key: "transport", Value: "https://api.lthn.ai"},
|
||||
})
|
||||
|
||||
c.Drive().New(core.Options{
|
||||
{Key: "name", Value: "mcp"},
|
||||
{Key: "transport", Value: "mcp://mcp.lthn.sh"},
|
||||
})
|
||||
```
|
||||
|
||||
Read them back:
|
||||
|
||||
```go
|
||||
handle := c.Drive().Get("api")
|
||||
hasMCP := c.Drive().Has("mcp")
|
||||
names := c.Drive().Names()
|
||||
```
|
||||
|
||||
## `Fs`
|
||||
|
||||
`Fs` wraps local filesystem operations with a consistent `Result` shape.
|
||||
|
||||
```go
|
||||
c.Fs().Write("/tmp/core-go/example.txt", "hello")
|
||||
r := c.Fs().Read("/tmp/core-go/example.txt")
|
||||
```
|
||||
|
||||
Other helpers:
|
||||
|
||||
```go
|
||||
c.Fs().EnsureDir("/tmp/core-go/cache")
|
||||
c.Fs().List("/tmp/core-go")
|
||||
c.Fs().Stat("/tmp/core-go/example.txt")
|
||||
c.Fs().Rename("/tmp/core-go/example.txt", "/tmp/core-go/example-2.txt")
|
||||
c.Fs().Delete("/tmp/core-go/example-2.txt")
|
||||
```
|
||||
|
||||
### Important Details
|
||||
|
||||
- the default `Core` starts with `Fs{root:"/"}`
|
||||
- relative paths resolve from the current working directory
|
||||
- `Delete` and `DeleteAll` refuse to remove `/` and `$HOME`
|
||||
|
||||
## `I18n`
|
||||
|
||||
`I18n` collects locale mounts and forwards translation work to a translator implementation when one is registered.
|
||||
|
||||
```go
|
||||
c.I18n().SetLanguage("en-GB")
|
||||
```
|
||||
|
||||
Without a translator, `Translate` returns the message key itself:
|
||||
|
||||
```go
|
||||
r := c.I18n().Translate("cmd.deploy.description")
|
||||
```
|
||||
|
||||
With a translator:
|
||||
|
||||
```go
|
||||
c.I18n().SetTranslator(myTranslator)
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
```go
|
||||
langs := c.I18n().AvailableLanguages()
|
||||
current := c.I18n().Language()
|
||||
```
|
||||
|
||||
## `Cli`
|
||||
|
||||
`Cli` exposes the command registry through a terminal-facing API.
|
||||
|
||||
```go
|
||||
c.Cli().SetBanner(func(_ *core.Cli) string {
|
||||
return "Agent Workbench"
|
||||
})
|
||||
|
||||
r := c.Cli().Run("workspace", "create", "--name=alpha")
|
||||
```
|
||||
|
||||
Use [commands.md](commands.md) for the full command and flag model.
|
||||
118
pkg/lib/workspace/default/.core/reference/docs/testing.md
Normal file
118
pkg/lib/workspace/default/.core/reference/docs/testing.md
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
---
|
||||
title: Testing
|
||||
description: Test naming and testing patterns used by CoreGO.
|
||||
---
|
||||
|
||||
# Testing
|
||||
|
||||
The repository uses `github.com/stretchr/testify/assert` and a simple AX-friendly naming pattern.
|
||||
|
||||
## Test Names
|
||||
|
||||
Use:
|
||||
|
||||
- `_Good` for expected success
|
||||
- `_Bad` for expected failure
|
||||
- `_Ugly` for panics, degenerate input, and edge behavior
|
||||
|
||||
Examples from this repository:
|
||||
|
||||
```go
|
||||
func TestNew_Good(t *testing.T) {}
|
||||
func TestService_Register_Duplicate_Bad(t *testing.T) {}
|
||||
func TestCore_Must_Ugly(t *testing.T) {}
|
||||
```
|
||||
|
||||
## Start with a Small Core
|
||||
|
||||
```go
|
||||
c := core.New(core.Options{
|
||||
{Key: "name", Value: "test-core"},
|
||||
})
|
||||
```
|
||||
|
||||
Then register only the pieces your test needs.
|
||||
|
||||
## Test a Service
|
||||
|
||||
```go
|
||||
started := false
|
||||
|
||||
c.Service("audit", core.Service{
|
||||
OnStart: func() core.Result {
|
||||
started = true
|
||||
return core.Result{OK: true}
|
||||
},
|
||||
})
|
||||
|
||||
r := c.ServiceStartup(context.Background(), nil)
|
||||
assert.True(t, r.OK)
|
||||
assert.True(t, started)
|
||||
```
|
||||
|
||||
## Test a Command
|
||||
|
||||
```go
|
||||
c.Command("greet", core.Command{
|
||||
Action: func(opts core.Options) core.Result {
|
||||
return core.Result{Value: "hello " + opts.String("name"), OK: true}
|
||||
},
|
||||
})
|
||||
|
||||
r := c.Cli().Run("greet", "--name=world")
|
||||
assert.True(t, r.OK)
|
||||
assert.Equal(t, "hello world", r.Value)
|
||||
```
|
||||
|
||||
## Test a Query or Task
|
||||
|
||||
```go
|
||||
c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result {
|
||||
if q == "ping" {
|
||||
return core.Result{Value: "pong", OK: true}
|
||||
}
|
||||
return core.Result{}
|
||||
})
|
||||
|
||||
assert.Equal(t, "pong", c.QUERY("ping").Value)
|
||||
```
|
||||
|
||||
```go
|
||||
c.RegisterTask(func(_ *core.Core, t core.Task) core.Result {
|
||||
if t == "compute" {
|
||||
return core.Result{Value: 42, OK: true}
|
||||
}
|
||||
return core.Result{}
|
||||
})
|
||||
|
||||
assert.Equal(t, 42, c.PERFORM("compute").Value)
|
||||
```
|
||||
|
||||
## Test Async Work
|
||||
|
||||
For `PerformAsync`, observe completion through the action bus.
|
||||
|
||||
```go
|
||||
completed := make(chan core.ActionTaskCompleted, 1)
|
||||
|
||||
c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result {
|
||||
if event, ok := msg.(core.ActionTaskCompleted); ok {
|
||||
completed <- event
|
||||
}
|
||||
return core.Result{OK: true}
|
||||
})
|
||||
```
|
||||
|
||||
Then wait with normal Go test tools such as channels, timers, or `assert.Eventually`.
|
||||
|
||||
## Use Real Temporary Paths
|
||||
|
||||
When testing `Fs`, `Data.Extract`, or other I/O helpers, use `t.TempDir()` and create realistic paths instead of mocking the filesystem by default.
|
||||
|
||||
## Repository Commands
|
||||
|
||||
```bash
|
||||
core go test
|
||||
core go test --run TestPerformAsync_Good
|
||||
go test ./...
|
||||
```
|
||||
Loading…
Add table
Reference in a new issue