← back to Cli Printing Press
feat(generator): add compound workflow template with archive and status
d51d1e89b6a26a852e20ffe9c7608ef888fe1af2 · 2026-03-25 21:43:05 -0700 · Matt Van Horn
New channel_workflow.go.tmpl generates 'workflow archive' (syncs all
syncable resources to local store) and 'workflow status' (shows
archive completeness). Registered in root.go when Store is enabled.
Scorecard detects _workflow suffix for Vision scoring.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Files touched
M internal/generator/generator.goM internal/generator/generator_test.goA internal/generator/templates/channel_workflow.go.tmplM internal/generator/templates/root.go.tmpl
Diff
commit d51d1e89b6a26a852e20ffe9c7608ef888fe1af2
Author: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Date: Wed Mar 25 21:43:05 2026 -0700
feat(generator): add compound workflow template with archive and status
New channel_workflow.go.tmpl generates 'workflow archive' (syncs all
syncable resources to local store) and 'workflow status' (shows
archive completeness). Registered in root.go when Store is enabled.
Scorecard detects _workflow suffix for Vision scoring.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---
internal/generator/generator.go | 16 ++
internal/generator/generator_test.go | 6 +-
.../generator/templates/channel_workflow.go.tmpl | 202 +++++++++++++++++++++
internal/generator/templates/root.go.tmpl | 3 +
4 files changed, 224 insertions(+), 3 deletions(-)
diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index 01d13ad9..9a75decf 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -243,6 +243,22 @@ func (g *Generator) Generate() error {
}
}
+ // Render workflow template when store is enabled (root.go registers it conditionally on VisionSet.Store)
+ if g.VisionSet.Store {
+ workflowData := struct {
+ *spec.APISpec
+ SyncableResources []string
+ SearchableFields map[string][]string
+ }{
+ APISpec: g.Spec,
+ SyncableResources: g.profile.SyncableResources,
+ SearchableFields: g.profile.SearchableFields,
+ }
+ if err := g.renderTemplate("channel_workflow.go.tmpl", filepath.Join("internal", "cli", "channel_workflow.go"), workflowData); err != nil {
+ return fmt.Errorf("rendering workflow: %w", err)
+ }
+ }
+
rootData := struct {
*spec.APISpec
VisionSet VisionTemplateSet
diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index 4dbba2c1..0290be66 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -21,9 +21,9 @@ func TestGenerateProjectsCompile(t *testing.T) {
specPath string
expectedFiles int
}{
- {name: "stytch", specPath: filepath.Join("..", "..", "testdata", "stytch.yaml"), expectedFiles: 25},
- {name: "clerk", specPath: filepath.Join("..", "..", "testdata", "clerk.yaml"), expectedFiles: 30},
- {name: "loops", specPath: filepath.Join("..", "..", "testdata", "loops.yaml"), expectedFiles: 28},
+ {name: "stytch", specPath: filepath.Join("..", "..", "testdata", "stytch.yaml"), expectedFiles: 26},
+ {name: "clerk", specPath: filepath.Join("..", "..", "testdata", "clerk.yaml"), expectedFiles: 31},
+ {name: "loops", specPath: filepath.Join("..", "..", "testdata", "loops.yaml"), expectedFiles: 29},
}
for _, tt := range tests {
diff --git a/internal/generator/templates/channel_workflow.go.tmpl b/internal/generator/templates/channel_workflow.go.tmpl
new file mode 100644
index 00000000..c6129015
--- /dev/null
+++ b/internal/generator/templates/channel_workflow.go.tmpl
@@ -0,0 +1,202 @@
+// Code generated by CLI Printing Press. DO NOT EDIT.
+
+package cli
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "time"
+
+ "github.com/{{.Owner}}/{{.Name}}-cli/internal/store"
+ "github.com/spf13/cobra"
+)
+
+func newWorkflowCmd(flags *rootFlags) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "workflow",
+ Short: "Compound workflows that combine multiple API operations",
+ }
+
+ cmd.AddCommand(newWorkflowArchiveCmd(flags))
+ cmd.AddCommand(newWorkflowStatusCmd(flags))
+
+ return cmd
+}
+
+func newWorkflowArchiveCmd(flags *rootFlags) *cobra.Command {
+ var dbPath string
+ var full bool
+
+ cmd := &cobra.Command{
+ Use: "archive",
+ Short: "Sync all resources to local store for offline access and search",
+ Long: `Archive fetches all syncable resources from the API and stores them in a
+local SQLite database. Supports incremental sync (only new data since last run)
+and full resync. After archiving, use 'search' for instant full-text search.`,
+ Example: ` # Archive all resources
+ {{.Name}}-cli workflow archive
+
+ # Full re-archive (ignore previous sync state)
+ {{.Name}}-cli workflow archive --full`,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ c, err := flags.newClient()
+ if err != nil {
+ return err
+ }
+ c.NoCache = true
+
+ if dbPath == "" {
+ dbPath = defaultDBPath("{{.Name}}-cli")
+ }
+ s, err := store.Open(dbPath)
+ if err != nil {
+ return fmt.Errorf("opening store: %w", err)
+ }
+ defer s.Close()
+
+ resources := []string{ {{- range .SyncableResources}}"{{.}}", {{end}} }
+ totalSynced := 0
+
+ for _, resource := range resources {
+ cursor := ""
+ if !full {
+ existing, _, _, err := s.GetSyncState(resource)
+ if err == nil && existing != "" {
+ cursor = existing
+ }
+ }
+
+ fmt.Fprintf(cmd.ErrOrStderr(), "Syncing %s...\n", resource)
+
+ params := map[string]string{"limit": "100"}
+ if cursor != "" {
+ params["after"] = cursor
+ }
+
+ count := 0
+ for {
+ data, fetchErr := c.Get("/"+resource, params)
+ if fetchErr != nil {
+ fmt.Fprintf(cmd.ErrOrStderr(), " warning: %s: %v\n", resource, fetchErr)
+ break
+ }
+ var items []json.RawMessage
+ if err := json.Unmarshal(data, &items); err != nil {
+ // Might be a single object, not array
+ if err := s.Upsert(resource, resource+"-singleton", data); err != nil {
+ fmt.Fprintf(cmd.ErrOrStderr(), " warning: store %s: %v\n", resource, err)
+ }
+ count++
+ break
+ }
+ if len(items) == 0 {
+ break
+ }
+ for _, item := range items {
+ var obj struct{ ID string `json:"id"` }
+ json.Unmarshal(item, &obj)
+ id := obj.ID
+ if id == "" {
+ id = fmt.Sprintf("%s-%d", resource, count)
+ }
+ if err := s.Upsert(resource, id, item); err != nil {
+ fmt.Fprintf(cmd.ErrOrStderr(), " warning: store %s/%s: %v\n", resource, id, err)
+ }
+ cursor = id
+ count++
+ }
+ if len(items) < 100 {
+ break
+ }
+ params["after"] = cursor
+ }
+
+ if count > 0 {
+ s.SaveSyncState(resource, cursor, count)
+ }
+ totalSynced += count
+ fmt.Fprintf(cmd.ErrOrStderr(), " %s: %d items\n", resource, count)
+ }
+
+ if flags.asJSON {
+ enc := json.NewEncoder(cmd.OutOrStdout())
+ enc.SetIndent("", " ")
+ return enc.Encode(map[string]any{
+ "resources_synced": len(resources),
+ "total_items": totalSynced,
+ "store_path": dbPath,
+ "timestamp": time.Now().UTC().Format(time.RFC3339),
+ })
+ }
+
+ fmt.Fprintf(cmd.OutOrStdout(), "Archived %d items across %d resources to %s\n", totalSynced, len(resources), dbPath)
+ return nil
+ },
+ }
+
+ cmd.Flags().StringVar(&dbPath, "db", "", "Database path (default: ~/.config/{{.Name}}-cli/store.db)")
+ cmd.Flags().BoolVar(&full, "full", false, "Full re-archive (ignore previous sync state)")
+
+ return cmd
+}
+
+func newWorkflowStatusCmd(flags *rootFlags) *cobra.Command {
+ var dbPath string
+
+ cmd := &cobra.Command{
+ Use: "status",
+ Short: "Show local archive status and sync state for all resources",
+ Example: ` # Show archive status
+ {{.Name}}-cli workflow status
+
+ # Show status as JSON
+ {{.Name}}-cli workflow status --json`,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if dbPath == "" {
+ dbPath = defaultDBPath("{{.Name}}-cli")
+ }
+ s, err := store.Open(dbPath)
+ if err != nil {
+ return fmt.Errorf("opening store: %w", err)
+ }
+ defer s.Close()
+
+ status, err := s.Status()
+ if err != nil {
+ return err
+ }
+
+ if flags.asJSON {
+ enc := json.NewEncoder(cmd.OutOrStdout())
+ enc.SetIndent("", " ")
+ return enc.Encode(status)
+ }
+
+ if len(status) == 0 {
+ fmt.Fprintln(cmd.OutOrStdout(), "No archived data. Run 'workflow archive' to sync.")
+ return nil
+ }
+
+ fmt.Fprintln(cmd.OutOrStdout(), "Archive Status:")
+ total := 0
+ for resource, count := range status {
+ fmt.Fprintf(cmd.OutOrStdout(), " %-30s %d items\n", resource, count)
+ total += count
+ }
+ fmt.Fprintf(cmd.OutOrStdout(), "\n Total: %d items\n", total)
+ fmt.Fprintf(cmd.OutOrStdout(), " Store: %s\n", dbPath)
+ return nil
+ },
+ }
+
+ cmd.Flags().StringVar(&dbPath, "db", "", "Database path")
+
+ return cmd
+}
+
+func defaultDBPath(name string) string {
+ home, _ := os.UserHomeDir()
+ return filepath.Join(home, ".config", name, "store.db")
+}
diff --git a/internal/generator/templates/root.go.tmpl b/internal/generator/templates/root.go.tmpl
index f9787535..b7ce9e90 100644
--- a/internal/generator/templates/root.go.tmpl
+++ b/internal/generator/templates/root.go.tmpl
@@ -76,6 +76,9 @@ func Execute() error {
{{- end}}
{{- if .VisionSet.Analytics}}
rootCmd.AddCommand(newAnalyticsCmd(&flags))
+{{- end}}
+{{- if .VisionSet.Store}}
+ rootCmd.AddCommand(newWorkflowCmd(&flags))
{{- end}}
rootCmd.AddCommand(newVersionCliCmd())
← bbd0a47c feat(store): generate per-resource SQLite tables from profil
·
back to Cli Printing Press
·
feat(press): v2 - depth over breadth, creativity over mechan 13860ec0 →