@tanagram/cli 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/commands/list.go +75 -19
- package/commands/login.go +159 -0
- package/commands/run.go +36 -7
- package/commands/sync_policies.go +88 -0
- package/git/repo.go +54 -0
- package/go.mod +5 -0
- package/go.sum +11 -0
- package/main.go +21 -1
- package/package.json +1 -1
- package/storage/cloud_policies.go +258 -0
package/commands/list.go
CHANGED
|
@@ -3,10 +3,11 @@ package commands
|
|
|
3
3
|
import (
|
|
4
4
|
"fmt"
|
|
5
5
|
|
|
6
|
+
gitpkg "github.com/tanagram/cli/git"
|
|
6
7
|
"github.com/tanagram/cli/storage"
|
|
7
8
|
)
|
|
8
9
|
|
|
9
|
-
// List displays all cached policies
|
|
10
|
+
// List displays all cached policies (both local and cloud)
|
|
10
11
|
func List() error {
|
|
11
12
|
// Find git root
|
|
12
13
|
gitRoot, err := storage.FindGitRoot()
|
|
@@ -14,42 +15,97 @@ func List() error {
|
|
|
14
15
|
return err
|
|
15
16
|
}
|
|
16
17
|
|
|
17
|
-
// Load cache
|
|
18
|
+
// Load local cache
|
|
18
19
|
cache, err := storage.LoadCache(gitRoot)
|
|
19
20
|
if err != nil {
|
|
20
21
|
return fmt.Errorf("failed to load cache: %w", err)
|
|
21
22
|
}
|
|
22
23
|
|
|
23
|
-
|
|
24
|
-
|
|
24
|
+
// Load local policies from cache
|
|
25
|
+
localPolicies, err := cache.GetAllPolicies()
|
|
26
|
+
if err != nil {
|
|
27
|
+
return fmt.Errorf("failed to load local policies: %w", err)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Try to load cloud policies for current repo
|
|
31
|
+
cloudPolicies := []storage.SimplifiedPolicy{}
|
|
32
|
+
var repoInfo *gitpkg.RepoInfo
|
|
33
|
+
|
|
34
|
+
cloudStorage := storage.NewCloudPolicyStorage(gitRoot)
|
|
35
|
+
repoInfo, err = gitpkg.GetCurrentRepo()
|
|
36
|
+
if err == nil {
|
|
37
|
+
// Successfully detected repo, try to load cloud policies
|
|
38
|
+
repoFile, err := cloudStorage.LoadPoliciesForRepo(repoInfo.Owner, repoInfo.Name)
|
|
39
|
+
if err == nil && repoFile != nil {
|
|
40
|
+
cloudPolicies = repoFile.Policies
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Check if we have any policies to display
|
|
45
|
+
if len(localPolicies) == 0 && len(cloudPolicies) == 0 {
|
|
46
|
+
fmt.Println("No policies found.")
|
|
47
|
+
fmt.Println("\nTo get started:")
|
|
48
|
+
fmt.Println(" • Local policies: Run 'tanagram sync' to extract from AGENTS.md, POLICIES.md, etc.")
|
|
49
|
+
fmt.Println(" • Cloud policies: Run 'tanagram sync-policies' to fetch from Tanagram")
|
|
25
50
|
return nil
|
|
26
51
|
}
|
|
27
52
|
|
|
28
|
-
|
|
53
|
+
// Display local policies
|
|
54
|
+
if len(localPolicies) > 0 {
|
|
55
|
+
fmt.Println("═══════════════════════════════════════════════════════════════")
|
|
56
|
+
fmt.Printf("LOCAL POLICIES (%d policies)\n", len(localPolicies))
|
|
57
|
+
fmt.Println("═══════════════════════════════════════════════════════════════")
|
|
58
|
+
fmt.Println("Source: Local instruction files (AGENTS.md, POLICIES.md, etc.)")
|
|
59
|
+
fmt.Println()
|
|
29
60
|
|
|
30
|
-
|
|
61
|
+
// Group by file
|
|
62
|
+
fileGroups := make(map[string][]storage.SerializablePolicy)
|
|
63
|
+
for filepath, serializablePolicies := range cache.Policies {
|
|
64
|
+
if len(serializablePolicies) > 0 {
|
|
65
|
+
fileGroups[filepath] = serializablePolicies
|
|
66
|
+
}
|
|
67
|
+
}
|
|
31
68
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
69
|
+
for filepath, serializablePolicies := range fileGroups {
|
|
70
|
+
fmt.Printf("📄 %s (%d policies)\n", filepath, len(serializablePolicies))
|
|
71
|
+
fmt.Println("───────────────────────────────────────────────────────────────")
|
|
72
|
+
for _, sp := range serializablePolicies {
|
|
73
|
+
fmt.Printf(" • %s: %s\n", sp.Name, sp.Message)
|
|
74
|
+
}
|
|
75
|
+
fmt.Println()
|
|
36
76
|
}
|
|
77
|
+
}
|
|
37
78
|
|
|
38
|
-
|
|
39
|
-
|
|
79
|
+
// Display cloud policies
|
|
80
|
+
if len(cloudPolicies) > 0 {
|
|
81
|
+
fmt.Println("═══════════════════════════════════════════════════════════════")
|
|
82
|
+
fmt.Printf("CLOUD POLICIES (%d policies)\n", len(cloudPolicies))
|
|
83
|
+
fmt.Println("═══════════════════════════════════════════════════════════════")
|
|
84
|
+
fmt.Printf("Source: Tanagram (repository: %s/%s)\n", repoInfo.Owner, repoInfo.Name)
|
|
85
|
+
fmt.Println()
|
|
40
86
|
|
|
41
|
-
for _,
|
|
42
|
-
|
|
43
|
-
|
|
87
|
+
for _, cp := range cloudPolicies {
|
|
88
|
+
status := "✓ enabled"
|
|
89
|
+
if !cp.Enabled {
|
|
90
|
+
status = "✗ disabled"
|
|
91
|
+
}
|
|
92
|
+
fmt.Printf(" • %s [%s]\n", cp.Name, status)
|
|
93
|
+
if cp.Message != "" {
|
|
94
|
+
fmt.Printf(" Message: %s\n", cp.Message)
|
|
95
|
+
}
|
|
44
96
|
}
|
|
45
97
|
fmt.Println()
|
|
46
98
|
}
|
|
47
99
|
|
|
48
100
|
// Summary
|
|
49
|
-
fmt.Println("
|
|
50
|
-
fmt.Printf("
|
|
51
|
-
fmt.
|
|
52
|
-
fmt.
|
|
101
|
+
fmt.Println("═══════════════════════════════════════════════════════════════")
|
|
102
|
+
fmt.Printf("SUMMARY\n")
|
|
103
|
+
fmt.Println("═══════════════════════════════════════════════════════════════")
|
|
104
|
+
fmt.Printf("Local policies: %d\n", len(localPolicies))
|
|
105
|
+
fmt.Printf("Cloud policies: %d\n", len(cloudPolicies))
|
|
106
|
+
fmt.Printf("Total enforced: %d\n", len(localPolicies)+len(cloudPolicies))
|
|
107
|
+
fmt.Println("\nNote: Cloud policies take precedence over local policies with the same name")
|
|
108
|
+
fmt.Println("All policies are enforced using LLM-based semantic analysis")
|
|
53
109
|
|
|
54
110
|
return nil
|
|
55
111
|
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
package commands
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"crypto/rand"
|
|
6
|
+
"crypto/sha256"
|
|
7
|
+
"encoding/base64"
|
|
8
|
+
"fmt"
|
|
9
|
+
"net"
|
|
10
|
+
"net/http"
|
|
11
|
+
"net/url"
|
|
12
|
+
"os"
|
|
13
|
+
"time"
|
|
14
|
+
|
|
15
|
+
"github.com/pkg/browser"
|
|
16
|
+
"github.com/tanagram/cli/auth"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
// getWebAppURL returns the web app URL based on environment
|
|
20
|
+
func getWebAppURL() string {
|
|
21
|
+
// Check environment variable first
|
|
22
|
+
if url := os.Getenv("TANAGRAM_WEB_URL"); url != "" {
|
|
23
|
+
return url
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Default to production
|
|
27
|
+
return "https://web.tanagram.ai"
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// generateCodeVerifier generates a random code verifier for PKCE
|
|
31
|
+
func generateCodeVerifier() (string, error) {
|
|
32
|
+
// Generate 32 random bytes (256 bits)
|
|
33
|
+
b := make([]byte, 32)
|
|
34
|
+
if _, err := rand.Read(b); err != nil {
|
|
35
|
+
return "", err
|
|
36
|
+
}
|
|
37
|
+
// Base64 URL encode without padding
|
|
38
|
+
return base64.RawURLEncoding.EncodeToString(b), nil
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// generateCodeChallenge generates the code challenge from the verifier
|
|
42
|
+
func generateCodeChallenge(verifier string) string {
|
|
43
|
+
hash := sha256.Sum256([]byte(verifier))
|
|
44
|
+
return base64.RawURLEncoding.EncodeToString(hash[:])
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Login implements the OAuth PKCE flow for CLI authentication
|
|
48
|
+
func Login() error {
|
|
49
|
+
// Generate PKCE parameters
|
|
50
|
+
codeVerifier, err := generateCodeVerifier()
|
|
51
|
+
if err != nil {
|
|
52
|
+
return fmt.Errorf("failed to generate code verifier: %w", err)
|
|
53
|
+
}
|
|
54
|
+
codeChallenge := generateCodeChallenge(codeVerifier)
|
|
55
|
+
|
|
56
|
+
// Create a channel to receive the authorization code
|
|
57
|
+
codeChan := make(chan string, 1)
|
|
58
|
+
errChan := make(chan error, 1)
|
|
59
|
+
|
|
60
|
+
// Start local HTTP server for callback on a dynamic port
|
|
61
|
+
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
62
|
+
if err != nil {
|
|
63
|
+
return fmt.Errorf("failed to start callback server: %w", err)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Get the actual port assigned
|
|
67
|
+
port := listener.Addr().(*net.TCPAddr).Port
|
|
68
|
+
redirectURI := fmt.Sprintf("http://127.0.0.1:%d/callback", port)
|
|
69
|
+
|
|
70
|
+
// Setup callback handler
|
|
71
|
+
mux := http.NewServeMux()
|
|
72
|
+
mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) {
|
|
73
|
+
code := r.URL.Query().Get("code")
|
|
74
|
+
if code == "" {
|
|
75
|
+
errChan <- fmt.Errorf("no authorization code received")
|
|
76
|
+
http.Error(w, "Authorization failed", http.StatusBadRequest)
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Send success message to browser
|
|
81
|
+
w.Header().Set("Content-Type", "text/html")
|
|
82
|
+
fmt.Fprintf(w, `
|
|
83
|
+
<!DOCTYPE html>
|
|
84
|
+
<html>
|
|
85
|
+
<head><title>Tanagram Login</title></head>
|
|
86
|
+
<body style="font-family: system-ui; text-align: center; padding: 50px;">
|
|
87
|
+
<h1>✓ Login Successful</h1>
|
|
88
|
+
<p>You can close this window and return to your terminal.</p>
|
|
89
|
+
</body>
|
|
90
|
+
</html>
|
|
91
|
+
`)
|
|
92
|
+
|
|
93
|
+
codeChan <- code
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
// Start the HTTP server
|
|
97
|
+
server := &http.Server{Handler: mux}
|
|
98
|
+
go func() {
|
|
99
|
+
if err := server.Serve(listener); err != nil && err != http.ErrServerClosed {
|
|
100
|
+
errChan <- fmt.Errorf("callback server error: %w", err)
|
|
101
|
+
}
|
|
102
|
+
}()
|
|
103
|
+
|
|
104
|
+
// Construct authorization URL pointing to web app's CLI authorization page
|
|
105
|
+
authURL := fmt.Sprintf("%s/cli-authorize?%s", getWebAppURL(), url.Values{
|
|
106
|
+
"redirect_uri": {redirectURI},
|
|
107
|
+
"code_challenge": {codeChallenge},
|
|
108
|
+
"code_challenge_method": {"S256"},
|
|
109
|
+
}.Encode())
|
|
110
|
+
|
|
111
|
+
fmt.Println("Opening browser for authentication...")
|
|
112
|
+
fmt.Printf("If the browser doesn't open automatically, visit:\n%s\n\n", authURL)
|
|
113
|
+
|
|
114
|
+
// Open browser
|
|
115
|
+
if err := browser.OpenURL(authURL); err != nil {
|
|
116
|
+
fmt.Printf("Warning: Could not open browser automatically: %v\n", err)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
fmt.Println("Waiting for authentication...")
|
|
120
|
+
|
|
121
|
+
// Wait for callback or timeout
|
|
122
|
+
var authCode string
|
|
123
|
+
select {
|
|
124
|
+
case authCode = <-codeChan:
|
|
125
|
+
// Success
|
|
126
|
+
case err := <-errChan:
|
|
127
|
+
return err
|
|
128
|
+
case <-time.After(5 * time.Minute):
|
|
129
|
+
return fmt.Errorf("authentication timeout - please try again")
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Shutdown the callback server
|
|
133
|
+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
134
|
+
defer cancel()
|
|
135
|
+
server.Shutdown(ctx)
|
|
136
|
+
|
|
137
|
+
// The authorization code is actually the session JWT from the web app
|
|
138
|
+
// No need to exchange - we can use it directly
|
|
139
|
+
sessionJWT := authCode
|
|
140
|
+
|
|
141
|
+
// Verify token is valid
|
|
142
|
+
if sessionJWT == "" {
|
|
143
|
+
return fmt.Errorf("received empty session token")
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Store session JWT in OS keyring
|
|
147
|
+
fmt.Println("Storing session token securely...")
|
|
148
|
+
|
|
149
|
+
if err := auth.SetAccessToken(sessionJWT); err != nil {
|
|
150
|
+
return fmt.Errorf("failed to store session token: %w", err)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
fmt.Println("\n✓ Successfully logged in to Tanagram")
|
|
154
|
+
fmt.Printf("Session token stored securely in OS keyring\n")
|
|
155
|
+
|
|
156
|
+
return nil
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Removed exchangeCodeForTokens - we receive the session JWT directly from the web app
|
package/commands/run.go
CHANGED
|
@@ -11,7 +11,7 @@ import (
|
|
|
11
11
|
"github.com/tanagram/cli/checker"
|
|
12
12
|
"github.com/tanagram/cli/config"
|
|
13
13
|
"github.com/tanagram/cli/extractor"
|
|
14
|
-
"github.com/tanagram/cli/git"
|
|
14
|
+
gitpkg "github.com/tanagram/cli/git"
|
|
15
15
|
"github.com/tanagram/cli/metrics"
|
|
16
16
|
"github.com/tanagram/cli/parser"
|
|
17
17
|
"github.com/tanagram/cli/snapshot"
|
|
@@ -186,21 +186,50 @@ func Run() error {
|
|
|
186
186
|
})
|
|
187
187
|
}
|
|
188
188
|
|
|
189
|
-
// Load
|
|
190
|
-
|
|
189
|
+
// Load local policies from cache
|
|
190
|
+
localPolicies, err := cache.GetAllPolicies()
|
|
191
191
|
if err != nil {
|
|
192
192
|
return fmt.Errorf("failed to load policies from cache: %w", err)
|
|
193
193
|
}
|
|
194
194
|
|
|
195
|
+
// Try to load cloud policies for current repo
|
|
196
|
+
cloudPolicies := []parser.Policy{}
|
|
197
|
+
cloudStorage := storage.NewCloudPolicyStorage(gitRoot)
|
|
198
|
+
|
|
199
|
+
repoInfo, err := gitpkg.GetCurrentRepo()
|
|
200
|
+
if err == nil {
|
|
201
|
+
// Successfully detected repo, try to load cloud policies
|
|
202
|
+
cloudPolicies, err = cloudStorage.LoadCloudPoliciesAsParserFormat(repoInfo.Owner, repoInfo.Name)
|
|
203
|
+
if err != nil {
|
|
204
|
+
// Cloud policies exist but failed to load - warn but continue
|
|
205
|
+
fmt.Printf("Warning: Failed to load cloud policies: %v\n", err)
|
|
206
|
+
cloudPolicies = []parser.Policy{}
|
|
207
|
+
} else if len(cloudPolicies) > 0 {
|
|
208
|
+
fmt.Printf("Loaded %d cloud policies for %s/%s\n", len(cloudPolicies), repoInfo.Owner, repoInfo.Name)
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
// If repo detection failed, silently continue with local-only policies
|
|
212
|
+
|
|
213
|
+
// Merge local and cloud policies (cloud takes precedence)
|
|
214
|
+
policies := storage.MergePolicies(localPolicies, cloudPolicies)
|
|
215
|
+
|
|
195
216
|
if len(policies) == 0 {
|
|
196
217
|
fmt.Println("No enforceable policies found")
|
|
197
218
|
return nil
|
|
198
219
|
}
|
|
199
220
|
|
|
200
|
-
|
|
221
|
+
totalLocal := len(localPolicies)
|
|
222
|
+
totalCloud := len(cloudPolicies)
|
|
223
|
+
totalMerged := len(policies)
|
|
224
|
+
|
|
225
|
+
if totalCloud > 0 {
|
|
226
|
+
fmt.Printf("Total policies: %d (%d local, %d cloud, %d after merge)\n", totalMerged, totalLocal, totalCloud, totalMerged)
|
|
227
|
+
} else {
|
|
228
|
+
fmt.Printf("Loaded %d local policies\n", totalLocal)
|
|
229
|
+
}
|
|
201
230
|
|
|
202
231
|
// Check if a snapshot exists (from PreToolUse hook)
|
|
203
|
-
var changesToCheck []
|
|
232
|
+
var changesToCheck []gitpkg.ChangedLine
|
|
204
233
|
useSnapshot := false
|
|
205
234
|
|
|
206
235
|
if snapshot.Exists(gitRoot) {
|
|
@@ -226,7 +255,7 @@ func Run() error {
|
|
|
226
255
|
|
|
227
256
|
// Convert snapshot.ChangedLine to git.ChangedLine
|
|
228
257
|
for _, sc := range snapshotChanges {
|
|
229
|
-
changesToCheck = append(changesToCheck,
|
|
258
|
+
changesToCheck = append(changesToCheck, gitpkg.ChangedLine{
|
|
230
259
|
File: sc.File,
|
|
231
260
|
LineNumber: sc.LineNumber,
|
|
232
261
|
Content: sc.Content,
|
|
@@ -243,7 +272,7 @@ func Run() error {
|
|
|
243
272
|
} else {
|
|
244
273
|
// No snapshot - fall back to checking all git changes
|
|
245
274
|
fmt.Println("Checking all changes (unstaged + staged)...")
|
|
246
|
-
diffResult, err :=
|
|
275
|
+
diffResult, err := gitpkg.GetAllChanges()
|
|
247
276
|
if err != nil {
|
|
248
277
|
return fmt.Errorf("error getting git diff: %w", err)
|
|
249
278
|
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
package commands
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
|
|
6
|
+
"github.com/tanagram/cli/api"
|
|
7
|
+
"github.com/tanagram/cli/storage"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
// SyncPolicies fetches policies from Tanagram API and saves them locally
|
|
11
|
+
func SyncPolicies() error {
|
|
12
|
+
// Find git root
|
|
13
|
+
gitRoot, err := storage.FindGitRoot()
|
|
14
|
+
if err != nil {
|
|
15
|
+
return fmt.Errorf("not in a git repository: %w", err)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
fmt.Println("Syncing policies from Tanagram...")
|
|
19
|
+
|
|
20
|
+
// Create API client
|
|
21
|
+
client, err := api.NewAPIClient()
|
|
22
|
+
if err != nil {
|
|
23
|
+
return err
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Fetch policies from API
|
|
27
|
+
fmt.Println("Fetching policies from API...")
|
|
28
|
+
response, err := client.GetPolicies()
|
|
29
|
+
if err != nil {
|
|
30
|
+
return err
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if len(response.Policies) == 0 {
|
|
34
|
+
fmt.Println("No policies found for your organization")
|
|
35
|
+
return nil
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
fmt.Printf("Found %d policies\n", response.Total)
|
|
39
|
+
|
|
40
|
+
// Group policies by repository
|
|
41
|
+
repoMap := make(map[string][]api.Policy)
|
|
42
|
+
for _, policy := range response.Policies {
|
|
43
|
+
for _, repo := range policy.PolicyRepositories {
|
|
44
|
+
key := repo.Owner + "/" + repo.Name
|
|
45
|
+
repoMap[key] = append(repoMap[key], policy)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
fmt.Printf("Policies apply to %d repositories\n", len(repoMap))
|
|
50
|
+
|
|
51
|
+
// Save policies for each repository
|
|
52
|
+
cloudStorage := storage.NewCloudPolicyStorage(gitRoot)
|
|
53
|
+
savedCount := 0
|
|
54
|
+
|
|
55
|
+
for repoKey, policies := range repoMap {
|
|
56
|
+
// Extract owner and repo name
|
|
57
|
+
owner := policies[0].PolicyRepositories[0].Owner
|
|
58
|
+
repo := policies[0].PolicyRepositories[0].Name
|
|
59
|
+
|
|
60
|
+
err := cloudStorage.SavePoliciesForRepo(owner, repo, policies)
|
|
61
|
+
if err != nil {
|
|
62
|
+
fmt.Printf("Warning: Failed to save policies for %s: %v\n", repoKey, err)
|
|
63
|
+
continue
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
savedCount++
|
|
67
|
+
fmt.Printf(" ✓ Saved %d policies for %s\n", len(policies), repoKey)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Save metadata
|
|
71
|
+
orgID := ""
|
|
72
|
+
orgName := ""
|
|
73
|
+
if len(response.Policies) > 0 {
|
|
74
|
+
orgID = response.Policies[0].OrganizationID
|
|
75
|
+
// Organization name not in API response, use ID for now
|
|
76
|
+
orgName = orgID
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
err = cloudStorage.SaveMetadata(orgID, orgName, response.Total, len(repoMap))
|
|
80
|
+
if err != nil {
|
|
81
|
+
return fmt.Errorf("failed to save metadata: %w", err)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
fmt.Printf("\n✓ Successfully synced %d policies across %d repositories\n", response.Total, savedCount)
|
|
85
|
+
fmt.Printf("Policies saved to: %s\n", cloudStorage.GetPoliciesDir())
|
|
86
|
+
|
|
87
|
+
return nil
|
|
88
|
+
}
|
package/git/repo.go
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
package git
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"os/exec"
|
|
6
|
+
"regexp"
|
|
7
|
+
"strings"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
// RepoInfo contains repository identification
|
|
11
|
+
type RepoInfo struct {
|
|
12
|
+
Owner string
|
|
13
|
+
Name string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// GetCurrentRepo detects the current repository from git remote
|
|
17
|
+
func GetCurrentRepo() (*RepoInfo, error) {
|
|
18
|
+
// Get git remote URL
|
|
19
|
+
cmd := exec.Command("git", "remote", "get-url", "origin")
|
|
20
|
+
output, err := cmd.Output()
|
|
21
|
+
if err != nil {
|
|
22
|
+
return nil, fmt.Errorf("failed to get git remote: %w (not in a git repository?)", err)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
remoteURL := strings.TrimSpace(string(output))
|
|
26
|
+
|
|
27
|
+
// Parse owner and repo name from various URL formats
|
|
28
|
+
// Examples:
|
|
29
|
+
// - git@github.com:owner/repo.git
|
|
30
|
+
// - https://github.com/owner/repo.git
|
|
31
|
+
// - https://github.com/owner/repo
|
|
32
|
+
// - https://username@github.com/owner/repo.git
|
|
33
|
+
// - https://username:password@github.com/owner/repo.git
|
|
34
|
+
|
|
35
|
+
// Try SSH format first
|
|
36
|
+
sshRegex := regexp.MustCompile(`git@[\w.-]+:([\w-]+)/([\w.-]+?)(\.git)?$`)
|
|
37
|
+
if matches := sshRegex.FindStringSubmatch(remoteURL); matches != nil {
|
|
38
|
+
return &RepoInfo{
|
|
39
|
+
Owner: matches[1],
|
|
40
|
+
Name: strings.TrimSuffix(matches[2], ".git"),
|
|
41
|
+
}, nil
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Try HTTPS format (with optional username:password@ prefix)
|
|
45
|
+
httpsRegex := regexp.MustCompile(`https?://(?:[\w.-]+@)?[\w.-]+/([\w-]+)/([\w.-]+?)(\.git)?$`)
|
|
46
|
+
if matches := httpsRegex.FindStringSubmatch(remoteURL); matches != nil {
|
|
47
|
+
return &RepoInfo{
|
|
48
|
+
Owner: matches[1],
|
|
49
|
+
Name: strings.TrimSuffix(matches[2], ".git"),
|
|
50
|
+
}, nil
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return nil, fmt.Errorf("could not parse git remote URL: %s", remoteURL)
|
|
54
|
+
}
|
package/go.mod
CHANGED
|
@@ -11,13 +11,16 @@ require (
|
|
|
11
11
|
)
|
|
12
12
|
|
|
13
13
|
require (
|
|
14
|
+
al.essio.dev/pkg/shellescape v1.5.1 // indirect
|
|
14
15
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
|
15
16
|
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
|
16
17
|
github.com/charmbracelet/x/ansi v0.10.1 // indirect
|
|
17
18
|
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
|
|
18
19
|
github.com/charmbracelet/x/term v0.2.1 // indirect
|
|
20
|
+
github.com/danieljoos/wincred v1.2.2 // indirect
|
|
19
21
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
|
20
22
|
github.com/go-ole/go-ole v1.2.6 // indirect
|
|
23
|
+
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
|
21
24
|
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
|
22
25
|
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
|
23
26
|
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
|
@@ -27,6 +30,7 @@ require (
|
|
|
27
30
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
|
28
31
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
|
29
32
|
github.com/muesli/termenv v0.16.0 // indirect
|
|
33
|
+
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
|
30
34
|
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
|
|
31
35
|
github.com/rivo/uniseg v0.4.7 // indirect
|
|
32
36
|
github.com/shoenig/go-m1cpu v0.1.6 // indirect
|
|
@@ -38,6 +42,7 @@ require (
|
|
|
38
42
|
github.com/tklauser/numcpus v0.6.1 // indirect
|
|
39
43
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
|
40
44
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
|
45
|
+
github.com/zalando/go-keyring v0.2.6 // indirect
|
|
41
46
|
golang.org/x/sys v0.36.0 // indirect
|
|
42
47
|
golang.org/x/text v0.27.0 // indirect
|
|
43
48
|
)
|
package/go.sum
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
al.essio.dev/pkg/shellescape v1.5.1 h1:86HrALUujYS/h+GtqoB26SBEdkWfmMI6FubjXlsXyho=
|
|
2
|
+
al.essio.dev/pkg/shellescape v1.5.1/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890=
|
|
1
3
|
github.com/anthropics/anthropic-sdk-go v1.17.0 h1:BwK8ApcmaAUkvZTiQE0yi3R9XneEFskDIjLTmOAFZxQ=
|
|
2
4
|
github.com/anthropics/anthropic-sdk-go v1.17.0/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE=
|
|
3
5
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
|
@@ -14,12 +16,16 @@ github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0G
|
|
|
14
16
|
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
|
15
17
|
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
|
16
18
|
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
|
19
|
+
github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0=
|
|
20
|
+
github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8=
|
|
17
21
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
|
18
22
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
|
19
23
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
|
20
24
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
|
21
25
|
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
|
|
22
26
|
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
|
27
|
+
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
|
28
|
+
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
|
23
29
|
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
|
24
30
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
|
25
31
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
|
@@ -41,6 +47,8 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
|
|
|
41
47
|
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
|
42
48
|
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
|
43
49
|
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
|
50
|
+
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
|
51
|
+
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
|
44
52
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
|
45
53
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
|
46
54
|
github.com/posthog/posthog-go v1.6.12 h1:rsOBL/YdMfLJtOVjKJLgdzYmvaL3aIW6IVbAteSe+aI=
|
|
@@ -76,11 +84,14 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM
|
|
|
76
84
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
|
77
85
|
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
|
78
86
|
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
|
87
|
+
github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s=
|
|
88
|
+
github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI=
|
|
79
89
|
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
|
|
80
90
|
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
|
|
81
91
|
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
82
92
|
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
83
93
|
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
94
|
+
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
84
95
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
85
96
|
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
86
97
|
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
package/main.go
CHANGED
|
@@ -125,6 +125,16 @@ func main() {
|
|
|
125
125
|
fmt.Println("\nNo selection made")
|
|
126
126
|
}
|
|
127
127
|
return
|
|
128
|
+
case "login":
|
|
129
|
+
metrics.Track("cli.command.execute", map[string]interface{}{
|
|
130
|
+
"command": "login",
|
|
131
|
+
})
|
|
132
|
+
err = commands.Login()
|
|
133
|
+
case "sync-policies":
|
|
134
|
+
metrics.Track("cli.command.execute", map[string]interface{}{
|
|
135
|
+
"command": "sync-policies",
|
|
136
|
+
})
|
|
137
|
+
err = commands.SyncPolicies()
|
|
128
138
|
case "puzzle":
|
|
129
139
|
metrics.Track("cli.command.execute", map[string]interface{}{
|
|
130
140
|
"command": "puzzle",
|
|
@@ -168,6 +178,11 @@ USAGE:
|
|
|
168
178
|
|
|
169
179
|
COMMANDS:
|
|
170
180
|
run Check git changes against policies (default)
|
|
181
|
+
login Authenticate with Tanagram using Stytch B2B
|
|
182
|
+
<<<<<<< HEAD
|
|
183
|
+
sync-policies Sync cloud policies from Tanagram
|
|
184
|
+
=======
|
|
185
|
+
>>>>>>> origin/main
|
|
171
186
|
snapshot Create a snapshot of current file state (used by PreToolUse hook)
|
|
172
187
|
sync Manually sync instruction files to cache
|
|
173
188
|
list Show all cached policies
|
|
@@ -179,8 +194,13 @@ COMMANDS:
|
|
|
179
194
|
EXAMPLES:
|
|
180
195
|
tanagram # Check changes (auto-syncs if files changed)
|
|
181
196
|
tanagram run # Same as above
|
|
197
|
+
tanagram login # Authenticate with Tanagram
|
|
198
|
+
<<<<<<< HEAD
|
|
199
|
+
tanagram sync-policies # Sync cloud policies from Tanagram
|
|
200
|
+
=======
|
|
201
|
+
>>>>>>> origin/main
|
|
182
202
|
tanagram snapshot # Create snapshot before making changes
|
|
183
|
-
tanagram sync # Manually sync
|
|
203
|
+
tanagram sync # Manually sync local instruction files
|
|
184
204
|
tanagram list # View all cached policies
|
|
185
205
|
tanagram config claude # Setup Claude Code hooks in ~/.claude/settings.json
|
|
186
206
|
tanagram config list # Show where hooks are installed
|
package/package.json
CHANGED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
package storage
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/json"
|
|
5
|
+
"fmt"
|
|
6
|
+
"os"
|
|
7
|
+
"path/filepath"
|
|
8
|
+
"time"
|
|
9
|
+
|
|
10
|
+
"github.com/tanagram/cli/api"
|
|
11
|
+
"github.com/tanagram/cli/parser"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
// RepoPolicyFile represents policies for a specific repository
|
|
15
|
+
type RepoPolicyFile struct {
|
|
16
|
+
Repository struct {
|
|
17
|
+
ID string `json:"id"`
|
|
18
|
+
Name string `json:"name"`
|
|
19
|
+
Owner string `json:"owner"`
|
|
20
|
+
IsPrivate bool `json:"is_private"`
|
|
21
|
+
} `json:"repository"`
|
|
22
|
+
Policies []SimplifiedPolicy `json:"policies"`
|
|
23
|
+
LastSync time.Time `json:"last_sync"`
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// SimplifiedPolicy is a policy optimized for CLI storage
|
|
27
|
+
type SimplifiedPolicy struct {
|
|
28
|
+
ID string `json:"id"`
|
|
29
|
+
Name string `json:"name"`
|
|
30
|
+
Substrate string `json:"substrate"`
|
|
31
|
+
Description string `json:"description"`
|
|
32
|
+
OriginalDescription string `json:"original_description"`
|
|
33
|
+
Enabled bool `json:"enabled"`
|
|
34
|
+
Message string `json:"message"`
|
|
35
|
+
SyncedAt time.Time `json:"synced_at"`
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// PolicyMetadata tracks overall sync state
|
|
39
|
+
type PolicyMetadata struct {
|
|
40
|
+
OrganizationID string `json:"organization_id"`
|
|
41
|
+
OrganizationName string `json:"organization_name"`
|
|
42
|
+
LastSync time.Time `json:"last_sync"`
|
|
43
|
+
TotalPolicies int `json:"total_policies"`
|
|
44
|
+
TotalRepos int `json:"total_repos"`
|
|
45
|
+
SyncMode string `json:"sync_mode"` // "auto" or "manual"
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// CloudPolicyStorage handles cloud policy storage
|
|
49
|
+
type CloudPolicyStorage struct {
|
|
50
|
+
gitRoot string // Not used for path, kept for compatibility
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// NewCloudPolicyStorage creates a new cloud policy storage instance
|
|
54
|
+
func NewCloudPolicyStorage(gitRoot string) *CloudPolicyStorage {
|
|
55
|
+
return &CloudPolicyStorage{gitRoot: gitRoot}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// GetPoliciesDir returns the cloud policies directory path (global, in user's home directory)
|
|
59
|
+
func (s *CloudPolicyStorage) GetPoliciesDir() string {
|
|
60
|
+
homeDir, err := os.UserHomeDir()
|
|
61
|
+
if err != nil {
|
|
62
|
+
// Fallback to current directory if home dir can't be determined
|
|
63
|
+
return filepath.Join(".tanagram", "policies")
|
|
64
|
+
}
|
|
65
|
+
return filepath.Join(homeDir, ".tanagram", "policies")
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// SavePoliciesForRepo saves policies for a specific repository
|
|
69
|
+
func (s *CloudPolicyStorage) SavePoliciesForRepo(owner, repo string, policies []api.Policy) error {
|
|
70
|
+
// Create directory structure
|
|
71
|
+
repoDir := filepath.Join(s.GetPoliciesDir(), owner)
|
|
72
|
+
if err := os.MkdirAll(repoDir, 0755); err != nil {
|
|
73
|
+
return fmt.Errorf("failed to create directory: %w", err)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Filter policies for this repo and convert to simplified format
|
|
77
|
+
var repoPolicies []SimplifiedPolicy
|
|
78
|
+
var repoInfo api.Repository
|
|
79
|
+
|
|
80
|
+
for _, policy := range policies {
|
|
81
|
+
// Check if this policy applies to this repo
|
|
82
|
+
for _, policyRepo := range policy.PolicyRepositories {
|
|
83
|
+
if policyRepo.Owner == owner && policyRepo.Name == repo {
|
|
84
|
+
repoInfo = policyRepo
|
|
85
|
+
repoPolicies = append(repoPolicies, SimplifiedPolicy{
|
|
86
|
+
ID: policy.ID,
|
|
87
|
+
Name: policy.Name,
|
|
88
|
+
Substrate: policy.Substrate,
|
|
89
|
+
Description: policy.DescriptionRewrittenByLLM,
|
|
90
|
+
OriginalDescription: policy.DescriptionFromUser,
|
|
91
|
+
Enabled: policy.EnabledStatus == "enabled",
|
|
92
|
+
Message: policy.CustomMessage,
|
|
93
|
+
SyncedAt: time.Now(),
|
|
94
|
+
})
|
|
95
|
+
break
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Create repo policy file
|
|
101
|
+
repoFile := RepoPolicyFile{
|
|
102
|
+
Policies: repoPolicies,
|
|
103
|
+
LastSync: time.Now(),
|
|
104
|
+
}
|
|
105
|
+
repoFile.Repository.ID = repoInfo.ID
|
|
106
|
+
repoFile.Repository.Name = repo
|
|
107
|
+
repoFile.Repository.Owner = owner
|
|
108
|
+
repoFile.Repository.IsPrivate = repoInfo.IsPrivate
|
|
109
|
+
|
|
110
|
+
// Save to file
|
|
111
|
+
filePath := filepath.Join(repoDir, repo+".json")
|
|
112
|
+
data, err := json.MarshalIndent(repoFile, "", " ")
|
|
113
|
+
if err != nil {
|
|
114
|
+
return fmt.Errorf("failed to marshal policies: %w", err)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if err := os.WriteFile(filePath, data, 0644); err != nil {
|
|
118
|
+
return fmt.Errorf("failed to write policy file: %w", err)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return nil
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// SaveMetadata saves sync metadata
|
|
125
|
+
func (s *CloudPolicyStorage) SaveMetadata(orgID, orgName string, totalPolicies, totalRepos int) error {
|
|
126
|
+
metadata := PolicyMetadata{
|
|
127
|
+
OrganizationID: orgID,
|
|
128
|
+
OrganizationName: orgName,
|
|
129
|
+
LastSync: time.Now(),
|
|
130
|
+
TotalPolicies: totalPolicies,
|
|
131
|
+
TotalRepos: totalRepos,
|
|
132
|
+
SyncMode: "manual",
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
metadataPath := filepath.Join(s.GetPoliciesDir(), "metadata.json")
|
|
136
|
+
data, err := json.MarshalIndent(metadata, "", " ")
|
|
137
|
+
if err != nil {
|
|
138
|
+
return fmt.Errorf("failed to marshal metadata: %w", err)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Ensure directory exists
|
|
142
|
+
if err := os.MkdirAll(s.GetPoliciesDir(), 0755); err != nil {
|
|
143
|
+
return fmt.Errorf("failed to create policies directory: %w", err)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if err := os.WriteFile(metadataPath, data, 0644); err != nil {
|
|
147
|
+
return fmt.Errorf("failed to write metadata: %w", err)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return nil
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// LoadPoliciesForRepo loads policies for a specific repository
|
|
154
|
+
func (s *CloudPolicyStorage) LoadPoliciesForRepo(owner, repo string) (*RepoPolicyFile, error) {
|
|
155
|
+
filePath := filepath.Join(s.GetPoliciesDir(), owner, repo+".json")
|
|
156
|
+
|
|
157
|
+
data, err := os.ReadFile(filePath)
|
|
158
|
+
if err != nil {
|
|
159
|
+
if os.IsNotExist(err) {
|
|
160
|
+
return nil, nil // No cloud policies for this repo
|
|
161
|
+
}
|
|
162
|
+
return nil, fmt.Errorf("failed to read policy file: %w", err)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
var repoFile RepoPolicyFile
|
|
166
|
+
if err := json.Unmarshal(data, &repoFile); err != nil {
|
|
167
|
+
return nil, fmt.Errorf("failed to parse policy file: %w", err)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return &repoFile, nil
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// LoadMetadata loads sync metadata
|
|
174
|
+
func (s *CloudPolicyStorage) LoadMetadata() (*PolicyMetadata, error) {
|
|
175
|
+
metadataPath := filepath.Join(s.GetPoliciesDir(), "metadata.json")
|
|
176
|
+
|
|
177
|
+
data, err := os.ReadFile(metadataPath)
|
|
178
|
+
if err != nil {
|
|
179
|
+
if os.IsNotExist(err) {
|
|
180
|
+
return nil, nil
|
|
181
|
+
}
|
|
182
|
+
return nil, fmt.Errorf("failed to read metadata: %w", err)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
var metadata PolicyMetadata
|
|
186
|
+
if err := json.Unmarshal(data, &metadata); err != nil {
|
|
187
|
+
return nil, fmt.Errorf("failed to parse metadata: %w", err)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return &metadata, nil
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// GetCacheAge returns how old the cache is
|
|
194
|
+
func (s *CloudPolicyStorage) GetCacheAge() (time.Duration, error) {
|
|
195
|
+
metadata, err := s.LoadMetadata()
|
|
196
|
+
if err != nil || metadata == nil {
|
|
197
|
+
return 0, fmt.Errorf("no cache found")
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return time.Since(metadata.LastSync), nil
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// LoadCloudPoliciesAsParserFormat loads cloud policies for a repo in parser.Policy format
|
|
204
|
+
func (s *CloudPolicyStorage) LoadCloudPoliciesAsParserFormat(owner, repo string) ([]parser.Policy, error) {
|
|
205
|
+
repoFile, err := s.LoadPoliciesForRepo(owner, repo)
|
|
206
|
+
if err != nil {
|
|
207
|
+
return nil, err
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if repoFile == nil {
|
|
211
|
+
// No cloud policies for this repo
|
|
212
|
+
return []parser.Policy{}, nil
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Convert SimplifiedPolicy to parser.Policy
|
|
216
|
+
var policies []parser.Policy
|
|
217
|
+
for _, cloudPolicy := range repoFile.Policies {
|
|
218
|
+
if !cloudPolicy.Enabled {
|
|
219
|
+
continue // Skip disabled policies
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Use the rewritten description as OriginalText (it's more detailed)
|
|
223
|
+
originalText := cloudPolicy.Description
|
|
224
|
+
if originalText == "" {
|
|
225
|
+
originalText = cloudPolicy.OriginalDescription
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
policies = append(policies, parser.Policy{
|
|
229
|
+
Name: cloudPolicy.Name,
|
|
230
|
+
Message: cloudPolicy.Message,
|
|
231
|
+
OriginalText: originalText,
|
|
232
|
+
})
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return policies, nil
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// MergePolicies merges local and cloud policies, with cloud taking precedence
|
|
239
|
+
func MergePolicies(local, cloud []parser.Policy) []parser.Policy {
|
|
240
|
+
// Create map of cloud policy names for quick lookup
|
|
241
|
+
cloudNames := make(map[string]bool)
|
|
242
|
+
for _, p := range cloud {
|
|
243
|
+
cloudNames[p.Name] = true
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Start with all cloud policies
|
|
247
|
+
merged := make([]parser.Policy, len(cloud))
|
|
248
|
+
copy(merged, cloud)
|
|
249
|
+
|
|
250
|
+
// Add local policies that don't conflict with cloud
|
|
251
|
+
for _, localPolicy := range local {
|
|
252
|
+
if !cloudNames[localPolicy.Name] {
|
|
253
|
+
merged = append(merged, localPolicy)
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return merged
|
|
258
|
+
}
|