Skip to content

Commit 0529851

Browse files
greynewellclaude
andcommitted
feat: home/root guard, size warning, offline fallback, cache TTL, self-update, enhanced status, exclude_patterns
- Home/root guard: refuse to analyze / watch from ~ or / (issue #115) - Upload size warning: warn when archive exceeds 50 MB with hint to use .supermodel.json - Graceful offline fallback: serve stale cached graph when API is unreachable - Cache TTL: 30-day expiry on Get(); new Prune(maxAge), Stats(), NewestEntry() helpers - .supermodel.json exclude_patterns: glob patterns for full path exclusions (e.g. "**/testdata/**") - Enhanced status: shows cache size in bytes, last analysis timestamp - Self-update: `supermodel update` downloads and installs the latest GitHub release; `supermodel update --check` prints available version without installing Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 5552bb6 commit 0529851

8 files changed

Lines changed: 613 additions & 17 deletions

File tree

cmd/update.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
7+
"github.com/spf13/cobra"
8+
9+
"github.com/supermodeltools/cli/internal/build"
10+
"github.com/supermodeltools/cli/internal/update"
11+
)
12+
13+
func init() {
14+
var checkOnly bool
15+
16+
c := &cobra.Command{
17+
Use: "update",
18+
Short: "Update supermodel to the latest release",
19+
Long: `Checks for a newer release on GitHub and, if found, downloads and installs it.
20+
21+
Use --check to only print the latest available version without installing.`,
22+
SilenceUsage: true,
23+
RunE: func(cmd *cobra.Command, args []string) error {
24+
if checkOnly {
25+
latest, err := update.Check()
26+
if err != nil {
27+
return fmt.Errorf("check for updates: %w", err)
28+
}
29+
current := strings.TrimPrefix(build.Version, "v")
30+
latestClean := strings.TrimPrefix(latest, "v")
31+
if current == latestClean {
32+
fmt.Printf("supermodel %s is up to date\n", build.Version)
33+
} else {
34+
fmt.Printf("current: %s → latest: %s\n", build.Version, latest)
35+
fmt.Println("Run `supermodel update` to install.")
36+
}
37+
return nil
38+
}
39+
40+
fmt.Println("Checking for updates…")
41+
updated, err := update.Run()
42+
if err != nil {
43+
return err
44+
}
45+
if updated {
46+
fmt.Printf("Updated to the latest version. Run `supermodel version` to confirm.\n")
47+
} else {
48+
fmt.Printf("supermodel %s is already up to date.\n", build.Version)
49+
}
50+
return nil
51+
},
52+
}
53+
54+
c.Flags().BoolVar(&checkOnly, "check", false, "check for updates without installing")
55+
rootCmd.AddCommand(c)
56+
57+
// Allow `update` to run without an API key (it's independent of auth)
58+
noConfigCommands["update"] = true
59+
}

internal/analyze/handler.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ func Run(ctx context.Context, cfg *config.Config, dir string, opts Options) erro
3737
// Uses git-based fingerprinting (~1ms for clean repos) to check the cache
3838
// before creating a zip. Only creates and uploads the zip on cache miss.
3939
func GetGraph(ctx context.Context, cfg *config.Config, dir string, force bool) (*api.Graph, string, error) {
40+
if err := guardDir(dir); err != nil {
41+
return nil, "", err
42+
}
43+
4044
// Fast path: check cache using git fingerprint before creating zip.
4145
if !force {
4246
fingerprint, err := cache.RepoFingerprint(dir)
@@ -58,6 +62,13 @@ func GetGraph(ctx context.Context, cfg *config.Config, dir string, force bool) (
5862
}
5963
defer os.Remove(zipPath)
6064

65+
if info, statErr := os.Stat(zipPath); statErr == nil {
66+
sizeMB := float64(info.Size()) / (1 << 20)
67+
if sizeMB > 50 {
68+
ui.Warn("Archive is %.0f MB — add patterns to .supermodel.json to reduce upload size", sizeMB)
69+
}
70+
}
71+
6172
hash, err := cache.HashFile(zipPath)
6273
if err != nil {
6374
return nil, "", err
@@ -76,6 +87,18 @@ func GetGraph(ctx context.Context, cfg *config.Config, dir string, force bool) (
7687
ir, err := client.AnalyzeShards(ctx, zipPath, "analyze-"+hash[:16], nil)
7788
spin.Stop()
7889
if err != nil {
90+
// Network/API failure — serve stale cache if available rather than hard-failing.
91+
if stale, _ := cache.Get(hash); stale != nil {
92+
ui.Warn("API unavailable (%v) — using stale cached result", err)
93+
return stale, hash, nil
94+
}
95+
if fp, fpErr := cache.RepoFingerprint(dir); fpErr == nil {
96+
fpKey := cache.AnalysisKey(fp, "graph", build.Version)
97+
if stale, _ := cache.Get(fpKey); stale != nil {
98+
ui.Warn("API unavailable (%v) — using stale cached result", err)
99+
return stale, fpKey, nil
100+
}
101+
}
79102
return nil, hash, err
80103
}
81104

internal/analyze/zip.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,25 @@ var skipDirs = map[string]bool{
3838
"venv": true,
3939
}
4040

41+
// guardDir returns an error if dir is the filesystem root or the user's home
42+
// directory — running analysis there would upload far too much.
43+
func guardDir(dir string) error {
44+
abs, err := filepath.Abs(dir)
45+
if err != nil {
46+
return err
47+
}
48+
// Reject root (Unix "/" or Windows "C:\")
49+
vol := filepath.VolumeName(abs)
50+
if abs == vol+string(filepath.Separator) || abs == string(filepath.Separator) {
51+
return fmt.Errorf("refusing to run in root directory — specify a project directory")
52+
}
53+
home, _ := os.UserHomeDir()
54+
if home != "" && abs == home {
55+
return fmt.Errorf("refusing to run in home directory (%s) — specify a project directory with --dir", abs)
56+
}
57+
return nil
58+
}
59+
4160
// createZip archives the repository at dir into a temporary ZIP file and
4261
// returns its path. The caller is responsible for removing the file.
4362
//

internal/cache/cache.go

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,10 @@ func dir() string {
2424
return filepath.Join(config.Dir(), "cache")
2525
}
2626

27-
// Get loads a cached graph for hash. Returns (nil, nil) on cache miss.
27+
// DefaultTTL is how long cached entries are considered fresh.
28+
const DefaultTTL = 30 * 24 * time.Hour
29+
30+
// Get loads a cached graph for hash. Returns (nil, nil) on cache miss or expiry.
2831
func Get(hash string) (*api.Graph, error) {
2932
data, err := os.ReadFile(filepath.Join(dir(), hash+".json"))
3033
if os.IsNotExist(err) {
@@ -37,9 +40,83 @@ func Get(hash string) (*api.Graph, error) {
3740
if err := json.Unmarshal(data, &e); err != nil {
3841
return nil, fmt.Errorf("parse cache: %w", err)
3942
}
43+
if !e.CachedAt.IsZero() && time.Since(e.CachedAt) > DefaultTTL {
44+
_ = Evict(hash) // stale — evict silently
45+
return nil, nil
46+
}
4047
return e.Graph, nil
4148
}
4249

50+
// Prune removes cache entries older than maxAge. Returns the number removed.
51+
func Prune(maxAge time.Duration) (int, error) {
52+
entries, err := os.ReadDir(dir())
53+
if os.IsNotExist(err) {
54+
return 0, nil
55+
}
56+
if err != nil {
57+
return 0, fmt.Errorf("read cache dir: %w", err)
58+
}
59+
removed := 0
60+
for _, e := range entries {
61+
if e.IsDir() || filepath.Ext(e.Name()) != ".json" {
62+
continue
63+
}
64+
info, err := e.Info()
65+
if err != nil {
66+
continue
67+
}
68+
if time.Since(info.ModTime()) > maxAge {
69+
if rmErr := os.Remove(filepath.Join(dir(), e.Name())); rmErr == nil {
70+
removed++
71+
}
72+
}
73+
}
74+
return removed, nil
75+
}
76+
77+
// Stats returns aggregate cache metrics: entry count and total size in bytes.
78+
func Stats() (count int, sizeBytes int64) {
79+
entries, err := os.ReadDir(dir())
80+
if err != nil {
81+
return 0, 0
82+
}
83+
for _, e := range entries {
84+
if e.IsDir() || filepath.Ext(e.Name()) != ".json" {
85+
continue
86+
}
87+
info, err := e.Info()
88+
if err != nil {
89+
continue
90+
}
91+
count++
92+
sizeBytes += info.Size()
93+
}
94+
return count, sizeBytes
95+
}
96+
97+
// NewestEntry returns the modification time of the most recently written cache
98+
// entry, or the zero time if the cache is empty.
99+
func NewestEntry() time.Time {
100+
entries, err := os.ReadDir(dir())
101+
if err != nil {
102+
return time.Time{}
103+
}
104+
var newest time.Time
105+
for _, e := range entries {
106+
if e.IsDir() || filepath.Ext(e.Name()) != ".json" {
107+
continue
108+
}
109+
info, err := e.Info()
110+
if err != nil {
111+
continue
112+
}
113+
if info.ModTime().After(newest) {
114+
newest = info.ModTime()
115+
}
116+
}
117+
return newest
118+
}
119+
43120
// Put stores g in the cache under hash.
44121
func Put(hash string, g *api.Graph) error {
45122
if err := os.MkdirAll(dir(), 0o700); err != nil {

internal/shards/handler.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,12 +56,33 @@ type RenderOptions struct {
5656
ThreeFile bool
5757
}
5858

59+
// guardDir returns an error if dir is the filesystem root or the user's home
60+
// directory — running analysis there would upload far too much.
61+
func guardDir(dir string) error {
62+
abs, err := filepath.Abs(dir)
63+
if err != nil {
64+
return err
65+
}
66+
vol := filepath.VolumeName(abs)
67+
if abs == vol+string(filepath.Separator) || abs == string(filepath.Separator) {
68+
return fmt.Errorf("refusing to run in root directory — specify a project directory with --dir")
69+
}
70+
home, _ := os.UserHomeDir()
71+
if home != "" && abs == home {
72+
return fmt.Errorf("refusing to run in home directory (%s) — specify a project directory with --dir", abs)
73+
}
74+
return nil
75+
}
76+
5977
// Generate uploads a zip, builds the graph cache, and renders all shards.
6078
func Generate(ctx context.Context, cfg *config.Config, dir string, opts GenerateOptions) error {
6179
repoDir, err := filepath.Abs(dir)
6280
if err != nil {
6381
return fmt.Errorf("resolving path: %w", err)
6482
}
83+
if err := guardDir(repoDir); err != nil {
84+
return err
85+
}
6586

6687
cacheFile := opts.CacheFile
6788
if cacheFile == "" {
@@ -102,6 +123,13 @@ func Generate(ctx context.Context, cfg *config.Config, dir string, opts Generate
102123
}
103124
defer os.Remove(zipPath)
104125

126+
if info, statErr := os.Stat(zipPath); statErr == nil {
127+
sizeMB := float64(info.Size()) / (1 << 20)
128+
if sizeMB > 50 {
129+
ui.Warn("Archive is %.0f MB — add exclude_dirs or exclude_patterns to .supermodel.json to reduce upload size", sizeMB)
130+
}
131+
}
132+
105133
// Read previous domains from cache for LLM seeding (stabilizes names across refreshes)
106134
var prevDomains []api.PreviousDomain
107135
if data, readErr := os.ReadFile(cacheFile); readErr == nil {
@@ -123,6 +151,22 @@ func Generate(ctx context.Context, cfg *config.Config, dir string, opts Generate
123151
ir, err := client.AnalyzeShards(ctx, zipPath, "shards-"+idemKey[:8], prevDomains)
124152
spin.Stop()
125153
if err != nil {
154+
// Network/API failure — fall back to stale cache and re-render rather than hard-failing.
155+
if data, readErr := os.ReadFile(cacheFile); readErr == nil {
156+
var staleIR api.ShardIR
157+
if json.Unmarshal(data, &staleIR) == nil && len(staleIR.Graph.Nodes) > 0 {
158+
ui.Warn("API unavailable (%v) — rendering from stale cache", err)
159+
staleCache := NewCache()
160+
staleCache.Build(&staleIR)
161+
files := staleCache.SourceFiles()
162+
written, renderErr := renderShards(repoDir, staleCache, files, opts.DryRun, opts.ThreeFile)
163+
if renderErr != nil {
164+
return fmt.Errorf("API error: %w; stale render also failed: %v", err, renderErr)
165+
}
166+
ui.Success("Wrote %d shards from stale cache (%d nodes)", written, len(staleIR.Graph.Nodes))
167+
return nil
168+
}
169+
}
126170
return err
127171
}
128172

@@ -166,6 +210,9 @@ func Watch(ctx context.Context, cfg *config.Config, dir string, opts WatchOption
166210
if err != nil {
167211
return fmt.Errorf("resolving path: %w", err)
168212
}
213+
if err := guardDir(repoDir); err != nil {
214+
return err
215+
}
169216

170217
cacheFile := opts.CacheFile
171218
if cacheFile == "" {

0 commit comments

Comments
 (0)