diff --git a/README.md b/README.md index 52c4a3c..8cd7d37 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,7 @@ nucleus validate nucleus plan --task "change request" --json nucleus gen nucleus scenario --json +nucleus serve --check --json nucleus lint nucleus verify nucleus report --json @@ -89,6 +90,16 @@ explicit opt-in HTTP scenario evidence. Network execution is never implicit: `--run-http` and `--cases` require `--base-url`, and captured headers are redacted for sensitive keys such as authorization and cookies. +`serve` exposes local metadata endpoints for manual probes and automation +preflight checks. Use `nucleus serve --check --json` to inspect the service +without opening a listener, or `nucleus serve --addr 127.0.0.1:8080` to expose +`/healthz`, `/readyz`, and `/.well-known/nucleus.json`. The command is +metadata-only: it does not auto-wire provider SDKs or generated business +handlers. JSON output uses `result_kind: "nucleus.serve_result"`, +`schema_version: "serve.v1"`, `schema_ref: +"contract/schema/serve.schema.json"`, `ok`, `mode`, `summary`, `diagnostics`, +and `server`. + `report` summarizes AI change quality and platform readiness without making network calls. By default it reads AI task result JSON files from `artifacts/nucleus/ai-tasks`; pass `--ai-tasks` for an explicit directory, or @@ -99,9 +110,10 @@ a stable envelope with `result_kind: "nucleus.report_result"`, "contract/schema/report.schema.json"`, `ok`, `mode`, `summary`, and `diagnostics`. -For the bundled example, run `nucleus validate --dir example/hello-http`. -Successful human output includes a short validation summary; `--json` emits the -same result with stable `ok`, `summary`, and `diagnostics` fields. +For any service directory containing `nucleus.yaml` and contract sources, run +`nucleus validate --dir `. Successful human output includes a short +validation summary; `--json` emits the same result with stable `ok`, `summary`, +and `diagnostics` fields. ## Project Shape @@ -110,7 +122,7 @@ The main repository hosts the CLI, examples, and public documentation: ```text api/ Contract files cmd/nucleus/ CLI implementation -example/ Runnable examples +examples/ Runnable examples and templates docs/ Concepts, ADRs, plans, and platform mapping ``` diff --git a/bridge b/bridge index c4f65c5..1995d48 160000 --- a/bridge +++ b/bridge @@ -1 +1 @@ -Subproject commit c4f65c54f66ef82d337aafdef1624fa69d12b423 +Subproject commit 1995d48409b3de91b0a0d15c0c0b4b5738487326 diff --git a/cap b/cap index aa0be7b..66253af 160000 --- a/cap +++ b/cap @@ -1 +1 @@ -Subproject commit aa0be7be3f182bbc913a2a99be38d9894a5a2995 +Subproject commit 66253af2e343839c67348f73465cd2f92513575a diff --git a/cmd/nucleus/internal/root/capability_example_test.go b/cmd/nucleus/internal/root/capability_example_test.go index a660d51..dff4ed5 100644 --- a/cmd/nucleus/internal/root/capability_example_test.go +++ b/cmd/nucleus/internal/root/capability_example_test.go @@ -3,13 +3,11 @@ package root import ( "bytes" "encoding/json" - "path/filepath" "testing" ) func TestCapabilityCommandWithGlobalDir(t *testing.T) { - repoRoot := repositoryRoot(t) - exampleDir := filepath.Join(repoRoot, "example", "hello-http") + exampleDir := writeRootExampleService(t) cmd := New() var stdout bytes.Buffer diff --git a/cmd/nucleus/internal/root/describe_example_test.go b/cmd/nucleus/internal/root/describe_example_test.go index 55d5138..b017c7b 100644 --- a/cmd/nucleus/internal/root/describe_example_test.go +++ b/cmd/nucleus/internal/root/describe_example_test.go @@ -10,8 +10,7 @@ import ( ) func TestDescribeCommandWithHelloHTTPExample(t *testing.T) { - repoRoot := repositoryRoot(t) - exampleDir := filepath.Join(repoRoot, "example", "hello-http") + exampleDir := writeRootExampleService(t) cmd := New() var stdout bytes.Buffer diff --git a/cmd/nucleus/internal/root/plan_example_test.go b/cmd/nucleus/internal/root/plan_example_test.go index 2069d91..a3aff3e 100644 --- a/cmd/nucleus/internal/root/plan_example_test.go +++ b/cmd/nucleus/internal/root/plan_example_test.go @@ -3,13 +3,11 @@ package root import ( "bytes" "encoding/json" - "path/filepath" "testing" ) func TestPlanCommandWithHelloHTTPExample(t *testing.T) { - repoRoot := repositoryRoot(t) - exampleDir := filepath.Join(repoRoot, "example", "hello-http") + exampleDir := writeRootExampleService(t) cmd := New() var stdout bytes.Buffer diff --git a/cmd/nucleus/internal/root/root.go b/cmd/nucleus/internal/root/root.go index a04998d..6beeb56 100644 --- a/cmd/nucleus/internal/root/root.go +++ b/cmd/nucleus/internal/root/root.go @@ -13,6 +13,7 @@ import ( "github.com/nucleuskit/nucleus/cmd/nucleus/internal/repair" "github.com/nucleuskit/nucleus/cmd/nucleus/internal/report" "github.com/nucleuskit/nucleus/cmd/nucleus/internal/scenario" + "github.com/nucleuskit/nucleus/cmd/nucleus/internal/serve" "github.com/nucleuskit/nucleus/cmd/nucleus/internal/validate" "github.com/nucleuskit/nucleus/cmd/nucleus/internal/verify" "github.com/spf13/cobra" @@ -37,7 +38,6 @@ func New() *cobra.Command { cmd.PersistentFlags().StringVar(&opts.dir, "dir", ".", "service root directory") cmd.PersistentFlags().BoolVar(&opts.verbose, "verbose", false, "enable verbose output") cmd.PersistentFlags().StringVar(&opts.schema, "schema", "", "override describe schema version") - cmd.AddCommand(describe.NewCommand(describe.Config{ Dir: &opts.dir, SchemaOverride: &opts.schema, @@ -81,5 +81,8 @@ func New() *cobra.Command { cmd.AddCommand(migrate.NewCommand(migrate.Config{ Dir: &opts.dir, })) + cmd.AddCommand(serve.NewCommand(serve.Config{ + Dir: &opts.dir, + })) return cmd } diff --git a/cmd/nucleus/internal/root/scenario_example_test.go b/cmd/nucleus/internal/root/scenario_example_test.go index 0d06209..453ee05 100644 --- a/cmd/nucleus/internal/root/scenario_example_test.go +++ b/cmd/nucleus/internal/root/scenario_example_test.go @@ -3,13 +3,11 @@ package root import ( "bytes" "encoding/json" - "path/filepath" "testing" ) func TestScenarioCommandWithHelloHTTPExample(t *testing.T) { - repoRoot := repositoryRoot(t) - exampleDir := filepath.Join(repoRoot, "example", "hello-http") + exampleDir := writeRootExampleService(t) cmd := New() var stdout bytes.Buffer diff --git a/cmd/nucleus/internal/root/serve_example_test.go b/cmd/nucleus/internal/root/serve_example_test.go new file mode 100644 index 0000000..7a771eb --- /dev/null +++ b/cmd/nucleus/internal/root/serve_example_test.go @@ -0,0 +1,64 @@ +package root + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestServeCommandJSONRootWiring(t *testing.T) { + dir := t.TempDir() + writeRootServeFile(t, dir, "nucleus.yaml", `schema_version: "1.0" +service: + name: demo + version: "0.1.0" +capabilities: + - http +`) + writeRootServeFile(t, dir, "api/openapi.yaml", `openapi: 3.0.3 +paths: + /healthz: + get: + operationId: getHealthz + responses: + "200": + description: ok +`) + writeRootServeFile(t, dir, "api/errors.yaml", "errors: []\n") + + cmd := New() + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetArgs([]string{"--dir", dir, "serve", "--check", "--json"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("execute serve: %v\nstderr=%s", err, stderr.String()) + } + + var output map[string]any + if err := json.Unmarshal(stdout.Bytes(), &output); err != nil { + t.Fatalf("decode serve output: %v\n%s", err, stdout.String()) + } + assertString(t, output, "result_kind", "nucleus.serve_result") + assertString(t, output, "schema_ref", "contract/schema/serve.schema.json") + assertString(t, output, "mode", "check") + assertBool(t, output, "ok", true) + summary := assertMap(t, output, "summary") + assertString(t, summary, "service", "demo") + assertNumber(t, summary, "endpoint_count", 1) +} + +func writeRootServeFile(t *testing.T, dir string, name string, data string) { + t.Helper() + path := filepath.Join(dir, name) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(data), 0o600); err != nil { + t.Fatal(err) + } +} diff --git a/cmd/nucleus/internal/root/service_fixture_test.go b/cmd/nucleus/internal/root/service_fixture_test.go new file mode 100644 index 0000000..508c751 --- /dev/null +++ b/cmd/nucleus/internal/root/service_fixture_test.go @@ -0,0 +1,119 @@ +package root + +import ( + "os" + "path/filepath" + "testing" +) + +func writeRootExampleService(t *testing.T) string { + t.Helper() + dir := t.TempDir() + writeRootFixtureFile(t, dir, "nucleus.yaml", `schema_version: "1.0" +service: + name: hello-http + version: "0.1.0" + env: test + owner: nucleus-maintainers + tier: example + namespace: examples + description: Contract-first HTTP service used by root command tests. +capabilities: + - http + - log +dependencies: + - name: greeting-profile + contract: api/openapi.yaml#/paths/~1hello~1{name} + required: false +ai: + intent: Exercise root command wiring against a stable service fixture. + allowed_changes: + - api/** + - configs/** + - internal/** + readonly: + - internal/adapter/http/gen/** + generated: + - contract/gen + - internal/adapter/http/gen + forbidden: + - configs/*.local.yaml +nucleus: + providers: + log: + provider: noop +`) + writeRootFixtureFile(t, dir, "api/openapi.yaml", `openapi: 3.1.0 +info: + title: Hello HTTP Example + version: 0.1.0 +paths: + /hello/{name}: + parameters: + - name: name + in: path + required: true + schema: + type: string + get: + operationId: get_hello + x-nucleus-priority: 10 + parameters: + - name: trace_id + in: header + required: false + schema: + type: string + responses: + "200": + description: Greeting response. + "404": + description: Greeting target was not found. +`) + writeRootFixtureFile(t, dir, "api/errors.yaml", `errors: + - code: 4001 + message: invalid_name + http_status: 400 + - code: 4041 + message: greeting_not_found + http_status: 404 +`) + writeRootFixtureFile(t, dir, "configs/app.yaml", `http: + address: "${HTTP_ADDR:-127.0.0.1:8080}" +greeting: + prefix: hello +`) + writeRootFixtureFile(t, dir, "go.mod", "module example.com/hello-http\n\ngo 1.26.3\n") + writeRootFixtureFile(t, dir, "internal/app/routes.go", `package app + +import "net/http" + +type Router struct{} + +func (Router) Handle(method string, path string, handler func()) {} + +type Logger struct{} + +func (Logger) Info() {} + +var log Logger + +func RegisterRoutes(router Router) { + router.Handle(http.MethodGet, "/hello/{name}", func() { + log.Info() + }) +} +`) + return dir +} + +func writeRootFixtureFile(t *testing.T, dir string, name string, data string) { + t.Helper() + path := filepath.Join(dir, filepath.FromSlash(name)) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(data), 0o600); err != nil { + t.Fatal(err) + } +} diff --git a/cmd/nucleus/internal/root/validate_example_test.go b/cmd/nucleus/internal/root/validate_example_test.go index d89d13d..74a1041 100644 --- a/cmd/nucleus/internal/root/validate_example_test.go +++ b/cmd/nucleus/internal/root/validate_example_test.go @@ -9,8 +9,7 @@ import ( ) func TestValidateCommandWithHelloHTTPExample(t *testing.T) { - repoRoot := repositoryRoot(t) - exampleDir := filepath.Join(repoRoot, "example", "hello-http") + exampleDir := writeRootExampleService(t) cmd := New() var stdout bytes.Buffer diff --git a/cmd/nucleus/internal/serve/command.go b/cmd/nucleus/internal/serve/command.go new file mode 100644 index 0000000..088bfb5 --- /dev/null +++ b/cmd/nucleus/internal/serve/command.go @@ -0,0 +1,74 @@ +package serve + +import ( + "errors" + "fmt" + + "github.com/spf13/cobra" +) + +// Config carries root-level flag values used by the serve command. +type Config struct { + Dir *string +} + +type options struct { + addr string + allowNonLocal bool + check bool + json bool + pretty bool + mode string +} + +// ErrServeFailed is returned when local metadata serving cannot start safely. +var ErrServeFailed = errors.New("serve failed") + +// NewCommand creates the serve subcommand. +func NewCommand(config Config) *cobra.Command { + opts := &options{addr: defaultAddr} + cmd := &cobra.Command{ + Use: commandUseServe, + Short: commandShortServe, + SilenceUsage: true, + SilenceErrors: true, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + result := buildResult(config, opts) + var listener listener + if !opts.check && result.OK { + opened, err := listen(result.Summary.Addr) + if err != nil { + result.Diagnostics = append(result.Diagnostics, errorDiagnostic("", diagnosticListenFailed, fmt.Sprintf("listen on %s: %v", result.Summary.Addr, err))) + result = finalizeResult(result) + } else { + listener = opened + result.Summary.Addr = listener.Addr().String() + result.Server.Listening = true + } + } + if opts.json { + if err := renderJSON(cmd.OutOrStdout(), result, opts.pretty); err != nil { + closeListener(listener) + return err + } + } else { + renderHuman(cmd.OutOrStdout(), cmd.ErrOrStderr(), result) + } + if !result.OK { + closeListener(listener) + return fmt.Errorf("%w: serve diagnostics contain errors", ErrServeFailed) + } + if opts.check { + return nil + } + return serveListener(cmd.Context(), listener, newHandler(result.Description)) + }, + } + cmd.Flags().StringVar(&opts.addr, flagAddr, defaultAddr, flagHelpAddr) + cmd.Flags().BoolVar(&opts.allowNonLocal, flagAllowNonLocal, false, flagHelpAllowNonLocal) + cmd.Flags().BoolVar(&opts.check, flagCheck, false, flagHelpCheck) + cmd.Flags().BoolVar(&opts.json, flagJSON, false, flagHelpJSON) + cmd.Flags().BoolVar(&opts.pretty, flagPretty, false, flagHelpPretty) + return cmd +} diff --git a/cmd/nucleus/internal/serve/command_test.go b/cmd/nucleus/internal/serve/command_test.go new file mode 100644 index 0000000..51d94b4 --- /dev/null +++ b/cmd/nucleus/internal/serve/command_test.go @@ -0,0 +1,355 @@ +package serve + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "strings" + "sync" + "testing" + "time" +) + +func TestCheckCommandRendersJSONEnvelope(t *testing.T) { + dir := writeServiceFixture(t) + cmd := NewCommand(Config{Dir: &dir}) + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{"--check", "--json", "--pretty"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("execute serve --check --json: %v\n%s", err, stdout.String()) + } + + var output map[string]any + if err := json.Unmarshal(stdout.Bytes(), &output); err != nil { + t.Fatalf("decode JSON: %v\n%s", err, stdout.String()) + } + if output["result_kind"] != resultKindServe { + t.Fatalf("result_kind = %v, want %s", output["result_kind"], resultKindServe) + } + if output["schema_version"] != schemaVersionServe { + t.Fatalf("schema_version = %v, want %s", output["schema_version"], schemaVersionServe) + } + if output["schema_ref"] != schemaRefServe { + t.Fatalf("schema_ref = %v, want %s", output["schema_ref"], schemaRefServe) + } + if output["ok"] != true { + t.Fatalf("ok = %v, want true", output["ok"]) + } + if output["mode"] != modeCheck { + t.Fatalf("mode = %v, want %s", output["mode"], modeCheck) + } + summary := requireMap(t, output, "summary") + if summary["service"] != "demo" { + t.Fatalf("summary.service = %v, want demo", summary["service"]) + } + if summary["addr"] != defaultAddr { + t.Fatalf("summary.addr = %v, want %s", summary["addr"], defaultAddr) + } + if summary["status"] != "ready" { + t.Fatalf("summary.status = %v, want ready", summary["status"]) + } + if summary["endpoint_count"] != float64(1) { + t.Fatalf("summary.endpoint_count = %v, want 1", summary["endpoint_count"]) + } + servedPaths := requireSlice(t, summary, "served_paths") + if len(servedPaths) != 3 { + t.Fatalf("len(summary.served_paths) = %d, want 3: %#v", len(servedPaths), servedPaths) + } + server := requireMap(t, output, "server") + if server["listening"] != false { + t.Fatalf("server.listening = %v, want false", server["listening"]) + } + if server["network_scope"] != networkScopeLoopback { + t.Fatalf("server.network_scope = %v, want %s", server["network_scope"], networkScopeLoopback) + } + metadataEndpoints := requireSlice(t, server, "metadata_endpoints") + if len(metadataEndpoints) != 3 { + t.Fatalf("len(server.metadata_endpoints) = %d, want 3", len(metadataEndpoints)) + } + if !strings.Contains(stdout.String(), "\n \"summary\"") { + t.Fatalf("expected pretty JSON output, got %q", stdout.String()) + } +} + +func TestCheckCommandRendersHumanSummary(t *testing.T) { + dir := writeServiceFixture(t) + cmd := NewCommand(Config{Dir: &dir}) + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{"--check"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("execute serve --check: %v\n%s", err, stdout.String()) + } + + output := stdout.String() + for _, want := range []string{ + "OK\n", + "mode: check\n", + "service: demo\n", + "addr: 127.0.0.1:8080\n", + "served: /healthz, /readyz, /.well-known/nucleus.json\n", + "metadata: endpoints=1 grpc_services=0 capabilities=1 generated_fresh=false\n", + "diagnostics: 0 errors, 0 warnings\n", + } { + if !strings.Contains(output, want) { + t.Fatalf("human output missing %q:\n%s", want, output) + } + } +} + +func TestCheckCommandRejectsNonLocalAddrByDefault(t *testing.T) { + dir := writeServiceFixture(t) + cmd := NewCommand(Config{Dir: &dir}) + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{"--check", "--json", "--addr", "0.0.0.0:8080"}) + + if err := cmd.Execute(); err == nil { + t.Fatalf("expected non-local addr to fail") + } + + var output map[string]any + if err := json.Unmarshal(stdout.Bytes(), &output); err != nil { + t.Fatalf("decode JSON: %v\n%s", err, stdout.String()) + } + assertDiagnosticCode(t, output, diagnosticNonLocalAddr) +} + +func TestCheckCommandAllowsNonLocalAddrWhenExplicit(t *testing.T) { + dir := writeServiceFixture(t) + cmd := NewCommand(Config{Dir: &dir}) + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{"--check", "--json", "--addr", "0.0.0.0:8080", "--allow-non-local"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("execute serve --check --allow-non-local: %v\n%s", err, stdout.String()) + } + + var output map[string]any + if err := json.Unmarshal(stdout.Bytes(), &output); err != nil { + t.Fatalf("decode JSON: %v\n%s", err, stdout.String()) + } + server := requireMap(t, output, "server") + if server["network_scope"] != networkScopeNonLocal { + t.Fatalf("server.network_scope = %v, want %s", server["network_scope"], networkScopeNonLocal) + } +} + +func TestCheckCommandNormalizesEmptyAddrToDefault(t *testing.T) { + dir := writeServiceFixture(t) + cmd := NewCommand(Config{Dir: &dir}) + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{"--check", "--json", "--addr", ""}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("execute serve --check --addr empty: %v\n%s", err, stdout.String()) + } + + var output map[string]any + if err := json.Unmarshal(stdout.Bytes(), &output); err != nil { + t.Fatalf("decode JSON: %v\n%s", err, stdout.String()) + } + summary := requireMap(t, output, "summary") + if summary["addr"] != defaultAddr { + t.Fatalf("summary.addr = %v, want %s", summary["addr"], defaultAddr) + } +} + +func TestCheckCommandReportsInvalidAddrScope(t *testing.T) { + dir := writeServiceFixture(t) + cmd := NewCommand(Config{Dir: &dir}) + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{"--check", "--json", "--addr", "not-an-address"}) + + if err := cmd.Execute(); err == nil { + t.Fatalf("expected invalid addr to fail") + } + + var output map[string]any + if err := json.Unmarshal(stdout.Bytes(), &output); err != nil { + t.Fatalf("decode JSON: %v\n%s", err, stdout.String()) + } + assertDiagnosticCode(t, output, diagnosticAddrInvalid) + server := requireMap(t, output, "server") + if server["network_scope"] != networkScopeInvalid { + t.Fatalf("server.network_scope = %v, want %s", server["network_scope"], networkScopeInvalid) + } +} + +func TestServeCommandReportsActualBoundAddrAndShutsDown(t *testing.T) { + dir := writeServiceFixture(t) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + cmd := NewCommand(Config{Dir: &dir}) + var stdout lockedBuffer + cmd.SetOut(&stdout) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetContext(ctx) + cmd.SetArgs([]string{"--json", "--addr", " 127.0.0.1:0 "}) + + errc := make(chan error, 1) + go func() { + errc <- cmd.Execute() + }() + + output := waitForJSONOutput(t, &stdout) + summary := requireMap(t, output, "summary") + addr, ok := summary["addr"].(string) + if !ok || addr == "" || strings.HasSuffix(addr, ":0") { + t.Fatalf("summary.addr = %v, want actual bound address", summary["addr"]) + } + server := requireMap(t, output, "server") + if server["listening"] != true { + t.Fatalf("server.listening = %v, want true", server["listening"]) + } + + response, err := http.Get("http://" + addr + "/healthz") + if err != nil { + t.Fatalf("GET /healthz: %v", err) + } + _ = response.Body.Close() + if response.StatusCode != http.StatusOK { + t.Fatalf("GET /healthz status = %d, want 200", response.StatusCode) + } + + cancel() + select { + case err := <-errc: + if err != nil { + t.Fatalf("serve returned error after cancel: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("serve did not shut down after context cancel") + } +} + +func TestServeCommandRendersJSONDiagnosticsOnListenFailure(t *testing.T) { + dir := writeServiceFixture(t) + listener, err := listen("127.0.0.1:0") + if err != nil { + t.Fatalf("listen test port: %v", err) + } + defer listener.Close() + + cmd := NewCommand(Config{Dir: &dir}) + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{"--json", "--addr", listener.Addr().String()}) + + if err := cmd.Execute(); err == nil { + t.Fatalf("expected occupied addr to fail") + } + + var output map[string]any + if err := json.Unmarshal(stdout.Bytes(), &output); err != nil { + t.Fatalf("decode JSON: %v\n%s", err, stdout.String()) + } + assertDiagnosticCode(t, output, diagnosticListenFailed) + server := requireMap(t, output, "server") + if server["listening"] != false { + t.Fatalf("server.listening = %v, want false", server["listening"]) + } +} + +type lockedBuffer struct { + mu sync.Mutex + buffer bytes.Buffer +} + +func (b *lockedBuffer) Write(data []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.buffer.Write(data) +} + +func (b *lockedBuffer) bytes() []byte { + b.mu.Lock() + defer b.mu.Unlock() + return append([]byte(nil), b.buffer.Bytes()...) +} + +func waitForJSONOutput(t *testing.T, buffer *lockedBuffer) map[string]any { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + data := buffer.bytes() + if len(data) > 0 { + var output map[string]any + if err := json.Unmarshal(data, &output); err == nil { + return output + } + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("timed out waiting for JSON output; got %q", string(buffer.bytes())) + return nil +} + +func TestCheckCommandRendersJSONDiagnosticsOnInspectFailure(t *testing.T) { + dir := t.TempDir() + cmd := NewCommand(Config{Dir: &dir}) + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{"--check", "--json"}) + + if err := cmd.Execute(); err == nil { + t.Fatalf("expected serve --check --json to fail for missing manifest") + } + + var output map[string]any + if err := json.Unmarshal(stdout.Bytes(), &output); err != nil { + t.Fatalf("decode JSON: %v\n%s", err, stdout.String()) + } + if output["ok"] != false { + t.Fatalf("ok = %v, want false", output["ok"]) + } + assertDiagnosticCode(t, output, diagnosticInspectFailed) +} + +func assertDiagnosticCode(t *testing.T, output map[string]any, want string) { + t.Helper() + diagnostics := requireSlice(t, output, "diagnostics") + if len(diagnostics) != 1 { + t.Fatalf("len(diagnostics) = %d, want 1: %#v", len(diagnostics), diagnostics) + } + item, ok := diagnostics[0].(map[string]any) + if !ok { + t.Fatalf("diagnostic has type %T, want map[string]any", diagnostics[0]) + } + if item["code"] != want { + t.Fatalf("diagnostic.code = %v, want %s", item["code"], want) + } +} + +func requireMap(t *testing.T, value map[string]any, key string) map[string]any { + t.Helper() + item, ok := value[key].(map[string]any) + if !ok { + t.Fatalf("%s has type %T, want map[string]any", key, value[key]) + } + return item +} + +func requireSlice(t *testing.T, value map[string]any, key string) []any { + t.Helper() + item, ok := value[key].([]any) + if !ok { + t.Fatalf("%s has type %T, want []any", key, value[key]) + } + return item +} diff --git a/cmd/nucleus/internal/serve/constants.go b/cmd/nucleus/internal/serve/constants.go new file mode 100644 index 0000000..ad1d70c --- /dev/null +++ b/cmd/nucleus/internal/serve/constants.go @@ -0,0 +1,56 @@ +package serve + +const ( + commandUseServe = "serve" + commandShortServe = "run local Nucleus metadata endpoints" + defaultDir = "." + defaultAddr = "127.0.0.1:8080" +) + +const ( + flagAddr = "addr" + flagAllowNonLocal = "allow-non-local" + flagCheck = "check" + flagJSON = "json" + flagPretty = "pretty" +) + +const ( + flagHelpAddr = "local metadata serve address" + flagHelpAllowNonLocal = "allow serving metadata on a non-loopback address" + flagHelpCheck = "inspect manifest and contracts without listening" + flagHelpJSON = "emit machine-readable serve result" + flagHelpPretty = "pretty-print JSON output" +) + +const ( + resultKindServe = "nucleus.serve_result" + schemaVersionServe = "serve.v1" + schemaRefServe = "contract/schema/serve.schema.json" + jsonIndentPrefix = "" + jsonIndentValue = " " +) + +const ( + modeServe = "serve" + modeCheck = "check" +) + +const ( + pathHealthz = "/healthz" + pathReadyz = "/readyz" + pathWellKnown = "/.well-known/nucleus.json" +) + +const ( + diagnosticInspectFailed = "serve.inspect_failed" + diagnosticAddrInvalid = "serve.addr_invalid" + diagnosticNonLocalAddr = "serve.non_local_addr" + diagnosticListenFailed = "serve.listen_failed" +) + +const ( + networkScopeLoopback = "loopback" + networkScopeNonLocal = "non_local" + networkScopeInvalid = "invalid" +) diff --git a/cmd/nucleus/internal/serve/doc.go b/cmd/nucleus/internal/serve/doc.go new file mode 100644 index 0000000..91c33fd --- /dev/null +++ b/cmd/nucleus/internal/serve/doc.go @@ -0,0 +1,2 @@ +// Package serve implements the local metadata server subcommand. +package serve diff --git a/cmd/nucleus/internal/serve/output.go b/cmd/nucleus/internal/serve/output.go new file mode 100644 index 0000000..dc39f5a --- /dev/null +++ b/cmd/nucleus/internal/serve/output.go @@ -0,0 +1,132 @@ +package serve + +import ( + "encoding/json" + "fmt" + "io" + "strings" + + "github.com/nucleuskit/contract/diagnostic" + "github.com/nucleuskit/contract/inspect" +) + +type serveResult struct { + ResultKind string `json:"result_kind"` + SchemaVersion string `json:"schema_version"` + SchemaRef string `json:"schema_ref"` + OK bool `json:"ok"` + Mode string `json:"mode"` + Summary serveSummary `json:"summary"` + Diagnostics diagnostic.Diagnostics `json:"diagnostics"` + Server serveServer `json:"server"` + Description inspect.Description `json:"-"` +} + +type serveSummary struct { + Status string `json:"status"` + Service string `json:"service,omitempty"` + Version string `json:"version,omitempty"` + Addr string `json:"addr"` + ServedPaths []string `json:"served_paths"` + EndpointCount int `json:"endpoint_count"` + GRPCServiceCount int `json:"grpc_service_count"` + CapabilityCount int `json:"capability_count"` + GeneratedFresh bool `json:"generated_fresh"` + Errors int `json:"errors"` + Warnings int `json:"warnings"` +} + +type serveServer struct { + Listening bool `json:"listening"` + NetworkScope string `json:"network_scope"` + MetadataEndpoints []metadataEndpoint `json:"metadata_endpoints"` +} + +type metadataEndpoint struct { + Method string `json:"method"` + Path string `json:"path"` + ContentType string `json:"content_type"` +} + +func renderHuman(stdout io.Writer, stderr io.Writer, result serveResult) { + for _, item := range result.Diagnostics { + _, _ = fmt.Fprintf(stderr, "%s %s %s: %s\n", item.Severity, item.Path, item.Code, item.Message) + } + if result.OK { + _, _ = fmt.Fprintln(stdout, "OK") + } else { + _, _ = fmt.Fprintln(stderr, "FAILED") + } + _, _ = fmt.Fprintf(stdout, "mode: %s\n", result.Mode) + if result.Summary.Service != "" { + _, _ = fmt.Fprintf(stdout, "service: %s\n", result.Summary.Service) + } + if result.Summary.Version != "" { + _, _ = fmt.Fprintf(stdout, "version: %s\n", result.Summary.Version) + } + _, _ = fmt.Fprintf(stdout, "addr: %s\n", result.Summary.Addr) + if len(result.Summary.ServedPaths) > 0 { + _, _ = fmt.Fprintf(stdout, "served: %s\n", strings.Join(result.Summary.ServedPaths, ", ")) + } + _, _ = fmt.Fprintf(stdout, "metadata: endpoints=%d grpc_services=%d capabilities=%d generated_fresh=%t\n", + result.Summary.EndpointCount, + result.Summary.GRPCServiceCount, + result.Summary.CapabilityCount, + result.Summary.GeneratedFresh, + ) + _, _ = fmt.Fprintf(stdout, "diagnostics: %d errors, %d warnings\n", result.Summary.Errors, result.Summary.Warnings) +} + +func renderJSON(writer io.Writer, result serveResult, pretty bool) error { + result = finalizeResult(result) + encoder := json.NewEncoder(writer) + encoder.SetEscapeHTML(false) + if pretty { + encoder.SetIndent(jsonIndentPrefix, jsonIndentValue) + } + return encoder.Encode(result) +} + +func finalizeResult(result serveResult) serveResult { + result.ResultKind = resultKindServe + result.SchemaVersion = schemaVersionServe + result.SchemaRef = schemaRefServe + if result.Mode == "" { + result.Mode = modeServe + } + if result.Summary.Addr == "" { + result.Summary.Addr = defaultAddr + } + if result.Summary.Status == "" { + result.Summary.Status = "ready" + } + if result.Summary.ServedPaths == nil { + result.Summary.ServedPaths = servedPaths() + } + if result.Server.NetworkScope == "" { + result.Server.NetworkScope = networkScopeInvalid + } + if result.Server.MetadataEndpoints == nil { + result.Server.MetadataEndpoints = metadataEndpoints() + } + if result.Diagnostics == nil { + result.Diagnostics = diagnostic.Diagnostics{} + } + result.Diagnostics.Sort() + result.OK = !result.Diagnostics.Failed() + if !result.OK { + result.Summary.Status = "failed" + } + result.Summary.Errors = result.Diagnostics.Count(diagnostic.SeverityError) + result.Summary.Warnings = result.Diagnostics.Count(diagnostic.SeverityWarning) + return result +} + +func errorDiagnostic(path string, code string, message string) diagnostic.Diagnostic { + return diagnostic.Diagnostic{ + Severity: diagnostic.SeverityError, + Code: code, + Path: path, + Message: message, + } +} diff --git a/cmd/nucleus/internal/serve/run.go b/cmd/nucleus/internal/serve/run.go new file mode 100644 index 0000000..4d5b9c6 --- /dev/null +++ b/cmd/nucleus/internal/serve/run.go @@ -0,0 +1,114 @@ +package serve + +import ( + "fmt" + "net" + "strings" + + "github.com/nucleuskit/contract/inspect" +) + +func buildResult(config Config, opts *options) serveResult { + result := serveResult{ + Mode: modeFromOptions(opts), + Summary: serveSummary{ + Addr: addrFromOptions(opts), + ServedPaths: servedPaths(), + }, + Server: serveServer{ + MetadataEndpoints: metadataEndpoints(), + }, + } + scope, err := classifyNetworkScope(result.Summary.Addr) + if err != nil { + result.Diagnostics = append(result.Diagnostics, errorDiagnostic("", diagnosticAddrInvalid, fmt.Sprintf("invalid listen address %q: %v", result.Summary.Addr, err))) + } else { + result.Server.NetworkScope = scope + if scope != networkScopeLoopback && (opts == nil || !opts.allowNonLocal) { + result.Diagnostics = append(result.Diagnostics, errorDiagnostic("", diagnosticNonLocalAddr, fmt.Sprintf("refusing to expose metadata on non-loopback address %q without --%s", result.Summary.Addr, flagAllowNonLocal))) + } + } + dir := stringValue(config.Dir, defaultDir) + description, err := inspect.Describe(dir) + if err != nil { + result.Diagnostics = append(result.Diagnostics, errorDiagnostic("nucleus.yaml", diagnosticInspectFailed, fmt.Sprintf("inspect service metadata: %v", err))) + return finalizeResult(result) + } + result.Description = description + result.Summary.Service = description.Service.Name + result.Summary.Version = description.Service.Version + result.Summary.EndpointCount = len(description.Endpoints) + result.Summary.GRPCServiceCount = len(description.GRPCServices) + result.Summary.CapabilityCount = len(description.Capabilities) + result.Summary.GeneratedFresh = generatedFresh(description.GeneratedFreshness) + return finalizeResult(result) +} + +func modeFromOptions(opts *options) string { + if opts != nil && opts.mode != "" { + return opts.mode + } + if opts != nil && opts.check { + return modeCheck + } + return modeServe +} + +func addrFromOptions(opts *options) string { + if opts == nil || opts.addr == "" { + return defaultAddr + } + addr := strings.TrimSpace(opts.addr) + if addr == "" { + return defaultAddr + } + return addr +} + +func stringValue(value *string, fallback string) string { + if value == nil { + return fallback + } + return *value +} + +func servedPaths() []string { + return []string{pathHealthz, pathReadyz, pathWellKnown} +} + +func generatedFresh(freshness []inspect.GeneratedFreshness) bool { + if len(freshness) == 0 { + return false + } + for _, item := range freshness { + if !item.Fresh { + return false + } + } + return true +} + +func classifyNetworkScope(addr string) (string, error) { + host, port, err := net.SplitHostPort(strings.TrimSpace(addr)) + if err != nil { + return networkScopeInvalid, err + } + if strings.TrimSpace(port) == "" { + return networkScopeInvalid, fmt.Errorf("missing port") + } + host = strings.Trim(host, "[]") + if host == "" { + return networkScopeNonLocal, nil + } + if strings.EqualFold(host, "localhost") { + return networkScopeLoopback, nil + } + ip := net.ParseIP(host) + if ip == nil { + return networkScopeNonLocal, nil + } + if ip.IsLoopback() { + return networkScopeLoopback, nil + } + return networkScopeNonLocal, nil +} diff --git a/cmd/nucleus/internal/serve/server.go b/cmd/nucleus/internal/serve/server.go new file mode 100644 index 0000000..66002ca --- /dev/null +++ b/cmd/nucleus/internal/serve/server.go @@ -0,0 +1,118 @@ +package serve + +import ( + "context" + "encoding/json" + "errors" + "net" + "net/http" + "time" + + "github.com/nucleuskit/contract/inspect" +) + +const ( + readHeaderTimeout = 5 * time.Second + readTimeout = 10 * time.Second + writeTimeout = 10 * time.Second + idleTimeout = 30 * time.Second + shutdownTimeout = 5 * time.Second +) + +type listener interface { + net.Listener +} + +func newHandler(description inspect.Description) http.Handler { + mux := http.NewServeMux() + mux.HandleFunc(pathHealthz, getOnly(func(writer http.ResponseWriter, request *http.Request) { + writer.Header().Set("Content-Type", "text/plain; charset=utf-8") + _, _ = writer.Write([]byte("OK\n")) + })) + mux.HandleFunc(pathReadyz, getOnly(func(writer http.ResponseWriter, request *http.Request) { + writeJSON(writer, readyPayload(description)) + })) + mux.HandleFunc(pathWellKnown, getOnly(func(writer http.ResponseWriter, request *http.Request) { + writeJSON(writer, description) + })) + return mux +} + +func listen(addr string) (listener, error) { + return net.Listen("tcp", addr) +} + +func serveListener(ctx context.Context, listener listener, handler http.Handler) error { + server := &http.Server{ + Handler: handler, + ReadHeaderTimeout: readHeaderTimeout, + ReadTimeout: readTimeout, + WriteTimeout: writeTimeout, + IdleTimeout: idleTimeout, + } + errc := make(chan error, 1) + go func() { + errc <- server.Serve(listener) + }() + select { + case err := <-errc: + if errors.Is(err, http.ErrServerClosed) { + return nil + } + return err + case <-ctx.Done(): + shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) + defer cancel() + if err := server.Shutdown(shutdownCtx); err != nil { + return err + } + err := <-errc + if errors.Is(err, http.ErrServerClosed) { + return nil + } + return err + } +} + +func closeListener(listener listener) { + if listener != nil { + _ = listener.Close() + } +} + +func getOnly(handler http.HandlerFunc) http.HandlerFunc { + return func(writer http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodGet { + writer.Header().Set("Allow", http.MethodGet) + http.Error(writer, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) + return + } + handler(writer, request) + } +} + +func readyPayload(description inspect.Description) map[string]any { + return map[string]any{ + "status": "ready", + "service": description.Service.Name, + "version": description.Service.Version, + "capabilities": description.Capabilities, + "endpoints": len(description.Endpoints), + "grpc_services": len(description.GRPCServices), + } +} + +func metadataEndpoints() []metadataEndpoint { + return []metadataEndpoint{ + {Method: http.MethodGet, Path: pathHealthz, ContentType: "text/plain"}, + {Method: http.MethodGet, Path: pathReadyz, ContentType: "application/json"}, + {Method: http.MethodGet, Path: pathWellKnown, ContentType: "application/json"}, + } +} + +func writeJSON(writer http.ResponseWriter, value any) { + writer.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(writer).Encode(value); err != nil { + http.Error(writer, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } +} diff --git a/cmd/nucleus/internal/serve/server_test.go b/cmd/nucleus/internal/serve/server_test.go new file mode 100644 index 0000000..df4b1d8 --- /dev/null +++ b/cmd/nucleus/internal/serve/server_test.go @@ -0,0 +1,113 @@ +package serve + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" +) + +func TestHandlerExposesMetadataEndpoints(t *testing.T) { + dir := writeServiceFixture(t) + result := buildResult(Config{Dir: &dir}, &options{addr: "127.0.0.1:9090", mode: modeServe}) + if !result.OK { + t.Fatalf("result should be OK: %#v", result.Diagnostics) + } + handler := newHandler(result.Description) + + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/healthz", nil)) + if recorder.Code != http.StatusOK { + t.Fatalf("/healthz status = %d, want 200; body=%s", recorder.Code, recorder.Body.String()) + } + if recorder.Body.String() != "OK\n" { + t.Fatalf("/healthz body = %q, want OK newline", recorder.Body.String()) + } + + recorder = httptest.NewRecorder() + handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/readyz", nil)) + if recorder.Code != http.StatusOK { + t.Fatalf("/readyz status = %d, want 200; body=%s", recorder.Code, recorder.Body.String()) + } + var ready map[string]any + if err := json.Unmarshal(recorder.Body.Bytes(), &ready); err != nil { + t.Fatalf("decode readyz: %v\n%s", err, recorder.Body.String()) + } + if ready["status"] != "ready" || ready["service"] != "demo" || ready["endpoints"] != float64(1) { + t.Fatalf("unexpected readyz payload: %#v", ready) + } + + recorder = httptest.NewRecorder() + handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/.well-known/nucleus.json", nil)) + if recorder.Code != http.StatusOK { + t.Fatalf("well-known status = %d, want 200; body=%s", recorder.Code, recorder.Body.String()) + } + var wellKnown map[string]any + if err := json.Unmarshal(recorder.Body.Bytes(), &wellKnown); err != nil { + t.Fatalf("decode well-known: %v\n%s", err, recorder.Body.String()) + } + service := requireMap(t, wellKnown, "service") + if service["name"] != "demo" { + t.Fatalf("well-known service.name = %v, want demo", service["name"]) + } +} + +func TestHandlerRejectsNonGETMetadataRequests(t *testing.T) { + dir := writeServiceFixture(t) + result := buildResult(Config{Dir: &dir}, &options{addr: defaultAddr, mode: modeServe}) + if !result.OK { + t.Fatalf("result should be OK: %#v", result.Diagnostics) + } + handler := newHandler(result.Description) + + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/readyz", nil)) + + if recorder.Code != http.StatusMethodNotAllowed { + t.Fatalf("POST /readyz status = %d, want 405", recorder.Code) + } +} + +func writeServiceFixture(t *testing.T) string { + t.Helper() + dir := t.TempDir() + writeFile(t, dir, "nucleus.yaml", `schema_version: "1.0" +service: + name: demo + version: "0.1.0" +capabilities: + - http +`) + writeFile(t, dir, "api/openapi.yaml", `openapi: 3.1.0 +info: + title: Demo + version: "0.1.0" +paths: + /hello: + get: + operationId: get_hello + responses: + "200": + description: OK +`) + writeFile(t, dir, "api/errors.yaml", `errors: + - code: 4001 + message: invalid + http_status: 400 +`) + writeFile(t, dir, "go.mod", "module example.com/demo\n\ngo 1.26.3\n") + return dir +} + +func writeFile(t *testing.T, dir string, name string, contents string) { + t.Helper() + path := filepath.Join(dir, filepath.FromSlash(name)) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("mkdir %s: %v", filepath.Dir(path), err) + } + if err := os.WriteFile(path, []byte(contents), 0o644); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} diff --git a/docs/concepts/ai-first-microservice-kernel.md b/docs/concepts/ai-first-microservice-kernel.md index d128016..3765a3e 100644 --- a/docs/concepts/ai-first-microservice-kernel.md +++ b/docs/concepts/ai-first-microservice-kernel.md @@ -17,3 +17,15 @@ The framework is therefore centered on contracts, manifests, schemas, and eviden `nucleus scenario` derives reviewable scenario plans from OpenAPI routes, error catalogs, and inspection flow facts. The default command produces suggestions only; it does not start services, inject providers, or make network calls. HTTP scenario execution is explicit evidence. A user or CI job must provide `--run-http --base-url ...` or `--cases ... --base-url ...`; the command then records request/response samples, assertion results, truncation metadata, and redacted headers. This keeps scenario checks aligned with the AI-safe loop without turning Nucleus into a hidden runtime harness or provider auto-wiring layer. + +## Local Metadata Serving + +`nucleus serve` is a metadata-only local server. It loads manifest and contract +facts through `contract/inspect`, then exposes `/healthz`, `/readyz`, and +`/.well-known/nucleus.json` for local probes. `--check` produces the same +auditable result envelope without listening, so agents and CI jobs can verify +the metadata surface before starting anything long-running. + +The command deliberately does not start generated business handlers or inject +capability providers. Application wiring remains responsible for runtime +assembly, while `serve` stays focused on local Nucleus metadata evidence. diff --git a/docs/concepts/serve-command.md b/docs/concepts/serve-command.md new file mode 100644 index 0000000..c93d894 --- /dev/null +++ b/docs/concepts/serve-command.md @@ -0,0 +1,96 @@ +# Serve Command + +`nucleus serve` runs a local metadata-only HTTP surface for a service directory. +It is intentionally narrow: the command loads manifest and contract facts through +`contract/inspect`, then exposes health, readiness, and well-known Nucleus +metadata endpoints. It does not wire provider SDKs, start generated business +handlers, or replace an application's own runtime assembly. + +## Modes + +The default mode is `serve`. It inspects the service, prints a startup result, +then listens on `--addr`: + +```bash +nucleus serve --dir . --addr 127.0.0.1:8080 +``` + +`--check` switches to inspection-only mode. It produces the same result envelope +without opening a listener, which makes it suitable for CI and agent preflight +checks: + +```bash +nucleus serve --dir . --check --json +``` + +## Endpoints + +The local metadata server exposes: + +- `GET /healthz`: liveness probe returning `OK` +- `GET /readyz`: compact JSON readiness summary +- `GET /.well-known/nucleus.json`: full `contract/inspect` service description + +Non-GET requests to these metadata endpoints return `405 Method Not Allowed`. + +## Output + +Human output is the default. `--json` emits a stable CLI result envelope: + +```json +{ + "result_kind": "nucleus.serve_result", + "schema_version": "serve.v1", + "schema_ref": "contract/schema/serve.schema.json", + "ok": true, + "mode": "check", + "summary": { + "status": "ready", + "service": "demo", + "version": "0.1.0", + "addr": "127.0.0.1:8080", + "served_paths": ["/healthz", "/readyz", "/.well-known/nucleus.json"], + "endpoint_count": 1, + "grpc_service_count": 0, + "capability_count": 1, + "generated_fresh": false, + "errors": 0, + "warnings": 0 + }, + "diagnostics": [], + "server": { + "listening": false, + "network_scope": "loopback", + "metadata_endpoints": [ + { + "method": "GET", + "path": "/healthz", + "content_type": "text/plain" + }, + { + "method": "GET", + "path": "/readyz", + "content_type": "application/json" + }, + { + "method": "GET", + "path": "/.well-known/nucleus.json", + "content_type": "application/json" + } + ] + } +} +``` + +Use `--pretty` with `--json` for indented JSON. Inspection failures are reported +as `serve.inspect_failed` diagnostics and return a non-zero exit code. By +default, `serve` refuses non-loopback bind addresses such as `0.0.0.0:8080`; +use `--allow-non-local` only when exposing local metadata beyond loopback is +intentional. + +## Scope + +`serve` is a local metadata command, not a framework host. Business routes should +remain owned by generated adapters and application wiring. Scenario execution +stays explicit through `nucleus scenario --run-http --base-url ...`, so Nucleus +continues to provide auditable evidence without hidden runtime auto-wiring. diff --git a/example/hello-http/api/errors.yaml b/example/hello-http/api/errors.yaml deleted file mode 100644 index 8aa243f..0000000 --- a/example/hello-http/api/errors.yaml +++ /dev/null @@ -1,7 +0,0 @@ -errors: - - code: 4001 - message: invalid_name - http_status: 400 - - code: 4041 - message: greeting_not_found - http_status: 404 diff --git a/example/hello-http/api/openapi.yaml b/example/hello-http/api/openapi.yaml deleted file mode 100644 index 67fec67..0000000 --- a/example/hello-http/api/openapi.yaml +++ /dev/null @@ -1,26 +0,0 @@ -openapi: 3.1.0 -info: - title: Hello HTTP Example - version: 0.1.0 -paths: - /hello/{name}: - parameters: - - name: name - in: path - required: true - schema: - type: string - get: - operationId: get_hello - x-nucleus-priority: 10 - parameters: - - name: trace_id - in: header - required: false - schema: - type: string - responses: - "200": - description: Greeting response. - "404": - description: Greeting target was not found. diff --git a/example/hello-http/configs/app.yaml b/example/hello-http/configs/app.yaml deleted file mode 100644 index 8f9f7d5..0000000 --- a/example/hello-http/configs/app.yaml +++ /dev/null @@ -1,4 +0,0 @@ -http: - address: "${HTTP_ADDR:-127.0.0.1:8080}" -greeting: - prefix: "hello" diff --git a/example/hello-http/contract/gen/.nucleus-source.sha256 b/example/hello-http/contract/gen/.nucleus-source.sha256 deleted file mode 100644 index c71a6ec..0000000 --- a/example/hello-http/contract/gen/.nucleus-source.sha256 +++ /dev/null @@ -1 +0,0 @@ -6399c0636543078a250f6ee503877d3a75637fe0242950245796a2299874b3a1 diff --git a/example/hello-http/contract/gen/contract_source.go b/example/hello-http/contract/gen/contract_source.go deleted file mode 100644 index c5d2337..0000000 --- a/example/hello-http/contract/gen/contract_source.go +++ /dev/null @@ -1,16 +0,0 @@ -package gen - -// ContractSourceHash is generated from contract source files. -const ContractSourceHash = "6399c0636543078a250f6ee503877d3a75637fe0242950245796a2299874b3a1" - -// EmbeddedContractSource is a generated contract source snapshot. -type EmbeddedContractSource struct { - Path string - Content string -} - -// EmbeddedContractSources contains generated contract source snapshots. -var EmbeddedContractSources = []EmbeddedContractSource{ - {Path: "api/errors.yaml", Content: "errors:\n - code: 4001\n message: invalid_name\n http_status: 400\n - code: 4041\n message: greeting_not_found\n http_status: 404\n"}, - {Path: "api/openapi.yaml", Content: "openapi: 3.1.0\ninfo:\n title: Hello HTTP Example\n version: 0.1.0\npaths:\n /hello/{name}:\n parameters:\n - name: name\n in: path\n required: true\n schema:\n type: string\n get:\n operationId: get_hello\n x-nucleus-priority: 10\n parameters:\n - name: trace_id\n in: header\n required: false\n schema:\n type: string\n responses:\n \"200\":\n description: Greeting response.\n \"404\":\n description: Greeting target was not found.\n"}, -} diff --git a/example/hello-http/contract/gen/endpoints.go b/example/hello-http/contract/gen/endpoints.go deleted file mode 100644 index c332584..0000000 --- a/example/hello-http/contract/gen/endpoints.go +++ /dev/null @@ -1,13 +0,0 @@ -package gen - -// Endpoint is generated from OpenAPI. -type Endpoint struct { - Method string - Path string - OperationID string -} - -// Endpoints lists generated HTTP endpoint metadata. -var Endpoints = []Endpoint{ - {Method: "GET", Path: "/hello/{name}", OperationID: "get_hello"}, -} diff --git a/example/hello-http/contract/gen/errors.go b/example/hello-http/contract/gen/errors.go deleted file mode 100644 index 69f9d81..0000000 --- a/example/hello-http/contract/gen/errors.go +++ /dev/null @@ -1,21 +0,0 @@ -package gen - -// Code is a generated Nucleus error code. -type Code int - -const ( - CodeInvalidName Code = 4001 - CodeGreetingNotFound Code = 4041 -) - -// ErrorMessages maps generated error codes to stable messages. -var ErrorMessages = map[Code]string{ - CodeInvalidName: "invalid_name", - CodeGreetingNotFound: "greeting_not_found", -} - -// HTTPStatuses maps generated error codes to HTTP statuses. -var HTTPStatuses = map[Code]int{ - CodeInvalidName: 400, - CodeGreetingNotFound: 404, -} diff --git a/example/hello-http/describe_test.go b/example/hello-http/describe_test.go deleted file mode 100644 index b3a6cc5..0000000 --- a/example/hello-http/describe_test.go +++ /dev/null @@ -1,84 +0,0 @@ -package hellohttp_test - -import ( - "encoding/json" - "os" - "os/exec" - "path/filepath" - "runtime" - "testing" -) - -func TestNucleusDescribeExample(t *testing.T) { - exampleDir := exampleRoot(t) - repoRoot := filepath.Clean(filepath.Join(exampleDir, "..", "..")) - - cmd := exec.Command("go", "run", filepath.Join(repoRoot, "cmd", "nucleus"), "describe", "--dir", exampleDir, "--json", "--flow", "--schema", "example-test") - cmd.Dir = repoRoot - cmd.Env = append(os.Environ(), "GOWORK=off") - output, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("nucleus describe failed: %v\n%s", err, output) - } - - var description map[string]any - if err := json.Unmarshal(output, &description); err != nil { - t.Fatalf("decode describe output: %v\n%s", err, output) - } - - service := requireMap(t, description, "service") - if service["name"] != "hello-http" { - t.Fatalf("service.name = %v, want hello-http", service["name"]) - } - if description["schema_version"] != "example-test" { - t.Fatalf("schema_version = %v, want example-test", description["schema_version"]) - } - - endpoints := requireSlice(t, description, "endpoints") - if len(endpoints) != 1 { - t.Fatalf("len(endpoints) = %d, want 1", len(endpoints)) - } - endpoint, ok := endpoints[0].(map[string]any) - if !ok { - t.Fatalf("endpoint has type %T, want map[string]any", endpoints[0]) - } - if endpoint["operation_id"] != "get_hello" { - t.Fatalf("endpoint.operation_id = %v, want get_hello", endpoint["operation_id"]) - } - - flowGraph := requireMap(t, description, "flow_graph") - if len(requireSlice(t, flowGraph, "nodes")) == 0 { - t.Fatal("flow_graph.nodes is empty") - } - errorPaths := requireSlice(t, flowGraph, "error_paths") - if len(errorPaths) != 2 { - t.Fatalf("len(flow_graph.error_paths) = %d, want 2", len(errorPaths)) - } -} - -func exampleRoot(t *testing.T) string { - t.Helper() - _, filename, _, ok := runtime.Caller(0) - if !ok { - t.Fatal("runtime.Caller failed") - } - return filepath.Dir(filename) -} - -func requireMap(t *testing.T, value map[string]any, key string) map[string]any { - t.Helper() - item, ok := value[key].(map[string]any) - if !ok { - t.Fatalf("%s has type %T, want map[string]any", key, value[key]) - } - return item -} - -func requireSlice(t *testing.T, value map[string]any, key string) []any { - t.Helper() - item, ok := value[key].([]any) - if !ok { - t.Fatalf("%s has type %T, want []any", key, value[key]) - } - return item -} diff --git a/example/hello-http/go.mod b/example/hello-http/go.mod deleted file mode 100644 index c0668d9..0000000 --- a/example/hello-http/go.mod +++ /dev/null @@ -1,7 +0,0 @@ -module github.com/nucleuskit/nucleus/example/hello-http - -go 1.26.3 - -require github.com/nucleuskit/http v0.0.0 - -replace github.com/nucleuskit/http => ../../runtime/http diff --git a/example/hello-http/internal/adapter/http/gen/.nucleus-source.sha256 b/example/hello-http/internal/adapter/http/gen/.nucleus-source.sha256 deleted file mode 100644 index c71a6ec..0000000 --- a/example/hello-http/internal/adapter/http/gen/.nucleus-source.sha256 +++ /dev/null @@ -1 +0,0 @@ -6399c0636543078a250f6ee503877d3a75637fe0242950245796a2299874b3a1 diff --git a/example/hello-http/internal/adapter/http/gen/handler.gen.go b/example/hello-http/internal/adapter/http/gen/handler.gen.go deleted file mode 100644 index 070691a..0000000 --- a/example/hello-http/internal/adapter/http/gen/handler.gen.go +++ /dev/null @@ -1,9 +0,0 @@ -package gen - -import "net/http" - -// Handler is implemented by the handwritten HTTP adapter. -type Handler interface { - // GetHello handles the get_hello operation. - GetHello(request *http.Request) (any, error) -} diff --git a/example/hello-http/internal/adapter/http/gen/routes.gen.go b/example/hello-http/internal/adapter/http/gen/routes.gen.go deleted file mode 100644 index 48a18cd..0000000 --- a/example/hello-http/internal/adapter/http/gen/routes.gen.go +++ /dev/null @@ -1,19 +0,0 @@ -package gen - -import ( - "net/http" - - runtimehttp "github.com/nucleuskit/http" -) - -// RegisterRoutes binds generated OpenAPI routes to a handwritten handler. -func RegisterRoutes(server *runtimehttp.Server, handler Handler) { - if server == nil || handler == nil { - return - } - server.RegisterRoutes([]runtimehttp.Route{ - {Method: "GET", Path: "/hello/{name}", OperationID: "get_hello", Handler: func(request *http.Request) (any, error) { - return handler.GetHello(request) - }}, - }) -} diff --git a/example/hello-http/internal/adapter/http/gen/types.gen.go b/example/hello-http/internal/adapter/http/gen/types.gen.go deleted file mode 100644 index a91d3c7..0000000 --- a/example/hello-http/internal/adapter/http/gen/types.gen.go +++ /dev/null @@ -1,25 +0,0 @@ -package gen - -// RouteParameter is generated OpenAPI route parameter metadata. -type RouteParameter struct { - Name string - In string - Required bool - SchemaType string -} - -// ServerRoute is generated HTTP route metadata for adapter registration. -type ServerRoute struct { - Method string - Path string - OperationID string - HandlerName string - Parameters []RouteParameter - RequestBodyRequired bool - Priority int -} - -// ServerRoutes lists generated HTTP server route metadata. -var ServerRoutes = []ServerRoute{ - {Method: "GET", Path: "/hello/{name}", OperationID: "get_hello", HandlerName: "GetHello", Parameters: []RouteParameter{{Name: "name", In: "path", Required: true, SchemaType: "string"}, {Name: "trace_id", In: "header", Required: false, SchemaType: "string"}}, RequestBodyRequired: false, Priority: 10}, -} diff --git a/example/hello-http/internal/app/routes.go b/example/hello-http/internal/app/routes.go deleted file mode 100644 index 100c447..0000000 --- a/example/hello-http/internal/app/routes.go +++ /dev/null @@ -1,19 +0,0 @@ -package app - -import "net/http" - -type Router struct{} - -func (Router) Handle(method string, path string, handler func()) {} - -type Logger struct{} - -func (Logger) Info() {} - -var log Logger - -func RegisterRoutes(router Router) { - router.Handle(http.MethodGet, "/hello/{name}", func() { - log.Info() - }) -} diff --git a/example/hello-http/nucleus.yaml b/example/hello-http/nucleus.yaml deleted file mode 100644 index d66d577..0000000 --- a/example/hello-http/nucleus.yaml +++ /dev/null @@ -1,33 +0,0 @@ -schema_version: "1.0" -service: - name: hello-http - version: "0.1.0" - env: example - owner: nucleus-maintainers - tier: example - namespace: examples - description: Minimal contract-first HTTP service used by CLI describe tests. -capabilities: - - http - - log -dependencies: - - name: greeting-profile - contract: api/openapi.yaml#/paths/~1hello~1{name} - required: false -ai: - intent: Exercise nucleus describe against a runnable example service. - allowed_changes: - - api/** - - configs/** - - internal/** - readonly: - - internal/adapter/http/gen/** - generated: - - contract/gen - - internal/adapter/http/gen - forbidden: - - configs/*.local.yaml -nucleus: - providers: - log: - provider: noop diff --git a/examples/agent/codex/SKILL.md b/examples/agent/codex/SKILL.md new file mode 100644 index 0000000..655d464 --- /dev/null +++ b/examples/agent/codex/SKILL.md @@ -0,0 +1,98 @@ +--- +name: nucleus +description: Use when creating, inspecting, modifying, verifying, or repairing Go services built with Nucleus, the Contract/Manifest-first AI-native Go service kernel. Triggers include Nucleus business services, nucleus.yaml, api/openapi.yaml, api/errors.yaml, api/proto, capability declarations, edit surfaces, generated freshness, executable plans, and Nucleus CLI/MCP workflows. +--- + +# Nucleus + +## Start Here + +Treat Nucleus as an installed service-kernel tool. Do not derive business code by copying a local Nucleus source checkout, examples, or testdata. + +For an empty directory, bootstrap through the CLI: + +```bash +nucleus init --name --module --template service --agent codex --dir . +``` + +Use `--template worker` for worker services and `--template library` for library packages. + +For an existing service, inspect machine-readable facts before editing: + +```bash +nucleus describe --dir . --json --pretty +nucleus plan --dir . --task "" --json --executable +``` + +Read `edit_surfaces`, `generated_freshness`, `capability_graph`, `verification.commands`, `edits[]`, and `blocked_edits[]`. + +## Core Workflow + +1. Inspect the service with `nucleus describe --dir . --json --pretty`. +2. Plan the requested change with `nucleus plan --dir . --task "" --json --executable`. +3. Apply Contract-First edits for external behavior: + - HTTP starts in `api/openapi.yaml`. + - gRPC starts in `api/proto/*.proto`. + - Error behavior starts in `api/errors.yaml`. +4. Apply Manifest-First edits for service identity, dependencies, capabilities, providers, and AI edit boundaries in `nucleus.yaml`. +5. Write only paths allowed by `describe.edit_surfaces.allowed`. +6. For an existing capability scaffold, prefer the CLI entrypoint: + + ```bash + nucleus capability add --provider --dir . + ``` + + When MCP is available, call `get_capability_recipe` first and follow its `scaffold_command` / `bridge_candidates[].scaffold_command`. The CLI supports every registered Nucleus capability. Some providers generate deep wiring, while others create a manifest/provider placeholder that must be replaced with real bridge construction in `internal/app`. For PostgreSQL persistence, use `nucleus capability add sql --provider postgres --dir .`. `sql` is the capability; `postgres` is a provider/bridge choice. MongoDB is not SQL; use `mongo` capability flows when available. +7. Regenerate instead of manually editing generated files: + + ```bash + nucleus gen --dir . + ``` + +8. Verify with the service's declared commands, plus the Nucleus defaults when applicable: + + ```bash + nucleus validate --dir . + nucleus lint --dir . --strict + nucleus verify --dir . --json + go test ./... + ``` + +9. Repair only from evidence and only inside allowed edit surfaces: + + ```bash + nucleus repair --dir . --from-evidence evidence.json --max-rounds 1 + ``` + +10. Report changed files, commands run, evidence, residual risks, and any manual follow-up. + +## Hard Stops + +Stop and report the blocker instead of guessing when: + +- `nucleus.yaml` is missing and required bootstrap inputs are unavailable. +- A requested edit falls outside `describe.edit_surfaces.allowed`. +- The plan includes `blocked_edits[]` that would be required for the task. +- A Manifest-First capability/provider change requires `nucleus.yaml` but it is blocked; do not add provider imports or app wiring as a workaround. +- HTTP, gRPC, or error behavior is requested without contract edits. +- Capability code is added without a matching `nucleus.yaml` declaration. +- A generated file would need manual editing. +- Verification commands are unknown, unavailable, or failing for unrelated reasons. +- Nucleus MCP output disagrees with CLI/schema output. + +## Resource Map + +Load only the reference needed for the task: + +- `references/workflow.md`: end-to-end workflows for empty repos, existing services, feature changes, fixes, and handoff. +- `references/boundaries.md`: layer rules, capability protocol, edit surfaces, directory rules, and hard-stop details. +- `references/commands.md`: Nucleus CLI command selection and expected usage. +- `references/mcp.md`: how to use Nucleus MCP as a tool layer without bypassing CLI/schema facts. + +Optional helper: + +```bash +python3 /scripts/nucleus_preflight.py --dir . --task "" --out .tmp/nucleus-preflight +``` + +The helper only runs Nucleus CLI inspection/planning commands and writes local evidence files. diff --git a/examples/agent/codex/agents/openai.yaml b/examples/agent/codex/agents/openai.yaml new file mode 100644 index 0000000..9928934 --- /dev/null +++ b/examples/agent/codex/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Nucleus" + short_description: "Safe Nucleus service workflow" + default_prompt: "Use $nucleus to safely change a Nucleus Go service." diff --git a/examples/agent/codex/references/boundaries.md b/examples/agent/codex/references/boundaries.md new file mode 100644 index 0000000..fef9e74 --- /dev/null +++ b/examples/agent/codex/references/boundaries.md @@ -0,0 +1,58 @@ +# Nucleus Boundary Reference + +## Product Boundary + +Nucleus is an AI-native Go service kernel: Contract/Manifest SSOT, thin service kernel, capability protocol, runtime adapters, and safe AI editing workflow. + +It is not a general application scaffold, UI wizard, business domain model, middleware bundle, or compatibility layer for unrelated frameworks. + +## Layer Rules + +- `core/*`: standard library only. +- `cap/*`: capability interfaces, options, and noop implementations. +- `bridge/*`: optional provider implementations of capabilities. +- `runtime/*`: transport assembly over `core` and `cap`; do not import `bridge`. +- business `domain/*`: depend on domain ports and core concepts only; do not import transport frameworks or provider bridges. +- business `internal/app`: assemble runtime, capabilities, providers, and bridges. + +## Contract-First + +External behavior starts in contracts: + +- HTTP: `api/openapi.yaml` +- gRPC: `api/proto/*.proto` +- errors: `api/errors.yaml` + +Run generation after contract edits. Treat generated files as readonly unless a Nucleus command regenerated them. + +## Manifest-First + +Service identity, capabilities, dependencies, providers, and AI edit boundaries start in `nucleus.yaml`. + +Capability imports and app wiring must match manifest declarations. Adding provider code without manifest declaration is drift. + +For business services, adding an existing capability means updating `nucleus.yaml` first, adding safe placeholder config, wiring providers only in `internal/app`, and verifying L004 with `nucleus lint --dir . --strict`. Use `nucleus capability add --provider --dir .` when the CLI has a scaffold. + +`sql` is the relational database capability. PostgreSQL, generic `database/sql`, and GORM are provider/bridge choices for `cap/sql`; MongoDB is a separate document-store capability and should use `cap/mongo`. + +## Edit Surfaces + +Use `nucleus describe --dir . --json --pretty` before writing. + +- `allowed`: maximum write surface. +- `readonly`: generated or reference output; update through generation. +- `forbidden`: never write. + +If the task cannot be completed inside allowed surfaces, stop and ask for an explicit boundary change. + +If a Manifest-First change requires `nucleus.yaml` and that file is blocked, stop at `blocked_edits[]`. Do not add imports, provider wiring, or repository code as a workaround for an undeclared capability. + +## Directory Rules + +Business service top-level directories come from that repository's `AGENTS.md`. Register a new top-level directory there before creating it. + +Do not apply Nucleus kernel `.codex/steering/*.md` as business-service structure rules. + +## Secrets + +Do not add secrets, plaintext DSNs, tokens, local credentials, or environment-specific private values. Use placeholders and documentation. diff --git a/examples/agent/codex/references/commands.md b/examples/agent/codex/references/commands.md new file mode 100644 index 0000000..554d7c2 --- /dev/null +++ b/examples/agent/codex/references/commands.md @@ -0,0 +1,74 @@ +# Nucleus Command Reference + +## Inspect + +```bash +nucleus describe --dir . --json --pretty +``` + +Use this before editing. Read edit surfaces, generated freshness, capability graph, endpoints, errors, dependencies, and verification commands. + +## Plan + +```bash +nucleus plan --dir . --task "" --json --executable +``` + +Use this to identify candidate edits, blocked edits, generated outputs, risk, and command order. + +## Generate + +```bash +nucleus gen --dir . +``` + +Run after contract changes. Do not manually edit generated files that should be produced by this command. + +## Capability Scaffold + +```bash +nucleus capability add --provider --dir . +``` + +Use this before manual provider wiring when adding a declared Nucleus capability. If MCP is available, first call `get_capability_recipe` and use its `scaffold_command` or provider-specific `bridge_candidates[].scaffold_command`. + +## Validate And Lint + +```bash +nucleus validate --dir . +nucleus lint --dir . --strict +``` + +Use `validate` for YAML/OpenAPI shape and `lint` for Nucleus rules such as contract drift, layer boundaries, manifest/capability consistency, and generated freshness. + +## Verify + +```bash +nucleus verify --dir . --json +``` + +Use this as the main machine-readable verification evidence. Also run any stricter commands listed under `describe.verification.commands`. + +## Execute + +```bash +nucleus execute --dir . --plan plan.json --allow-command "" +``` + +Use only when an executable plan explicitly includes controlled commands and the command is allowlisted. + +## Repair + +```bash +nucleus repair --dir . --from-evidence evidence.json --max-rounds 1 +``` + +Use only from verification evidence. Do not repair through forbidden or readonly surfaces. + +## Report + +```bash +nucleus report --dir . --platform --json +``` + +Use when the task needs platform readiness metadata, quality metrics, or a structured handoff summary. diff --git a/examples/agent/codex/references/mcp.md b/examples/agent/codex/references/mcp.md new file mode 100644 index 0000000..632ed20 --- /dev/null +++ b/examples/agent/codex/references/mcp.md @@ -0,0 +1,49 @@ +# Nucleus MCP Reference + +Nucleus MCP is a tool layer over CLI/schema facts. Use it for quick access to service facts, not as a separate source of truth. + +## Preferred Order + +1. Use CLI/schema commands to understand the contract: + + ```bash + nucleus describe --dir . --json --pretty + nucleus plan --dir . --task "" --json --executable + ``` + +2. Use MCP tools for targeted facts: + - describe service + - endpoints + - error catalog + - capability recipes and scaffold commands + - flow graph + - executable plan + - verification or repair wrappers + +3. Return to CLI commands for generation, lint, verification, repair, and report evidence. + +## Capability Recipes + +Use `get_capability_recipe` before adding or changing a capability. Read these fields: + +- `scaffold_supported`: whether `nucleus capability add` knows this capability. +- `scaffold_command`: default CLI command for the capability. +- `bridge_candidates[].scaffold_command`: provider-specific CLI commands. +- `wiring`: layer boundary guidance for app-level provider injection. + +Run the scaffold command before manual provider wiring unless the command is unavailable or the manifest/edit surface blocks it. + +## Consistency Rule + +If MCP and CLI/schema disagree, trust CLI/schema and record the mismatch in the handoff. + +## Safety Rule + +MCP tools must not bypass: + +- Contract-First edits +- Manifest-First edits +- allowed edit surfaces +- generated freshness +- verification commands +- evidence and rollback requirements diff --git a/examples/agent/codex/references/workflow.md b/examples/agent/codex/references/workflow.md new file mode 100644 index 0000000..d824ad5 --- /dev/null +++ b/examples/agent/codex/references/workflow.md @@ -0,0 +1,87 @@ +# Nucleus Workflow Reference + +## Empty Repository + +Use CLI bootstrap only: + +```bash +nucleus init --name --module --template service --agent codex --dir . +``` + +Ask for only missing bootstrap inputs: + +- service name +- Go module path +- template type: `service`, `worker`, or `library` + +Do not inspect or copy local Nucleus examples, testdata, templates, or source checkouts to derive business code. + +## Existing Service + +Start with: + +```bash +nucleus describe --dir . --json --pretty +nucleus plan --dir . --task "" --json --executable +``` + +Use `describe` to determine current facts: + +- service identity and tier +- endpoints and gRPC services +- error codes +- dependencies +- capability graph +- allowed, readonly, and forbidden edit surfaces +- generated freshness +- verification commands + +Use `plan --executable` to determine the candidate write set. Treat `blocked_edits[]` as a hard stop unless the user explicitly changes the service edit boundaries. Do not continue by adding provider imports or wiring outside the manifest path. + +## HTTP Or gRPC Behavior Change + +Apply contract edits before implementation: + +1. Update `api/openapi.yaml` for HTTP behavior. +2. Update `api/proto/*.proto` for gRPC behavior. +3. Update `api/errors.yaml` for new or changed errors. +4. Run `nucleus gen --dir .`. +5. Implement domain, adapter, or app wiring in allowed edit surfaces. +6. Run lint and verification. + +Do not hand-write routes that drift from OpenAPI. Do not invent error codes outside `api/errors.yaml`. + +## Capability Or Provider Change + +Apply manifest edits before code: + +1. Update `nucleus.yaml` capability declarations, dependencies, providers, or edit boundaries. +2. If MCP is available, call `get_capability_recipe` and use its `scaffold_command` or provider-specific `bridge_candidates[].scaffold_command`. +3. Prefer a scaffold command when available, for example `nucleus capability add sql --provider postgres --dir .`. +4. Add or adjust app wiring in the service assembly layer. +5. Keep `domain` independent of provider SDKs and transport frameworks. +6. Verify manifest/import consistency with `nucleus lint --dir . --strict`. + +SQL providers such as PostgreSQL and MySQL belong to `cap/sql`. MongoDB is a separate document-store capability and must not be modeled as SQL. + +## Fix Or Repair + +Use evidence first: + +```bash +nucleus verify --dir . --json > evidence.json +nucleus repair --dir . --from-evidence evidence.json --max-rounds 1 +``` + +Repair is limited to allowed edit surfaces. If the failure requires readonly or forbidden files, missing business intent, secrets, or unclear rollback, report `needs_manual_action`. + +## Handoff + +Summarize: + +- changed files +- contract and manifest changes +- generated outputs +- verification commands and results +- residual risks +- manual follow-ups diff --git a/examples/agent/codex/scripts/nucleus_preflight.py b/examples/agent/codex/scripts/nucleus_preflight.py new file mode 100755 index 0000000..f541a1a --- /dev/null +++ b/examples/agent/codex/scripts/nucleus_preflight.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +"""Run Nucleus describe and optional executable plan preflight.""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +from pathlib import Path + + +def run_json(command: list[str]) -> dict: + completed = subprocess.run( + command, + check=False, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + if completed.returncode != 0: + sys.stderr.write(completed.stderr) + raise SystemExit(completed.returncode) + try: + return json.loads(completed.stdout) + except json.JSONDecodeError as exc: + sys.stderr.write(f"Command did not return JSON: {' '.join(command)}\n{exc}\n") + raise SystemExit(2) from exc + + +def ensure_describe_shape(describe: dict) -> None: + missing = [ + key + for key in ("edit_surfaces", "generated_freshness", "capability_graph", "verification") + if key not in describe + ] + if missing: + sys.stderr.write("describe output missing required keys: " + ", ".join(missing) + "\n") + raise SystemExit(2) + + +def write_json(path: Path, value: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(value, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--dir", default=".", help="Nucleus service directory") + parser.add_argument("--task", default="", help="Optional task for executable planning") + parser.add_argument("--out", default="", help="Optional directory for JSON evidence output") + parser.add_argument("--nucleus-bin", default="nucleus", help="Nucleus CLI executable") + args = parser.parse_args() + + describe = run_json([args.nucleus_bin, "describe", "--dir", args.dir, "--json", "--pretty"]) + ensure_describe_shape(describe) + + plan = None + if args.task: + plan = run_json( + [ + args.nucleus_bin, + "plan", + "--dir", + args.dir, + "--task", + args.task, + "--json", + "--executable", + ] + ) + + if args.out: + out_dir = Path(args.out) + write_json(out_dir / "describe.json", describe) + if plan is not None: + write_json(out_dir / "plan.json", plan) + + edit_surfaces = describe.get("edit_surfaces") or {} + verification = describe.get("verification") or {} + summary = { + "service": describe.get("service", {}), + "allowed": edit_surfaces.get("allowed", []), + "readonly": edit_surfaces.get("readonly", []), + "forbidden": edit_surfaces.get("forbidden", []), + "verification_commands": verification.get("commands", []), + } + if plan is not None: + summary["plan_task_type"] = plan.get("task_type") + summary["planned_edits"] = plan.get("edits", plan.get("suggested_edits", [])) + summary["blocked_edits"] = plan.get("blocked_edits", []) + + print(json.dumps(summary, indent=2, ensure_ascii=False)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/deploy/docker/README.md b/examples/deploy/docker/README.md new file mode 100644 index 0000000..86e3937 --- /dev/null +++ b/examples/deploy/docker/README.md @@ -0,0 +1,35 @@ +# Nucleus 本地平台栈 + +该目录提供本地开发用 compose 示例,用于验证 Nucleus 能力接入协议和平台字段映射,不是生产部署模板。 + +## 启动 + +```bash +docker compose -f deploy/docker/docker-compose.yml up -d +``` + +或使用仓库 Make 目标: + +```bash +make docker-up +make docker-down +``` + +## 服务 + +| 服务 | 地址 | 用途 | +|------|------|------| +| Nacos | http://localhost:8848/nacos | 配置和注册示例 | +| Redis | localhost:6379 | 缓存、锁能力示例 | +| Kafka | localhost:9092 | MQ 能力示例 | +| OTel Collector | localhost:4317 / localhost:4318 | OTLP gRPC/HTTP | +| Jaeger | http://localhost:16686 | 本地 trace 查询 | +| Prometheus | http://localhost:9090 | 指标查询 | + +## 配置约束 + +- 不包含生产密钥,Nacos auth 在本地示例中关闭。 +- Redis 未设置密码,仅绑定本机开发端口。 +- Kafka 使用单节点 KRaft 模式,仅适合本地验证。 +- 生产环境应由平台注入 Secret、采样策略、持久化和网络策略。 + diff --git a/examples/deploy/docker/docker-compose.yml b/examples/deploy/docker/docker-compose.yml new file mode 100644 index 0000000..d4d4a5f --- /dev/null +++ b/examples/deploy/docker/docker-compose.yml @@ -0,0 +1,96 @@ +name: nucleus-platform-local + +services: + nacos: + image: nacos/nacos-server:v2.4.3 + container_name: nucleus-nacos + environment: + MODE: standalone + PREFER_HOST_MODE: hostname + NACOS_AUTH_ENABLE: "false" + ports: + - "8848:8848" + - "9848:9848" + networks: + - nucleus + + redis: + image: redis:7.2-alpine + container_name: nucleus-redis + command: ["redis-server", "--appendonly", "yes"] + ports: + - "6379:6379" + volumes: + - redis-data:/data + networks: + - nucleus + + kafka: + image: bitnami/kafka:3.7 + container_name: nucleus-kafka + environment: + ALLOW_PLAINTEXT_LISTENER: "yes" + KAFKA_CFG_NODE_ID: "1" + KAFKA_CFG_PROCESS_ROLES: controller,broker + KAFKA_CFG_CONTROLLER_QUORUM_VOTERS: 1@kafka:9093 + KAFKA_CFG_LISTENERS: PLAINTEXT://:9092,CONTROLLER://:9093 + KAFKA_CFG_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092 + KAFKA_CFG_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT + KAFKA_CFG_CONTROLLER_LISTENER_NAMES: CONTROLLER + KAFKA_CFG_INTER_BROKER_LISTENER_NAME: PLAINTEXT + ports: + - "9092:9092" + volumes: + - kafka-data:/bitnami/kafka + networks: + - nucleus + + jaeger: + image: jaegertracing/all-in-one:1.57 + container_name: nucleus-jaeger + environment: + COLLECTOR_OTLP_ENABLED: "true" + ports: + - "16686:16686" + networks: + - nucleus + + otel-collector: + image: otel/opentelemetry-collector-contrib:0.105.0 + container_name: nucleus-otel-collector + command: ["--config=/etc/otelcol/config.yaml"] + volumes: + - ./otel-collector-config.yaml:/etc/otelcol/config.yaml:ro + ports: + - "4317:4317" + - "4318:4318" + - "8889:8889" + depends_on: + - jaeger + networks: + - nucleus + + prometheus: + image: prom/prometheus:v2.53.0 + container_name: nucleus-prometheus + command: + - "--config.file=/etc/prometheus/prometheus.yml" + - "--storage.tsdb.retention.time=24h" + volumes: + - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro + - prometheus-data:/prometheus + ports: + - "9090:9090" + depends_on: + - otel-collector + networks: + - nucleus + +networks: + nucleus: + name: nucleus-platform-local + +volumes: + redis-data: + kafka-data: + prometheus-data: diff --git a/examples/deploy/docker/otel-collector-config.yaml b/examples/deploy/docker/otel-collector-config.yaml new file mode 100644 index 0000000..1634dfa --- /dev/null +++ b/examples/deploy/docker/otel-collector-config.yaml @@ -0,0 +1,32 @@ +receivers: + otlp: + protocols: + grpc: + endpoint: 0.0.0.0:4317 + http: + endpoint: 0.0.0.0:4318 + +processors: + batch: {} + +exporters: + debug: + verbosity: basic + otlp/jaeger: + endpoint: jaeger:4317 + tls: + insecure: true + prometheus: + endpoint: 0.0.0.0:8889 + +service: + pipelines: + traces: + receivers: [otlp] + processors: [batch] + exporters: [debug, otlp/jaeger] + metrics: + receivers: [otlp] + processors: [batch] + exporters: [debug, prometheus] + diff --git a/examples/deploy/docker/prometheus.yml b/examples/deploy/docker/prometheus.yml new file mode 100644 index 0000000..ff4744c --- /dev/null +++ b/examples/deploy/docker/prometheus.yml @@ -0,0 +1,9 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s + +scrape_configs: + - job_name: otel-collector + static_configs: + - targets: ["otel-collector:8889"] + diff --git a/examples/library/README.md b/examples/library/README.md new file mode 100644 index 0000000..c134c93 --- /dev/null +++ b/examples/library/README.md @@ -0,0 +1,9 @@ +# Nucleus Library Template + +Generated by: + +```bash +nucleus init --template library --name demo-lib --module example.com/demo-lib --dir ./demo-lib +``` + +The generated library is intentionally runtime-free and can pass `go test ./...` without Nucleus runtime imports. diff --git a/examples/service/README.md b/examples/service/README.md new file mode 100644 index 0000000..66f10e3 --- /dev/null +++ b/examples/service/README.md @@ -0,0 +1,19 @@ +# Nucleus Service Template + +Generated by: + +```bash +nucleus init --template service --name demo-api --module example.com/demo-api --dir ./demo-api +``` + +The generated service includes: + +- `api/openapi.yaml` and `api/errors.yaml` as contract SSOT. +- `contract/gen` for contract facts and freshness markers. +- `internal/adapter/http/gen` for generated HTTP route binders and handler interfaces. +- `cmd//main.go` as process entry only. +- `internal/app`, `internal/config`, `internal/domain`, `internal/usecase`, `internal/adapter`, `internal/component`, and `internal/server` as handwritten service surfaces. +- `configs/config.example.yaml`, `docs/`, `deploy/`, and `test/integration/` scaffolding. +- `AGENTS.md` with business-service edit rules and top-level directory registration. + +Run `nucleus gen --dir .`, `nucleus lint --dir . --strict`, `nucleus verify --dir . --json`, and `go test ./...` after contract or implementation changes. diff --git a/examples/worker/README.md b/examples/worker/README.md new file mode 100644 index 0000000..f58c525 --- /dev/null +++ b/examples/worker/README.md @@ -0,0 +1,9 @@ +# Nucleus Worker Template + +Generated by: + +```bash +nucleus init --template worker --name demo-worker --module example.com/demo-worker --dir ./demo-worker +``` + +The generated worker includes `nucleus.yaml`, minimal contracts, and a worker entrypoint.