diff --git a/docs/cli-usage.md b/docs/cli-usage.md index d629094..02de115 100644 --- a/docs/cli-usage.md +++ b/docs/cli-usage.md @@ -88,6 +88,27 @@ set it to `""` to disable Skill generation. The optional `skill.include` value points at repo-local Skill resources merged into generated Skill files. Keep the include directory outside `skill.root`. +Generated CLIs expose `--version` and `-v`. The release version is +build metadata, not `cli.yaml` data: pass `Version`, `Commit`, and `Date` through +`lathe.RunOptions`, or inject `github.com/lathe-cli/lathe/pkg/lathe.Version`, +`Commit`, and `Date` with Go `-ldflags` in your release build. + +To let the generated CLI update itself from GitHub Releases, add: + +```yaml +update: + github: + owner: acme + repo: acmectl + asset: "acmectl_{{ .Version }}_{{ .OS }}_{{ .Arch }}.tar.gz" +``` + +`asset` is a Go template with `Name`, `Version`, `Tag`, `OS`, and `Arch`. +`update` fetches the latest GitHub release, reports when the current binary is +already current, and asks for confirmation before replacing the executable. Pass +`--yes` to skip the prompt. The release asset must expose a `sha256:` digest; zip, +tar.gz, and tgz assets must contain a binary named after `cli.name`. + ## Declare API Specs Create `specs/sources.yaml`: diff --git a/internal/codegen/render/render.go b/internal/codegen/render/render.go index 9abc8c1..87c1b5b 100644 --- a/internal/codegen/render/render.go +++ b/internal/codegen/render/render.go @@ -125,6 +125,7 @@ var reservedRootCommands = map[string]bool{ "commands": true, "help": true, "search": true, + "update": true, } func MergeOverlay(specs []runtime.CommandSpec, overrides map[string]overlay.Override) []runtime.CommandSpec { diff --git a/internal/codegen/render/skill.go b/internal/codegen/render/skill.go index d52bc35..69b1bed 100644 --- a/internal/codegen/render/skill.go +++ b/internal/codegen/render/skill.go @@ -261,6 +261,12 @@ func renderSkillMD(manifest *config.Manifest, refs []moduleRef) string { fmt.Fprintf(&b, "- `%s commands show --json`: source of truth for one command.\n", cli) fmt.Fprintf(&b, "- `%s commands schema --json`: catalog schema version for parser compatibility.\n", cli) fmt.Fprintf(&b, "- `%s search \"\" --json`: ranked candidate commands.\n\n", cli) + b.WriteString("## Maintenance Commands\n\n") + fmt.Fprintf(&b, "- `%s --version` or `%s -v`: print CLI build version.\n", cli, cli) + if manifest.Update.GitHub != nil { + fmt.Fprintf(&b, "- `%s update`: update this CLI from configured GitHub Releases. Run only when the user explicitly asks to update `%s`; it may replace the current executable. Use `--yes` only when explicitly authorized.\n", cli, cli) + } + b.WriteString("\n") b.WriteString("## References\n\n") b.WriteString("- Read `references/catalog.md` for the command discovery protocol and catalog field meanings.\n") for _, ref := range refs { diff --git a/pkg/config/manifest.go b/pkg/config/manifest.go index 8312d14..6d9c298 100644 --- a/pkg/config/manifest.go +++ b/pkg/config/manifest.go @@ -9,8 +9,9 @@ import ( ) type Manifest struct { - CLI CLIInfo `yaml:"cli"` - Auth AuthInfo `yaml:"auth"` + CLI CLIInfo `yaml:"cli"` + Auth AuthInfo `yaml:"auth"` + Update UpdateInfo `yaml:"update,omitempty"` } type CLIInfo struct { @@ -26,6 +27,16 @@ type AuthInfo struct { Validate *AuthValidate `yaml:"validate,omitempty"` } +type UpdateInfo struct { + GitHub *GitHubUpdate `yaml:"github,omitempty"` +} + +type GitHubUpdate struct { + Owner string `yaml:"owner"` + Repo string `yaml:"repo"` + Asset string `yaml:"asset"` +} + type AuthValidate struct { Method string `yaml:"method"` Path string `yaml:"path"` @@ -59,6 +70,14 @@ func Load(bytes []byte) (*Manifest, error) { if m.CLI.Name == "" { return nil, fmt.Errorf("cli.name is required") } + if m.Update.GitHub != nil { + m.Update.GitHub.Owner = strings.TrimSpace(m.Update.GitHub.Owner) + m.Update.GitHub.Repo = strings.TrimSpace(m.Update.GitHub.Repo) + m.Update.GitHub.Asset = strings.TrimSpace(m.Update.GitHub.Asset) + if m.Update.GitHub.Owner == "" || m.Update.GitHub.Repo == "" || m.Update.GitHub.Asset == "" { + return nil, fmt.Errorf("update.github.owner, update.github.repo, and update.github.asset are required") + } + } m.CLI.CommandPath = strings.ToLower(strings.TrimSpace(m.CLI.CommandPath)) if m.CLI.CommandPath == "" { m.CLI.CommandPath = CommandPathAuto diff --git a/pkg/lathe/lathe.go b/pkg/lathe/lathe.go index 538e92b..3393374 100644 --- a/pkg/lathe/lathe.go +++ b/pkg/lathe/lathe.go @@ -28,9 +28,11 @@ func NewApp(m *config.Manifest) *cobra.Command { Use: m.CLI.Name, Short: m.CLI.Short, Long: agentHint(m.CLI.Name, m.CLI.Short), + Version: versionInfo(), SilenceUsage: true, } cmd.CompletionOptions.DisableDefaultCmd = true + cmd.SetVersionTemplate("{{.Use}} {{.Version}}\n") cmd.PersistentFlags().String("hostname", "", fmt.Sprintf("Server hostname (overrides $%s)", m.CLI.HostEnv)) cmd.PersistentFlags().StringP("output", "o", "table", "Output format: table|json|yaml|raw") cmd.PersistentFlags().Bool("insecure", false, "Skip TLS certificate verification for this invocation") @@ -48,6 +50,9 @@ func NewApp(m *config.Manifest) *cobra.Command { cmd.AddCommand(authCmd) cmd.AddCommand(commandsCmd(m)) cmd.AddCommand(searchCmd(m)) + if m.Update.GitHub != nil { + cmd.AddCommand(updateCmd(m)) + } cmd.AddCommand(metaCmd(m.CLI.Name)) return cmd } diff --git a/pkg/lathe/update.go b/pkg/lathe/update.go new file mode 100644 index 0000000..ddbeac5 --- /dev/null +++ b/pkg/lathe/update.go @@ -0,0 +1,432 @@ +package lathe + +import ( + "archive/tar" + "archive/zip" + "bufio" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + neturl "net/url" + "os" + "path/filepath" + stdruntime "runtime" + "strconv" + "strings" + "text/template" + "time" + + "github.com/spf13/cobra" + + "github.com/lathe-cli/lathe/pkg/config" +) + +var ( + updateGitHubAPIBaseURL = "https://api.github.com" + updateHTTPClient = &http.Client{Timeout: 30 * time.Second} + installUpdate = replaceExecutable +) + +type githubRelease struct { + TagName string `json:"tag_name"` + HTMLURL string `json:"html_url"` + Assets []githubAsset `json:"assets"` +} + +type githubAsset struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` + Digest string `json:"digest"` +} + +func updateCmd(m *config.Manifest) *cobra.Command { + var yes bool + cmd := &cobra.Command{ + Use: "update", + Short: "Update this CLI from GitHub Releases", + RunE: func(cmd *cobra.Command, _ []string) error { + return runGitHubUpdate(cmd, m, yes) + }, + } + cmd.Flags().BoolVar(&yes, "yes", false, "Update without prompting") + return cmd +} + +func runGitHubUpdate(cmd *cobra.Command, m *config.Manifest, yes bool) error { + release, err := fetchLatestGitHubRelease(cmd.Context(), *m.Update.GitHub) + if err != nil { + return err + } + if release.TagName == "" { + return fmt.Errorf("latest release has no tag_name") + } + cmp, err := compareVersions(Version, release.TagName) + if err != nil { + return err + } + if cmp == 0 { + fmt.Fprintf(cmd.OutOrStdout(), "%s is up to date (%s)\n", m.CLI.Name, release.TagName) + return nil + } + if cmp > 0 { + fmt.Fprintf(cmd.OutOrStdout(), "%s is newer than latest release (%s > %s)\n", m.CLI.Name, Version, release.TagName) + return nil + } + if !yes && !confirmUpdate(cmd, m.CLI.Name, Version, release.TagName) { + fmt.Fprintln(cmd.OutOrStdout(), "Update cancelled.") + return nil + } + + assetName, err := renderUpdateAssetName(m.CLI.Name, m.Update.GitHub.Asset, release.TagName) + if err != nil { + return err + } + asset, ok := findReleaseAsset(release, assetName) + if !ok { + return fmt.Errorf("release %s has no asset %q", release.TagName, assetName) + } + digest, ok := sha256Digest(asset.Digest) + if !ok { + return fmt.Errorf("release asset %q is missing a sha256 digest", asset.Name) + } + archivePath, err := downloadReleaseAsset(cmd.Context(), asset, digest) + if err != nil { + return err + } + defer func() { _ = os.Remove(archivePath) }() + + binaryPath, err := updatePayloadPath(archivePath, asset.Name, m.CLI.Name) + if err != nil { + return err + } + if binaryPath != archivePath { + defer func() { _ = os.Remove(binaryPath) }() + } + if err := installUpdate(binaryPath); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "Updated %s to %s\n", m.CLI.Name, release.TagName) + return nil +} + +func fetchLatestGitHubRelease(ctx context.Context, gh config.GitHubUpdate) (githubRelease, error) { + u := strings.TrimRight(updateGitHubAPIBaseURL, "/") + "/repos/" + neturl.PathEscape(gh.Owner) + "/" + neturl.PathEscape(gh.Repo) + "/releases/latest" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return githubRelease{}, err + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + resp, err := updateHTTPClient.Do(req) + if err != nil { + return githubRelease{}, fmt.Errorf("fetch latest release: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return githubRelease{}, fmt.Errorf("fetch latest release: GitHub returned %s", resp.Status) + } + var release githubRelease + if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + return githubRelease{}, fmt.Errorf("parse latest release: %w", err) + } + return release, nil +} + +func confirmUpdate(cmd *cobra.Command, name, current, latest string) bool { + fmt.Fprintf(cmd.OutOrStdout(), "Update %s from %s to %s? [y/N] ", name, current, latest) + line, _ := bufio.NewReader(cmd.InOrStdin()).ReadString('\n') + answer := strings.TrimSpace(line) + return strings.EqualFold(answer, "y") || strings.EqualFold(answer, "yes") +} + +func renderUpdateAssetName(name, pattern, tag string) (string, error) { + t, err := template.New("asset").Parse(pattern) + if err != nil { + return "", fmt.Errorf("parse update asset template: %w", err) + } + var b strings.Builder + data := struct { + Name string + Version string + Tag string + OS string + Arch string + }{ + Name: name, + Version: cleanVersion(tag), + Tag: tag, + OS: stdruntime.GOOS, + Arch: stdruntime.GOARCH, + } + if err := t.Execute(&b, data); err != nil { + return "", fmt.Errorf("render update asset template: %w", err) + } + return b.String(), nil +} + +func findReleaseAsset(release githubRelease, name string) (githubAsset, bool) { + for _, asset := range release.Assets { + if asset.Name == name { + return asset, true + } + } + return githubAsset{}, false +} + +func sha256Digest(digest string) (string, bool) { + alg, value, ok := strings.Cut(digest, ":") + if !ok || !strings.EqualFold(alg, "sha256") { + return "", false + } + raw, err := hex.DecodeString(value) + if err != nil || len(raw) != sha256.Size { + return "", false + } + return strings.ToLower(value), true +} + +func downloadReleaseAsset(ctx context.Context, asset githubAsset, expectedDigest string) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, asset.BrowserDownloadURL, nil) + if err != nil { + return "", err + } + req.Header.Set("Accept", "application/octet-stream") + resp, err := updateHTTPClient.Do(req) + if err != nil { + return "", fmt.Errorf("download %s: %w", asset.Name, err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("download %s: server returned %s", asset.Name, resp.Status) + } + f, err := os.CreateTemp("", "lathe-update-*") + if err != nil { + return "", err + } + path := f.Name() + h := sha256.New() + _, copyErr := io.Copy(io.MultiWriter(f, h), resp.Body) + closeErr := f.Close() + if copyErr != nil { + _ = os.Remove(path) + return "", fmt.Errorf("download %s: %w", asset.Name, copyErr) + } + if closeErr != nil { + _ = os.Remove(path) + return "", closeErr + } + if got := fmt.Sprintf("%x", h.Sum(nil)); got != expectedDigest { + _ = os.Remove(path) + return "", fmt.Errorf("download %s: sha256 mismatch", asset.Name) + } + return path, nil +} + +func updatePayloadPath(path, assetName, cliName string) (string, error) { + lower := strings.ToLower(assetName) + switch { + case strings.HasSuffix(lower, ".tar.gz"), strings.HasSuffix(lower, ".tgz"): + return extractTarGzBinary(path, cliName) + case strings.HasSuffix(lower, ".zip"): + return extractZipBinary(path, cliName) + default: + return path, nil + } +} + +func extractTarGzBinary(path, cliName string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer f.Close() + gz, err := gzip.NewReader(f) + if err != nil { + return "", err + } + defer func() { _ = gz.Close() }() + tr := tar.NewReader(gz) + for { + h, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return "", err + } + if h.Typeflag == tar.TypeReg && binaryNameMatches(h.Name, cliName) { + return writeUpdatePayload(tr, h.FileInfo().Mode()) + } + } + return "", fmt.Errorf("archive does not contain %q", cliName) +} + +func extractZipBinary(path, cliName string) (string, error) { + zr, err := zip.OpenReader(path) + if err != nil { + return "", err + } + defer func() { _ = zr.Close() }() + for _, f := range zr.File { + if f.FileInfo().IsDir() || !binaryNameMatches(f.Name, cliName) { + continue + } + rc, err := f.Open() + if err != nil { + return "", err + } + out, werr := writeUpdatePayload(rc, f.FileInfo().Mode()) + cerr := rc.Close() + if werr != nil { + return "", werr + } + if cerr != nil { + _ = os.Remove(out) + return "", cerr + } + return out, nil + } + return "", fmt.Errorf("archive does not contain %q", cliName) +} + +func binaryNameMatches(name, cliName string) bool { + base := filepath.Base(name) + return base == cliName || base == cliName+".exe" +} + +func writeUpdatePayload(r io.Reader, mode os.FileMode) (string, error) { + f, err := os.CreateTemp("", "lathe-update-bin-*") + if err != nil { + return "", err + } + path := f.Name() + _, copyErr := io.Copy(f, r) + if mode == 0 { + mode = 0o755 + } + chmodErr := f.Chmod(mode.Perm()) + closeErr := f.Close() + if copyErr != nil { + _ = os.Remove(path) + return "", copyErr + } + if chmodErr != nil { + _ = os.Remove(path) + return "", chmodErr + } + if closeErr != nil { + _ = os.Remove(path) + return "", closeErr + } + return path, nil +} + +func replaceExecutable(src string) error { + target, err := os.Executable() + if err != nil { + return err + } + if resolved, err := filepath.EvalSymlinks(target); err == nil { + target = resolved + } + info, err := os.Stat(target) + if err != nil { + return err + } + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + dir := filepath.Dir(target) + tmp, err := os.CreateTemp(dir, "."+filepath.Base(target)+".update-*") + if err != nil { + return err + } + tmpPath := tmp.Name() + _, copyErr := io.Copy(tmp, in) + chmodErr := tmp.Chmod(info.Mode().Perm()) + closeErr := tmp.Close() + if copyErr != nil { + _ = os.Remove(tmpPath) + return copyErr + } + if chmodErr != nil { + _ = os.Remove(tmpPath) + return chmodErr + } + if closeErr != nil { + _ = os.Remove(tmpPath) + return closeErr + } + if err := os.Rename(tmpPath, target); err != nil { + _ = os.Remove(tmpPath) + return fmt.Errorf("replace %s: %w", target, err) + } + return nil +} + +func compareVersions(current, latest string) (int, error) { + a, err := parseVersion(current) + if err != nil { + return 0, fmt.Errorf("current version %q cannot be compared; build with RunOptions.Version or ldflags to enable update", current) + } + b, err := parseVersion(latest) + if err != nil { + return 0, fmt.Errorf("latest version %q cannot be compared", latest) + } + for i := range a.parts { + if a.parts[i] < b.parts[i] { + return -1, nil + } + if a.parts[i] > b.parts[i] { + return 1, nil + } + } + if a.suffix == b.suffix { + return 0, nil + } + if a.suffix != "" && b.suffix == "" { + return -1, nil + } + if a.suffix == "" && b.suffix != "" { + return 1, nil + } + return strings.Compare(a.suffix, b.suffix), nil +} + +type parsedVersion struct { + parts [3]int + suffix string +} + +func parseVersion(v string) (parsedVersion, error) { + v = cleanVersion(v) + core, suffix, _ := strings.Cut(v, "-") + fields := strings.Split(core, ".") + if len(fields) == 0 || len(fields) > 3 { + return parsedVersion{}, fmt.Errorf("invalid version") + } + var out parsedVersion + out.suffix = suffix + for i, field := range fields { + if field == "" { + return parsedVersion{}, fmt.Errorf("invalid version") + } + n, err := strconv.Atoi(field) + if err != nil { + return parsedVersion{}, err + } + out.parts[i] = n + } + return out, nil +} + +func cleanVersion(v string) string { + return strings.TrimPrefix(strings.TrimSpace(v), "v") +} diff --git a/pkg/lathe/version.go b/pkg/lathe/version.go index c3c1134..a551e3c 100644 --- a/pkg/lathe/version.go +++ b/pkg/lathe/version.go @@ -8,12 +8,16 @@ var ( Date = "unknown" ) +func versionInfo() string { + return Version + " (" + Commit + ", " + Date + ")" +} + func versionCmd() *cobra.Command { return &cobra.Command{ Use: "version", Short: "Print version information", Run: func(cmd *cobra.Command, _ []string) { - cmd.Printf("%s %s (%s, %s)\n", cmd.Root().Use, Version, Commit, Date) + cmd.Printf("%s %s\n", cmd.Root().Use, versionInfo()) }, } }