[object Object]

← 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

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 →