← back to Cli Printing Press
feat(cli): schema-version gate, doctor cache section, cache/share spec surface (#232)
e116a27c1fad3226fc9432da5cb972b169585efb · 2026-04-21 21:59:44 -0700 · Matt Van Horn
Foundations for the discrawl-inspired auto-refresh and snapshot-share
capabilities. No behavior change for existing published CLIs until a
spec opts into cache or share in a follow-up; this just ships the
generator plumbing.
Unit 0 (spec surface): adds Cache and Share blocks to APISpec with
Validate() rejecting share.enabled without snapshot_tables and rejecting
any table name matching auth_*, *_cache, or *_secrets. Parse, round-trip,
and validation tests (18 subtests).
Unit 1 (schema-version gate): store.go.tmpl now reads PRAGMA user_version
on every Open, fails fast when the on-disk schema exceeds the binary's
supported version, and stamps user_version on fresh or pre-gate databases.
Exports Store.SchemaVersion(), Store.DB(), Store.Path() for downstream
callers. A generated internal/store/schema_version_test.go ships in every
printed CLI that has a store.
Unit 4 (doctor cache section): doctor.go.tmpl emits a cache block with
schema_version, db_path, db_bytes, per-resource rows and last_synced_at,
and fresh/stale/unknown verdict when HasStore is true. Adds a --fail-on
flag for CI integration. Preserves the existing doctor output exactly
when no local store is emitted.
Ref: docs/plans/2026-04-21-001-feat-auto-refresh-and-share-plan.md
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Files touched
M internal/generator/generator.goM internal/generator/generator_test.goM internal/generator/templates/doctor.go.tmplM internal/generator/templates/store.go.tmplA internal/generator/templates/store_schema_version_test.go.tmplM internal/spec/spec.goM internal/spec/spec_test.go
Diff
commit e116a27c1fad3226fc9432da5cb972b169585efb
Author: Matt Van Horn <mvanhorn@users.noreply.github.com>
Date: Tue Apr 21 21:59:44 2026 -0700
feat(cli): schema-version gate, doctor cache section, cache/share spec surface (#232)
Foundations for the discrawl-inspired auto-refresh and snapshot-share
capabilities. No behavior change for existing published CLIs until a
spec opts into cache or share in a follow-up; this just ships the
generator plumbing.
Unit 0 (spec surface): adds Cache and Share blocks to APISpec with
Validate() rejecting share.enabled without snapshot_tables and rejecting
any table name matching auth_*, *_cache, or *_secrets. Parse, round-trip,
and validation tests (18 subtests).
Unit 1 (schema-version gate): store.go.tmpl now reads PRAGMA user_version
on every Open, fails fast when the on-disk schema exceeds the binary's
supported version, and stamps user_version on fresh or pre-gate databases.
Exports Store.SchemaVersion(), Store.DB(), Store.Path() for downstream
callers. A generated internal/store/schema_version_test.go ships in every
printed CLI that has a store.
Unit 4 (doctor cache section): doctor.go.tmpl emits a cache block with
schema_version, db_path, db_bytes, per-resource rows and last_synced_at,
and fresh/stale/unknown verdict when HasStore is true. Adds a --fail-on
flag for CI integration. Preserves the existing doctor output exactly
when no local store is emitted.
Ref: docs/plans/2026-04-21-001-feat-auto-refresh-and-share-plan.md
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
---
internal/generator/generator.go | 17 ++
internal/generator/generator_test.go | 7 +-
internal/generator/templates/doctor.go.tmpl | 226 ++++++++++++++++++++-
internal/generator/templates/store.go.tmpl | 59 ++++++
.../templates/store_schema_version_test.go.tmpl | 116 +++++++++++
internal/spec/spec.go | 93 +++++++++
internal/spec/spec_test.go | 192 +++++++++++++++++
7 files changed, 703 insertions(+), 7 deletions(-)
diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index 72dd1d6d..d69d06d4 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -488,6 +488,15 @@ type helpersTemplateData struct {
HelperFlags
}
+// doctorTemplateData wraps APISpec with a HasStore flag so the doctor
+// template can gate its cache-health section. Doctor is emitted for every
+// CLI — with or without a local store — so the template needs explicit
+// knowledge of whether internal/store exists.
+type doctorTemplateData struct {
+ *spec.APISpec
+ HasStore bool
+}
+
// readmeTemplateData wraps APISpec with additional fields for README rendering.
type readmeTemplateData struct {
*spec.APISpec
@@ -697,6 +706,11 @@ func (g *Generator) Generate() error {
APISpec: g.Spec,
HelperFlags: hFlags,
}
+ case "doctor.go.tmpl":
+ data = &doctorTemplateData{
+ APISpec: g.Spec,
+ HasStore: g.VisionSet.Store,
+ }
default:
data = g.Spec
}
@@ -953,6 +967,9 @@ func (g *Generator) Generate() error {
if err := g.renderTemplate("store.go.tmpl", filepath.Join("internal", "store", "store.go"), storeData); err != nil {
return fmt.Errorf("rendering store: %w", err)
}
+ if err := g.renderTemplate("store_schema_version_test.go.tmpl", filepath.Join("internal", "store", "schema_version_test.go"), storeData); err != nil {
+ return fmt.Errorf("rendering store schema version test: %w", err)
+ }
}
// Render vision CLI commands
diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index b2188b4a..f7d0adfc 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -29,9 +29,10 @@ func TestGenerateProjectsCompile(t *testing.T) {
// +1 for internal/cli/profile.go (HeyGen-style named-profile system)
// +1 for internal/cli/deliver.go (HeyGen-style --deliver output routing)
// +1 for internal/cli/feedback.go (HeyGen-style in-band agent feedback channel)
- {name: "stytch", specPath: filepath.Join("..", "..", "testdata", "stytch.yaml"), expectedFiles: 39},
- {name: "clerk", specPath: filepath.Join("..", "..", "testdata", "clerk.yaml"), expectedFiles: 43},
- {name: "loops", specPath: filepath.Join("..", "..", "testdata", "loops.yaml"), expectedFiles: 41},
+ // +1 for internal/store/schema_version_test.go (PRAGMA user_version gate, discrawl-inspired)
+ {name: "stytch", specPath: filepath.Join("..", "..", "testdata", "stytch.yaml"), expectedFiles: 40},
+ {name: "clerk", specPath: filepath.Join("..", "..", "testdata", "clerk.yaml"), expectedFiles: 44},
+ {name: "loops", specPath: filepath.Join("..", "..", "testdata", "loops.yaml"), expectedFiles: 42},
}
for _, tt := range tests {
diff --git a/internal/generator/templates/doctor.go.tmpl b/internal/generator/templates/doctor.go.tmpl
index 9d8edfc3..9d5b3791 100644
--- a/internal/generator/templates/doctor.go.tmpl
+++ b/internal/generator/templates/doctor.go.tmpl
@@ -4,9 +4,15 @@
package cli
import (
+{{- if .HasStore}}
+ "database/sql"
+{{- end}}
"fmt"
+{{- if .HasStore}}
+ "io"
+{{- end}}
"net/http"
-{{- if .Auth.EnvVars}}
+{{- if or .Auth.EnvVars .HasStore}}
"os"
{{- end}}
{{- if or (eq .Auth.Type "cookie") (eq .Auth.Type "composed")}}
@@ -16,11 +22,15 @@ import (
"time"
"{{modulePath}}/internal/config"
+{{- if .HasStore}}
+ "{{modulePath}}/internal/store"
+{{- end}}
"github.com/spf13/cobra"
)
func newDoctorCmd(flags *rootFlags) *cobra.Command {
- return &cobra.Command{
+ var failOn string
+ cmd := &cobra.Command{
Use: "doctor",
Short: "Check CLI health",
RunE: func(cmd *cobra.Command, args []string) error {
@@ -192,10 +202,21 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command {
report["api"] = "not configured (set base_url in config file)"
}
+{{- if .HasStore}}
+ // Cache health: only reported when this CLI has a local store.
+ // Surfaces rows + last_synced_at per resource, schema version,
+ // and a fresh/stale/unknown verdict so agents can introspect
+ // whether to trust the cached data before issuing queries.
+ report["cache"] = collectCacheReport({{if .Cache.StaleAfter}}"{{.Cache.StaleAfter}}"{{else}}""{{end}})
+{{- end}}
+
report["version"] = version
if flags.asJSON {
- return flags.printJSON(cmd, report)
+ if err := flags.printJSON(cmd, report); err != nil {
+ return err
+ }
+ return doctorExitForFailOn(failOn, report)
}
// Human-readable output with color
@@ -244,7 +265,204 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command {
fmt.Fprintf(w, " Get a key at: %v\n", keyURL)
}
{{- end}}
- return nil
+{{- if .HasStore}}
+ // Cache section: render after the primary health block so it
+ // sits next to version info, mirroring the JSON report layout.
+ if cacheAny, ok := report["cache"]; ok {
+ if cacheRep, ok := cacheAny.(map[string]any); ok {
+ renderCacheReport(w, cacheRep)
+ }
+ }
+{{- end}}
+ return doctorExitForFailOn(failOn, report)
},
}
+ cmd.Flags().StringVar(&failOn, "fail-on", "", "Exit non-zero when a health level is reached: stale, error. Default is never.")
+ return cmd
+}
+
+// doctorExitForFailOn returns a non-nil error when the report's worst
+// status meets or exceeds the --fail-on threshold. "error" always trips
+// when any section reports an error; "stale" also trips when the cache
+// section is stale. The default empty string means never fail on status.
+func doctorExitForFailOn(failOn string, report map[string]any) error {
+ if failOn == "" {
+ return nil
+ }
+ worstError := false
+ worstStale := false
+ for _, v := range report {
+ s, ok := v.(string)
+ if ok {
+ if strings.Contains(s, "error") || strings.Contains(s, "unreachable") || strings.Contains(s, "invalid") {
+ worstError = true
+ }
+ }
+ if m, ok := v.(map[string]any); ok {
+ if st, _ := m["status"].(string); st == "error" {
+ worstError = true
+ } else if st == "stale" {
+ worstStale = true
+ }
+ }
+ }
+ switch failOn {
+ case "error":
+ if worstError {
+ return fmt.Errorf("doctor: --fail-on=error triggered")
+ }
+ case "stale":
+ if worstError || worstStale {
+ return fmt.Errorf("doctor: --fail-on=stale triggered")
+ }
+ default:
+ return fmt.Errorf("doctor: unknown --fail-on value %q (valid: stale, error)", failOn)
+ }
+ return nil
}
+{{- if .HasStore}}
+
+// collectCacheReport opens the local store, reads per-resource sync state,
+// and returns a map summarising cache health. Never panics on missing DB
+// or open failure; returns a map with status=unknown or status=error so the
+// caller can render and agents can interpret.
+//
+// staleAfterSpec is the CLI's configured threshold (e.g. "6h"); empty means
+// use the runtime default. The default is deliberately conservative (6h)
+// because the alternative is no freshness story at all.
+func collectCacheReport(staleAfterSpec string) map[string]any {
+ report := map[string]any{}
+ dbPath := defaultDBPath("{{.Name}}-pp-cli")
+ report["db_path"] = dbPath
+
+ fi, err := os.Stat(dbPath)
+ if err != nil {
+ if os.IsNotExist(err) {
+ report["status"] = "unknown"
+ report["hint"] = "Database not created yet; run '{{.Name}}-pp-cli sync' to hydrate."
+ return report
+ }
+ report["status"] = "error"
+ report["error"] = err.Error()
+ return report
+ }
+ report["db_bytes"] = fi.Size()
+
+ s, err := store.Open(dbPath)
+ if err != nil {
+ report["status"] = "error"
+ report["error"] = err.Error()
+ return report
+ }
+ defer s.Close()
+
+ if v, verr := s.SchemaVersion(); verr == nil {
+ report["schema_version"] = v
+ }
+
+ staleAfter := 6 * time.Hour
+ if staleAfterSpec != "" {
+ if d, derr := time.ParseDuration(staleAfterSpec); derr == nil {
+ staleAfter = d
+ }
+ }
+
+ rows, qerr := s.DB().Query(`SELECT resource_type, COALESCE(total_count, 0), last_synced_at FROM sync_state ORDER BY resource_type`)
+ if qerr != nil {
+ // sync_state may not exist on a fresh DB that has migrated but not
+ // yet had any sync runs — treat as unknown rather than error.
+ report["status"] = "unknown"
+ report["hint"] = "No sync state recorded; run '{{.Name}}-pp-cli sync' to populate."
+ return report
+ }
+ defer rows.Close()
+
+ var resources []map[string]any
+ fresh := true
+ haveAny := false
+ oldest := time.Duration(0)
+ for rows.Next() {
+ var rtype string
+ var count int64
+ var lastSynced sql.NullTime
+ if err := rows.Scan(&rtype, &count, &lastSynced); err != nil {
+ continue
+ }
+ r := map[string]any{"type": rtype, "rows": count}
+ if lastSynced.Valid {
+ haveAny = true
+ r["last_synced_at"] = lastSynced.Time.UTC().Format(time.RFC3339)
+ age := time.Since(lastSynced.Time)
+ r["staleness"] = age.Round(time.Minute).String()
+ if age > staleAfter {
+ fresh = false
+ }
+ if age > oldest {
+ oldest = age
+ }
+ } else {
+ r["staleness"] = "never"
+ fresh = false
+ }
+ resources = append(resources, r)
+ }
+ report["resources"] = resources
+ report["stale_after"] = staleAfter.String()
+
+ switch {
+ case !haveAny && len(resources) == 0:
+ report["status"] = "unknown"
+ report["hint"] = "sync_state is empty; run '{{.Name}}-pp-cli sync' to hydrate."
+ case fresh:
+ report["status"] = "fresh"
+ default:
+ report["status"] = "stale"
+ report["oldest_age"] = oldest.Round(time.Minute).String()
+ report["hint"] = "Some resources are older than stale_after; run '{{.Name}}-pp-cli sync' to refresh."
+ }
+ return report
+}
+
+func renderCacheReport(w io.Writer, rep map[string]any) {
+ status, _ := rep["status"].(string)
+ indicator := green("OK")
+ switch status {
+ case "stale":
+ indicator = yellow("WARN")
+ case "error":
+ indicator = red("FAIL")
+ case "unknown":
+ indicator = yellow("INFO")
+ }
+ fmt.Fprintf(w, " %s Cache: %s\n", indicator, status)
+ if v, ok := rep["db_path"]; ok {
+ fmt.Fprintf(w, " db_path: %v\n", v)
+ }
+ if v, ok := rep["schema_version"]; ok {
+ fmt.Fprintf(w, " schema_version: %v\n", v)
+ }
+ if v, ok := rep["db_bytes"]; ok {
+ fmt.Fprintf(w, " db_bytes: %v\n", v)
+ }
+ if v, ok := rep["stale_after"]; ok {
+ fmt.Fprintf(w, " stale_after: %v\n", v)
+ }
+ if v, ok := rep["oldest_age"]; ok {
+ fmt.Fprintf(w, " oldest_age: %v\n", v)
+ }
+ if resourcesAny, ok := rep["resources"]; ok {
+ if resources, ok := resourcesAny.([]map[string]any); ok && len(resources) > 0 {
+ fmt.Fprintf(w, " resources:\n")
+ for _, r := range resources {
+ rtype, _ := r["type"].(string)
+ rows := r["rows"]
+ staleness, _ := r["staleness"].(string)
+ fmt.Fprintf(w, " - %s: %v rows, %s\n", rtype, rows, staleness)
+ }
+ }
+ }
+ if hint, ok := rep["hint"]; ok {
+ fmt.Fprintf(w, " hint: %v\n", hint)
+ }
+}
+{{- end}}
diff --git a/internal/generator/templates/store.go.tmpl b/internal/generator/templates/store.go.tmpl
index c93c4387..14b66792 100644
--- a/internal/generator/templates/store.go.tmpl
+++ b/internal/generator/templates/store.go.tmpl
@@ -26,6 +26,14 @@ func IsUUID(s string) bool {
return uuidPattern.MatchString(s)
}
+// StoreSchemaVersion is the on-disk schema version this binary understands.
+// It is stamped into SQLite's PRAGMA user_version on fresh databases and
+// checked on every open. Bump this whenever a migration changes table
+// shape — adding columns, dropping indexes, changing FTS5 tokenizers —
+// so an older binary refuses to open a newer database rather than silently
+// producing wrong results against a schema it cannot read.
+const StoreSchemaVersion = 1
+
type Store struct {
db *sql.DB
path string
@@ -59,7 +67,50 @@ func (s *Store) Close() error {
return s.db.Close()
}
+// Path returns the on-disk path of the backing SQLite file.
+func (s *Store) Path() string {
+ return s.path
+}
+
+// DB exposes the underlying *sql.DB for callers that need to run ad-hoc
+// queries (e.g., doctor's cache inspection, share snapshot import).
+// Callers must not call Close on the returned handle.
+func (s *Store) DB() *sql.DB {
+ return s.db
+}
+
+// SchemaVersion reads PRAGMA user_version, which is stamped by migrate().
+// A zero value means the database predates the schema-version gate — not
+// a bug, but the caller may want to warn.
+func (s *Store) SchemaVersion() (int, error) {
+ var v int
+ if err := s.db.QueryRow(`PRAGMA user_version`).Scan(&v); err != nil {
+ return 0, fmt.Errorf("read user_version: %w", err)
+ }
+ return v, nil
+}
+
+// setSchemaVersion stamps PRAGMA user_version. The value is not parameterizable
+// in SQLite's PRAGMA syntax, so it is formatted into the statement; migrate()
+// only calls this with the compile-time StoreSchemaVersion constant, so there
+// is no untrusted input.
+func (s *Store) setSchemaVersion(version int) error {
+ stmt := fmt.Sprintf(`PRAGMA user_version = %d`, version)
+ if _, err := s.db.Exec(stmt); err != nil {
+ return fmt.Errorf("stamp user_version: %w", err)
+ }
+ return nil
+}
+
func (s *Store) migrate() error {
+ current, err := s.SchemaVersion()
+ if err != nil {
+ return fmt.Errorf("reading schema version: %w", err)
+ }
+ if current > StoreSchemaVersion {
+ return fmt.Errorf("database schema version %d is newer than supported version %d; upgrade the CLI binary or open an older database", current, StoreSchemaVersion)
+ }
+
migrations := []string{
`CREATE TABLE IF NOT EXISTS resources (
id TEXT PRIMARY KEY,
@@ -133,6 +184,14 @@ func (s *Store) migrate() error {
return fmt.Errorf("migration failed: %w", err)
}
}
+ // Stamp the schema version after successful migration. On a fresh DB
+ // this writes 1; on an already-stamped DB this is a no-op write of the
+ // same value. An older DB with user_version = 0 and pre-existing tables
+ // gets stamped here without any data rewrites because the migrations
+ // above are idempotent via CREATE TABLE IF NOT EXISTS.
+ if err := s.setSchemaVersion(StoreSchemaVersion); err != nil {
+ return err
+ }
return nil
}
diff --git a/internal/generator/templates/store_schema_version_test.go.tmpl b/internal/generator/templates/store_schema_version_test.go.tmpl
new file mode 100644
index 00000000..7ddd5e04
--- /dev/null
+++ b/internal/generator/templates/store_schema_version_test.go.tmpl
@@ -0,0 +1,116 @@
+// Copyright {{currentYear}} {{.Owner}}. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package store
+
+import (
+ "database/sql"
+ "path/filepath"
+ "testing"
+
+ _ "modernc.org/sqlite"
+)
+
+// TestSchemaVersion_StampedOnFreshDB verifies that opening a brand-new
+// database stamps the current schema version. This is the contract that
+// makes StoreSchemaVersion upgrades safe: every freshly-created DB
+// records the version it was built under.
+func TestSchemaVersion_StampedOnFreshDB(t *testing.T) {
+ dbPath := filepath.Join(t.TempDir(), "data.db")
+ s, err := Open(dbPath)
+ if err != nil {
+ t.Fatalf("open fresh db: %v", err)
+ }
+ defer s.Close()
+
+ v, err := s.SchemaVersion()
+ if err != nil {
+ t.Fatalf("read schema version: %v", err)
+ }
+ if v != StoreSchemaVersion {
+ t.Fatalf("fresh db version = %d, want %d", v, StoreSchemaVersion)
+ }
+}
+
+// TestSchemaVersion_StampExistingZeroDB verifies the stamp-and-continue
+// rule for existing deployed databases. A DB that predates the gate has
+// user_version = 0; opening it with this binary should stamp the version
+// to 1 without touching any data.
+func TestSchemaVersion_StampExistingZeroDB(t *testing.T) {
+ dbPath := filepath.Join(t.TempDir(), "data.db")
+
+ // Pre-create the DB with user_version = 0 and no tables, simulating
+ // a database created by a pre-gate version of the binary before any
+ // migrations ran.
+ raw, err := sql.Open("sqlite", dbPath)
+ if err != nil {
+ t.Fatalf("open raw: %v", err)
+ }
+ if _, err := raw.Exec(`PRAGMA user_version = 0`); err != nil {
+ t.Fatalf("stamp zero: %v", err)
+ }
+ raw.Close()
+
+ s, err := Open(dbPath)
+ if err != nil {
+ t.Fatalf("open pre-gate db: %v", err)
+ }
+ defer s.Close()
+
+ v, err := s.SchemaVersion()
+ if err != nil {
+ t.Fatalf("read schema version: %v", err)
+ }
+ if v != StoreSchemaVersion {
+ t.Fatalf("post-stamp version = %d, want %d", v, StoreSchemaVersion)
+ }
+}
+
+// TestSchemaVersion_RefusesNewerDB verifies fail-fast when the on-disk
+// schema is newer than the binary supports. Without this gate, a user
+// who upgrades their library but not their binary would hit silent
+// "no such column" errors instead of a clear version mismatch.
+func TestSchemaVersion_RefusesNewerDB(t *testing.T) {
+ dbPath := filepath.Join(t.TempDir(), "data.db")
+
+ raw, err := sql.Open("sqlite", dbPath)
+ if err != nil {
+ t.Fatalf("open raw: %v", err)
+ }
+ if _, err := raw.Exec(`PRAGMA user_version = 999`); err != nil {
+ t.Fatalf("stamp future version: %v", err)
+ }
+ raw.Close()
+
+ _, err = Open(dbPath)
+ if err == nil {
+ t.Fatalf("expected open to fail on newer schema, got nil")
+ }
+}
+
+// TestSchemaVersion_ReopenIsIdempotent verifies that opening an already
+// correctly-stamped DB is a no-op — the second open reads the version
+// and the migrations are all idempotent (CREATE TABLE IF NOT EXISTS).
+func TestSchemaVersion_ReopenIsIdempotent(t *testing.T) {
+ dbPath := filepath.Join(t.TempDir(), "data.db")
+
+ s1, err := Open(dbPath)
+ if err != nil {
+ t.Fatalf("first open: %v", err)
+ }
+ s1.Close()
+
+ s2, err := Open(dbPath)
+ if err != nil {
+ t.Fatalf("second open: %v", err)
+ }
+ defer s2.Close()
+
+ v, err := s2.SchemaVersion()
+ if err != nil {
+ t.Fatalf("read schema version: %v", err)
+ }
+ if v != StoreSchemaVersion {
+ t.Fatalf("reopened version = %d, want %d", v, StoreSchemaVersion)
+ }
+}
diff --git a/internal/spec/spec.go b/internal/spec/spec.go
index 221132da..b733388a 100644
--- a/internal/spec/spec.go
+++ b/internal/spec/spec.go
@@ -38,6 +38,8 @@ type APISpec struct {
Resources map[string]Resource `yaml:"resources" json:"resources"`
Types map[string]TypeDef `yaml:"types" json:"types"`
ExtraCommands []ExtraCommand `yaml:"extra_commands,omitempty" json:"extra_commands,omitempty"` // hand-written cobra commands declared so SKILL.md can document them; spec-only metadata, no code generated
+ Cache CacheConfig `yaml:"cache,omitempty" json:"cache,omitempty"` // cache freshness + auto-refresh config; when enabled, generated read commands auto-refresh stale local data before serving
+ Share ShareConfig `yaml:"share,omitempty" json:"share,omitempty"` // git-backed snapshot sharing config; when enabled, emits a `share` subcommand that publishes/subscribes to a git repo
}
// ExtraCommand declares a hand-written cobra command so the SKILL.md
@@ -106,6 +108,35 @@ type ConfigSpec struct {
Path string `yaml:"path" json:"path"`
}
+// CacheConfig gates the auto-refresh machinery emitted into a printed CLI.
+// Opt-in — CLIs whose local store is per-user state (carts, drafts) should leave
+// Enabled at its zero value so reads never silently replace the user's state
+// with a snapshot from a different session.
+//
+// StaleAfter and RefreshTimeout are strings parsed to time.Duration at CLI
+// runtime; keeping them as strings lets spec authors write "6h" or "30s" and
+// preserves the yaml-level representation for round-trip tooling.
+type CacheConfig struct {
+ Enabled bool `yaml:"enabled,omitempty" json:"enabled,omitempty"` // master switch; when false, freshness helpers and pre-run refresh hook are not emitted
+ StaleAfter string `yaml:"stale_after,omitempty" json:"stale_after,omitempty"` // default duration after which any resource's last_synced_at is considered stale (e.g., "6h"). Blank means runtime default (6h).
+ RefreshTimeout string `yaml:"refresh_timeout,omitempty" json:"refresh_timeout,omitempty"` // max wall-clock the pre-run refresh may block the command (e.g., "30s"). On timeout the command serves stale data with a stderr warning. Blank means runtime default (30s).
+ EnvOptOut string `yaml:"env_opt_out,omitempty" json:"env_opt_out,omitempty"` // env var name that disables auto-refresh when set to "1" (e.g., LINEAR_NO_AUTO_REFRESH). Blank lets the template derive {{upper name}}_NO_AUTO_REFRESH.
+ Resources map[string]string `yaml:"resources,omitempty" json:"resources,omitempty"` // per-resource override of stale_after (e.g., quotes: "5m", channels: "24h"). Resources not listed inherit StaleAfter.
+}
+
+// ShareConfig gates the git-backed snapshot share surface emitted into a
+// printed CLI. When Enabled, the generator emits an internal/share package
+// plus a `share` cobra command (publish, subscribe, export, import). Share
+// is off by default because it is a multi-user feature and most CLIs are
+// single-user; enabling also requires an explicit SnapshotTables allowlist
+// to prevent accidental export of auth or per-user state.
+type ShareConfig struct {
+ Enabled bool `yaml:"enabled,omitempty" json:"enabled,omitempty"` // master switch; when false, the share package and command are not emitted
+ SnapshotTables []string `yaml:"snapshot_tables,omitempty" json:"snapshot_tables,omitempty"` // explicit allowlist of SQLite tables included in the snapshot. Required when Enabled. Names matching denylisted patterns (*_cache, *_secrets, auth_*) are rejected at parse time.
+ DefaultRepo string `yaml:"default_repo,omitempty" json:"default_repo,omitempty"` // optional default git remote (e.g., "git@github.com:acme/linear-snapshots.git"); command-line --repo flag always wins
+ DefaultBranch string `yaml:"default_branch,omitempty" json:"default_branch,omitempty"` // optional default branch for push/pull; blank means "main"
+}
+
type Resource struct {
Description string `yaml:"description" json:"description"`
Path string `yaml:"path,omitempty" json:"path,omitempty"` // base path for operations shorthand (e.g., /api/items)
@@ -335,6 +366,9 @@ func (s *APISpec) Validate() error {
if err := validateExtraCommands(s.ExtraCommands); err != nil {
return err
}
+ if err := validateCacheShare(s.Cache, s.Share); err != nil {
+ return err
+ }
for name, r := range s.Resources {
if len(r.Endpoints) == 0 && len(r.SubResources) == 0 {
return fmt.Errorf("resource %q has no endpoints", name)
@@ -392,6 +426,65 @@ func validateExtraCommands(cmds []ExtraCommand) error {
return nil
}
+// shareTableDenyRe matches table names that must never appear in a share
+// snapshot: anything ending in _cache or _secrets, or starting with auth_.
+// These patterns catch the tables most likely to hold bearer tokens,
+// device fingerprints, or derived per-user state that should never travel
+// in a shared git repo.
+var shareTableDenyRe = regexp.MustCompile(`(?i)^auth_|_cache$|_secrets$`)
+
+// shareTableNameRe enforces SQLite-compatible lowercase identifiers for
+// snapshot table entries. Keeping this strict avoids surprises when the
+// generator later emits SELECT/DELETE statements against these names.
+var shareTableNameRe = regexp.MustCompile(`^[a-z][a-z0-9_]*$`)
+
+// durationLikeRe is a forgiving parse-time sanity check for CacheConfig
+// duration strings. The strict parse happens in the generated CLI at
+// runtime via time.ParseDuration; this check only rejects obviously
+// malformed values so typos surface at spec load, not at end-user runtime.
+var durationLikeRe = regexp.MustCompile(`^\d+(\.\d+)?(ns|us|µs|ms|s|m|h)(\d+(\.\d+)?(ns|us|µs|ms|s|m|h))*$`)
+
+func validateCacheShare(cache CacheConfig, share ShareConfig) error {
+ if cache.StaleAfter != "" && !durationLikeRe.MatchString(cache.StaleAfter) {
+ return fmt.Errorf("cache.stale_after %q is not a valid Go duration", cache.StaleAfter)
+ }
+ if cache.RefreshTimeout != "" && !durationLikeRe.MatchString(cache.RefreshTimeout) {
+ return fmt.Errorf("cache.refresh_timeout %q is not a valid Go duration", cache.RefreshTimeout)
+ }
+ for resource, dur := range cache.Resources {
+ if resource == "" {
+ return fmt.Errorf("cache.resources: resource name must not be empty")
+ }
+ if !durationLikeRe.MatchString(dur) {
+ return fmt.Errorf("cache.resources[%s] = %q is not a valid Go duration", resource, dur)
+ }
+ }
+
+ if !share.Enabled {
+ if len(share.SnapshotTables) > 0 {
+ return fmt.Errorf("share.snapshot_tables is set but share.enabled is false; either enable or remove")
+ }
+ return nil
+ }
+ if len(share.SnapshotTables) == 0 {
+ return fmt.Errorf("share.enabled requires a non-empty share.snapshot_tables allowlist")
+ }
+ seen := make(map[string]struct{}, len(share.SnapshotTables))
+ for i, t := range share.SnapshotTables {
+ if !shareTableNameRe.MatchString(t) {
+ return fmt.Errorf("share.snapshot_tables[%d]: %q must be a lowercase SQLite identifier (letters, digits, underscore)", i, t)
+ }
+ if shareTableDenyRe.MatchString(t) {
+ return fmt.Errorf("share.snapshot_tables[%d]: %q matches the denylist (auth_*, *_cache, *_secrets) and must not be shared", i, t)
+ }
+ if _, dup := seen[t]; dup {
+ return fmt.Errorf("share.snapshot_tables[%d]: %q appears more than once", i, t)
+ }
+ seen[t] = struct{}{}
+ }
+ return nil
+}
+
// CountMCPTools counts total endpoints and public (NoAuth) endpoints across
// all resources and sub-resources.
func (s *APISpec) CountMCPTools() (total, public int) {
diff --git a/internal/spec/spec_test.go b/internal/spec/spec_test.go
index ada8e574..96ee7378 100644
--- a/internal/spec/spec_test.go
+++ b/internal/spec/spec_test.go
@@ -764,3 +764,195 @@ func TestExtraCommandsRoundTripYAML(t *testing.T) {
require.NoError(t, yaml.Unmarshal(data, &parsed))
assert.Equal(t, original.ExtraCommands, parsed.ExtraCommands)
}
+
+func TestCacheShareParse(t *testing.T) {
+ input := `
+name: demo
+base_url: http://x
+auth:
+ type: none
+config:
+ format: toml
+ path: ~/.config/demo/config.toml
+resources:
+ items:
+ description: "Items"
+ endpoints:
+ list:
+ method: GET
+ path: /items
+cache:
+ enabled: true
+ stale_after: 6h
+ refresh_timeout: 30s
+ env_opt_out: DEMO_NO_AUTO_REFRESH
+ resources:
+ items: 5m
+share:
+ enabled: true
+ snapshot_tables:
+ - items
+ - sync_state
+ default_repo: git@github.com:acme/demo-snapshots.git
+ default_branch: main
+`
+ s, err := ParseBytes([]byte(input))
+ require.NoError(t, err)
+ assert.True(t, s.Cache.Enabled)
+ assert.Equal(t, "6h", s.Cache.StaleAfter)
+ assert.Equal(t, "30s", s.Cache.RefreshTimeout)
+ assert.Equal(t, "DEMO_NO_AUTO_REFRESH", s.Cache.EnvOptOut)
+ assert.Equal(t, "5m", s.Cache.Resources["items"])
+ assert.True(t, s.Share.Enabled)
+ assert.Equal(t, []string{"items", "sync_state"}, s.Share.SnapshotTables)
+ assert.Equal(t, "git@github.com:acme/demo-snapshots.git", s.Share.DefaultRepo)
+ assert.Equal(t, "main", s.Share.DefaultBranch)
+}
+
+func TestCacheShareAbsentIsBackwardCompatible(t *testing.T) {
+ input := `
+name: demo
+base_url: http://x
+auth:
+ type: none
+config:
+ format: toml
+ path: ~/.config/demo/config.toml
+resources:
+ items:
+ description: "Items"
+ endpoints:
+ list:
+ method: GET
+ path: /items
+`
+ s, err := ParseBytes([]byte(input))
+ require.NoError(t, err)
+ assert.False(t, s.Cache.Enabled)
+ assert.False(t, s.Share.Enabled)
+ assert.Empty(t, s.Cache.Resources)
+ assert.Empty(t, s.Share.SnapshotTables)
+}
+
+func TestCacheShareValidation(t *testing.T) {
+ base := func(cache CacheConfig, share ShareConfig) APISpec {
+ return APISpec{
+ Name: "demo",
+ BaseURL: "http://x",
+ Resources: map[string]Resource{
+ "items": {Endpoints: map[string]Endpoint{"list": {Method: "GET", Path: "/items"}}},
+ },
+ Cache: cache,
+ Share: share,
+ }
+ }
+
+ tests := []struct {
+ name string
+ cache CacheConfig
+ share ShareConfig
+ wantErr string
+ }{
+ {
+ name: "share enabled without snapshot_tables",
+ share: ShareConfig{Enabled: true},
+ wantErr: "non-empty share.snapshot_tables allowlist",
+ },
+ {
+ name: "share snapshot_tables set but disabled",
+ share: ShareConfig{Enabled: false, SnapshotTables: []string{"items"}},
+ wantErr: "snapshot_tables is set but share.enabled is false",
+ },
+ {
+ name: "share table auth_tokens rejected",
+ share: ShareConfig{Enabled: true, SnapshotTables: []string{"auth_tokens"}},
+ wantErr: "denylist",
+ },
+ {
+ name: "share table oauth_cache rejected",
+ share: ShareConfig{Enabled: true, SnapshotTables: []string{"oauth_cache"}},
+ wantErr: "denylist",
+ },
+ {
+ name: "share table session_secrets rejected",
+ share: ShareConfig{Enabled: true, SnapshotTables: []string{"session_secrets"}},
+ wantErr: "denylist",
+ },
+ {
+ name: "share table uppercase rejected",
+ share: ShareConfig{Enabled: true, SnapshotTables: []string{"Items"}},
+ wantErr: "lowercase SQLite identifier",
+ },
+ {
+ name: "share table duplicate rejected",
+ share: ShareConfig{Enabled: true, SnapshotTables: []string{"items", "items"}},
+ wantErr: "appears more than once",
+ },
+ {
+ name: "cache stale_after invalid duration",
+ cache: CacheConfig{Enabled: true, StaleAfter: "yesterday"},
+ wantErr: "not a valid Go duration",
+ },
+ {
+ name: "cache refresh_timeout invalid duration",
+ cache: CacheConfig{Enabled: true, RefreshTimeout: "soonish"},
+ wantErr: "not a valid Go duration",
+ },
+ {
+ name: "cache per-resource invalid duration",
+ cache: CacheConfig{Enabled: true, Resources: map[string]string{"items": "eh"}},
+ wantErr: "not a valid Go duration",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ s := base(tt.cache, tt.share)
+ err := s.Validate()
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), tt.wantErr)
+ })
+ }
+}
+
+func TestCacheShareAcceptsValidShapes(t *testing.T) {
+ tests := []struct {
+ name string
+ cache CacheConfig
+ share ShareConfig
+ }{
+ {
+ name: "cache only, no share",
+ cache: CacheConfig{Enabled: true, StaleAfter: "6h", RefreshTimeout: "30s"},
+ },
+ {
+ name: "cache with per-resource overrides",
+ cache: CacheConfig{Enabled: true, StaleAfter: "6h", Resources: map[string]string{"items": "5m", "teams": "24h"}},
+ },
+ {
+ name: "share only, no cache",
+ share: ShareConfig{Enabled: true, SnapshotTables: []string{"items", "teams", "sync_state"}},
+ },
+ {
+ name: "cache and share both enabled",
+ cache: CacheConfig{Enabled: true, StaleAfter: "6h"},
+ share: ShareConfig{Enabled: true, SnapshotTables: []string{"items"}, DefaultBranch: "main"},
+ },
+ {
+ name: "composite duration (90m, 1h30m) accepted",
+ cache: CacheConfig{Enabled: true, StaleAfter: "1h30m"},
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ s := APISpec{
+ Name: "demo",
+ BaseURL: "http://x",
+ Resources: map[string]Resource{"items": {Endpoints: map[string]Endpoint{"list": {Method: "GET", Path: "/items"}}}},
+ Cache: tt.cache,
+ Share: tt.share,
+ }
+ require.NoError(t, s.Validate())
+ })
+ }
+}
← ab7254b1 docs(cli): fix plugin install commands in README (#230)
·
back to Cli Printing Press
·
feat(cli): auto-refresh stale caches before read commands (# 4c05e1ee →