Files
zeekayandHanzo Dev 170e572463 cli: the registry is the CLI, and the doc comment is what describes it
One registry, three projections. A typed op already produced a REST route, an
OpenAPI path and an MCP tool; it now also produces a command. cli.go derives
`<service> <operation>` from the op's own identity, so adding a route can never
rename an existing command, and LocalInvoke runs it through the op's own invoke
seam — a fused binary needs no client because the command IS the handler call.
clispec.go builds the same commands from a served spec, which is what lets the
CLI drive a service it was not linked against.

cmd/zipdoc + internal/zipdoc close the gap Go leaves: doc comments do not exist
at run time, so prose reaches the spec only via a build-time AST pass. Field
docs and examples are extracted from source and keyed "<Type>.<field>".

Two defects found by making the projections agree:

commandName treated "ends in a parameter" as the only way to address one thing,
so GET /v1/paas/apps/:app/deploy became apps-deploy-LIST — named for a
collection, returning one deploy. The route already says which it is past its
parameter: /apps/:app/deploy is singular, /nodes/:node/jobs is plural.

OpenAPI parameters carried no description while the registry had one, so the
same argument had help in-process and none from the spec. Both now read the
same key, and query parameters get it too.

internal/jsontag exists so the reflect view of the wire-name rule and the AST
view cannot drift; transport gains a dial-only https, because reaching a
published API is an ordinary Mount and terminating TLS stays the ingress's job.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-27 17:02:36 -07:00

103 lines
3.0 KiB
Go

// Command zipdoc makes a doc comment the spec.
//
// It walks a package for typed registrations — zip.Get[In,Out](app, path, fn)
// and its Post/Put/Patch/Delete siblings — takes the doc comment on each handler
// and on each field of its In and Out types, and writes a file that hands them
// to zip.Describe at init. From there they are the operation's description, its
// fields' descriptions and its request/response examples in the OpenAPI spec
// zip serves, and in the MCP tools it exposes.
//
// The point is that nothing is written twice. A summary passed to WithSummary
// sitting under a doc comment that says the same thing is two places to change
// and one to forget; this pass removes the second place.
//
// Put this above the package's route registrations:
//
// //go:generate zipdoc
//
// and write handlers the way Go already asks you to:
//
// // ListInvoices returns every invoice for the caller's org, newest first.
// //
// // Example: {"org": "hanzo", "limit": 25}
// // Response: {"invoices": [{"id": "inv_1", "cents": 1200}]}
// func ListInvoices(ctx context.Context, in *ListIn) (*ListOut, error)
//
// A malformed Example or Response fails generation rather than shipping a spec
// that is wrong.
//
// Usage:
//
// zipdoc [-o name] [-check] [packages]
//
// With -check nothing is written and a file that no longer matches its source is
// an error. That is the CI gate: the spec is derived from the code, so it cannot
// drift from it.
package main
import (
"flag"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/zap-proto/zip/internal/zipdoc"
)
func main() {
name := flag.String("o", zipdoc.DefaultFile, "name of the generated file, written in each package's own directory")
check := flag.Bool("check", false, "write nothing; fail if a generated file is out of date")
flag.Usage = func() {
fmt.Fprint(os.Stderr, "usage: zipdoc [-o name] [-check] [packages]\n\n"+
"Extracts the doc comments on zip's typed handlers into zip.Describe calls.\n"+
"Packages default to the current directory, as `go generate` runs it.\n\n")
flag.PrintDefaults()
}
flag.Parse()
if err := run(*name, *check, flag.Args()); err != nil {
fmt.Fprintln(os.Stderr, "zipdoc:", err)
os.Exit(1)
}
}
func run(name string, check bool, patterns []string) error {
if filepath.Base(name) != name {
return fmt.Errorf("-o takes a file name, not a path: %s", name)
}
dir, err := os.Getwd()
if err != nil {
return err
}
pkgs, err := zipdoc.Load(dir, patterns)
if err != nil {
return err
}
var stale []string
for _, p := range pkgs {
if check {
why, err := p.Stale(name)
if err != nil {
return err
}
if why != "" {
stale = append(stale, why)
}
continue
}
changed, err := p.Write(name)
if err != nil {
return err
}
if changed {
fmt.Fprintln(os.Stderr, "zipdoc:", filepath.Join(p.Dir, name))
}
}
if len(stale) > 0 {
return fmt.Errorf("%d generated file(s) do not match the source; run `go generate ./...`:\n\t%s",
len(stale), strings.Join(stale, "\n\t"))
}
return nil
}