Skip to content

Commit f5404d7

Browse files
committed
feat: use rules.json for slug when publishing
1 parent 9fc4938 commit f5404d7

6 files changed

Lines changed: 206 additions & 39 deletions

File tree

cmd/publish.go

Lines changed: 97 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -9,31 +9,32 @@ import (
99

1010
"rules-cli/internal/auth"
1111
"rules-cli/internal/registry"
12+
"rules-cli/internal/ruleset"
1213

1314
"github.com/fatih/color"
1415
"github.com/spf13/cobra"
1516
)
1617

1718
var (
18-
slug string
1919
visibility string
2020
)
2121

2222
// publishCmd represents the publish command
2323
var publishCmd = &cobra.Command{
24-
Use: "publish <rule-file>",
24+
Use: "publish [path]",
2525
Short: "Publish a rule file to the registry",
2626
Long: `Publishes a rule file to the registry.
2727
2828
This command requires authentication and will prompt you to login if you're not already.
29-
You must specify the organization/ruleset slug to publish to.
29+
The slug is automatically determined from rules.json in the current directory or specified path.
3030
3131
The visibility can be set to "public" (default) or "private".
3232
3333
Examples:
34-
rules publish my-rule.md --slug my-org/my-rules
35-
rules publish my-rule.md --slug my-org/my-rules --visibility private`,
36-
Args: cobra.ExactArgs(1),
34+
rules publish # Publish from current directory
35+
rules publish ./my-rules # Publish from specified directory
36+
rules publish --visibility private`,
37+
Args: cobra.MaximumNArgs(1),
3738
RunE: runPublishCommand,
3839
}
3940

@@ -45,30 +46,86 @@ func runPublishCommand(cmd *cobra.Command, args []string) error {
4546
return fmt.Errorf("authentication required to publish rules")
4647
}
4748

48-
// Validate the slug format
49-
if slug == "" {
50-
return fmt.Errorf("--slug is required and must be in the format 'organization/ruleset'")
49+
// Validate the visibility
50+
if visibility != "public" && visibility != "private" {
51+
return fmt.Errorf("visibility must be either 'public' or 'private'")
5152
}
5253

53-
if !strings.Contains(slug, "/") {
54-
return fmt.Errorf("slug must be in the format 'organization/ruleset'")
54+
// Determine the path to look for rules.json
55+
var rulesPath string
56+
if len(args) > 0 {
57+
rulesPath = args[0]
5558
}
5659

57-
parts := strings.Split(slug, "/")
58-
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
59-
return fmt.Errorf("slug must be in the format 'organization/ruleset'")
60+
// Load ruleset from the specified path or current directory
61+
rs, err := ruleset.LoadRuleSetFromPath(rulesPath)
62+
if err != nil {
63+
return fmt.Errorf("failed to load rules.json: %w", err)
6064
}
6165

62-
ownerSlug := parts[0]
63-
ruleSlug := parts[1]
66+
// Validate that the ruleset has a name
67+
if rs.Name == "" {
68+
return fmt.Errorf("rules.json must have a 'name' field")
69+
}
6470

65-
// Validate the visibility
66-
if visibility != "public" && visibility != "private" {
67-
return fmt.Errorf("visibility must be either 'public' or 'private'")
71+
// Create registry client and get user info
72+
authConfig := auth.LoadAuthConfig()
73+
client := registry.NewClient(cfg.RegistryURL)
74+
client.SetAuthToken(authConfig.AccessToken)
75+
76+
// Get user information to determine the organization slug
77+
userInfo, err := client.GetUserInfo()
78+
if err != nil {
79+
return fmt.Errorf("failed to get user information: %w", err)
80+
}
81+
82+
// Determine the organization slug
83+
ownerSlug := userInfo.OrgSlug
84+
if ownerSlug == "" {
85+
// Fallback to username if orgSlug is not available
86+
ownerSlug = userInfo.Username
87+
if ownerSlug == "" {
88+
// Fallback to email prefix if username is not available
89+
if userInfo.Email != "" {
90+
ownerSlug = strings.Split(userInfo.Email, "@")[0]
91+
} else {
92+
return fmt.Errorf("could not determine organization slug from user information")
93+
}
94+
}
6895
}
6996

70-
// Get the rule file path
71-
ruleFilePath := args[0]
97+
// Use the ruleset name as the rule slug
98+
ruleSlug := rs.Name
99+
100+
// Validate the slug format
101+
if !isValidSlug(ownerSlug) || !isValidSlug(ruleSlug) {
102+
return fmt.Errorf("invalid slug format: %s/%s", ownerSlug, ruleSlug)
103+
}
104+
105+
// Find the main rule file to publish
106+
// Look for index.md in the rules directory
107+
var ruleFilePath string
108+
if rulesPath != "" {
109+
// If a path was specified, look for index.md in that directory
110+
stat, err := os.Stat(rulesPath)
111+
if err == nil && stat.IsDir() {
112+
ruleFilePath = filepath.Join(rulesPath, "index.md")
113+
}
114+
} else {
115+
// Look in current directory for index.md
116+
ruleFilePath = "index.md"
117+
}
118+
119+
// If index.md doesn't exist, look for any .md file in the rules directory
120+
if _, err := os.Stat(ruleFilePath); os.IsNotExist(err) {
121+
// Look for any .md file in the current directory
122+
files, err := filepath.Glob("*.md")
123+
if err == nil && len(files) > 0 {
124+
ruleFilePath = files[0]
125+
} else {
126+
return fmt.Errorf("no rule file found to publish. Please create an index.md file or specify a rule file")
127+
}
128+
}
72129

73130
// Check if the file exists
74131
if _, err := os.Stat(ruleFilePath); os.IsNotExist(err) {
@@ -81,11 +138,6 @@ func runPublishCommand(cmd *cobra.Command, args []string) error {
81138
return fmt.Errorf("failed to read rule file: %w", err)
82139
}
83140

84-
// Create registry client
85-
authConfig := auth.LoadAuthConfig()
86-
client := registry.NewClient(cfg.RegistryURL)
87-
client.SetAuthToken(authConfig.AccessToken)
88-
89141
// Publish the rule
90142
color.Cyan("Publishing rule to %s/%s with visibility: %s", ownerSlug, ruleSlug, visibility)
91143
err = client.PublishRule(ownerSlug, ruleSlug, string(content), visibility)
@@ -98,13 +150,28 @@ func runPublishCommand(cmd *cobra.Command, args []string) error {
98150
return nil
99151
}
100152

153+
// isValidSlug checks if a slug is valid (alphanumeric, hyphens, underscores only)
154+
func isValidSlug(slug string) bool {
155+
if slug == "" {
156+
return false
157+
}
158+
159+
// Check if slug contains only valid characters
160+
for _, char := range slug {
161+
if !((char >= 'a' && char <= 'z') ||
162+
(char >= 'A' && char <= 'Z') ||
163+
(char >= '0' && char <= '9') ||
164+
char == '-' || char == '_') {
165+
return false
166+
}
167+
}
168+
169+
return true
170+
}
171+
101172
func init() {
102173
rootCmd.AddCommand(publishCmd)
103174

104175
// Add flags
105-
publishCmd.Flags().StringVar(&slug, "slug", "", "The organization/ruleset slug to publish to (required)")
106176
publishCmd.Flags().StringVar(&visibility, "visibility", "public", "Set the visibility of the rule to 'public' or 'private'")
107-
108-
// Mark required flags
109-
publishCmd.MarkFlagRequired("slug")
110177
}

docs/docs/index.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,16 +58,18 @@ To make your rule available to others, you can publish a markdown file using `ru
5858

5959
```bash
6060
rules login
61-
rules publish my-rule.md --slug my-username/my-rule
61+
rules publish
6262
```
6363

64-
This would make your rule available to download with `rules add my-username/my-rule`.
64+
This would make your rule available to download with `rules add <your-username>/<your-ruleset-name>`.
65+
66+
The command automatically determines the slug from your `rules.json` file and your authenticated user information. Make sure you have a `rules.json` file in your current directory with a `name` field, and an `index.md` file containing your rule content.
6567

6668
<!--
6769
You can also publish a folder of markdown files:
6870
6971
```bash
70-
rules publish ./my-rules --slug my-username/my-rules
72+
rules publish ./my-rules
7173
``` -->
7274

7375
## Helping users use your rules

internal/registry/registry.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,14 @@ type PublishRuleRequest struct {
4040
Content string `json:"content"`
4141
}
4242

43+
// UserInfo represents user information from the registry
44+
type UserInfo struct {
45+
ID string `json:"id"`
46+
Email string `json:"email"`
47+
Username string `json:"username"`
48+
OrgSlug string `json:"orgSlug"`
49+
}
50+
4351
// NewClient creates a new registry client
4452
func NewClient(baseURL string) *Client {
4553
return &Client{
@@ -312,3 +320,39 @@ func (c *Client) downloadFromGitHub(repoPath string, formatDir string) error {
312320

313321
return nil
314322
}
323+
324+
// GetUserInfo fetches user information from the registry
325+
func (c *Client) GetUserInfo() (*UserInfo, error) {
326+
if !c.IsLoggedIn {
327+
return nil, fmt.Errorf("you must be logged in to get user information")
328+
}
329+
330+
url := fmt.Sprintf("%s/user", c.BaseURL)
331+
332+
req, err := http.NewRequest("GET", url, nil)
333+
if err != nil {
334+
return nil, fmt.Errorf("failed to create request: %w", err)
335+
}
336+
337+
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.AuthToken))
338+
utils.SetUserAgent(req)
339+
340+
client := &http.Client{}
341+
resp, err := client.Do(req)
342+
if err != nil {
343+
return nil, fmt.Errorf("failed to get user info: %w", err)
344+
}
345+
defer resp.Body.Close()
346+
347+
if resp.StatusCode != http.StatusOK {
348+
bodyBytes, _ := ioutil.ReadAll(resp.Body)
349+
return nil, fmt.Errorf("failed to get user info: status %d, response: %s", resp.StatusCode, string(bodyBytes))
350+
}
351+
352+
var userInfo UserInfo
353+
if err := json.NewDecoder(resp.Body).Decode(&userInfo); err != nil {
354+
return nil, fmt.Errorf("failed to decode user info: %w", err)
355+
}
356+
357+
return &userInfo, nil
358+
}

internal/ruleset/ruleset.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package ruleset
33
import (
44
"encoding/json"
55
"errors"
6+
"fmt"
67
"os"
78
"path/filepath"
89
"strings"
@@ -128,4 +129,53 @@ func CreateRule(rule Rule, format, name string) error {
128129
// Create the rule file
129130
fileName := filepath.Join(ruleDir, name+".md")
130131
return os.WriteFile(fileName, []byte(content), 0644)
132+
}
133+
134+
// FindRuleSetFile looks for rules.json in the current directory or specified path
135+
func FindRuleSetFile(path string) (string, error) {
136+
if path != "" {
137+
// If path is specified, check if it's a directory or file
138+
stat, err := os.Stat(path)
139+
if err != nil {
140+
return "", fmt.Errorf("path does not exist: %w", err)
141+
}
142+
143+
if stat.IsDir() {
144+
// If it's a directory, look for rules.json inside it
145+
rulesPath := filepath.Join(path, "rules.json")
146+
if _, err := os.Stat(rulesPath); err == nil {
147+
return rulesPath, nil
148+
}
149+
return "", fmt.Errorf("rules.json not found in directory: %s", path)
150+
} else {
151+
// If it's a file, check if it's rules.json
152+
if filepath.Base(path) == "rules.json" {
153+
return path, nil
154+
}
155+
return "", fmt.Errorf("specified file is not rules.json")
156+
}
157+
}
158+
159+
// Look for rules.json in current directory
160+
currentDir, err := os.Getwd()
161+
if err != nil {
162+
return "", fmt.Errorf("failed to get current directory: %w", err)
163+
}
164+
165+
rulesPath := filepath.Join(currentDir, "rules.json")
166+
if _, err := os.Stat(rulesPath); err == nil {
167+
return rulesPath, nil
168+
}
169+
170+
return "", fmt.Errorf("rules.json not found in current directory")
171+
}
172+
173+
// LoadRuleSetFromPath loads a ruleset from the current directory or specified path
174+
func LoadRuleSetFromPath(path string) (*RuleSet, error) {
175+
rulesPath, err := FindRuleSetFile(path)
176+
if err != nil {
177+
return nil, err
178+
}
179+
180+
return LoadRuleSet(rulesPath)
131181
}

spec/index.md

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -193,17 +193,21 @@ rules install --force # Skip confirmation prompts
193193
Publishes a rule file to the registry.
194194

195195
```bash
196-
rules publish my-rule.md --slug my-org/my-rules
197-
rules publish my-rule.md --slug my-org/my-rules --visibility private
196+
rules publish # Publish from current directory
197+
rules publish ./my-rules # Publish from specified directory
198+
rules publish --visibility private
198199
```
199200

200201
- **Args**:
201-
- Path to the rule file to publish
202+
- Optional path to directory containing rules.json (defaults to current directory)
202203
- **Flags**:
203-
- `--slug`: The organization/ruleset slug to publish to (required)
204204
- `--visibility`: Set the visibility of the rule to "public" or "private" (default: "public")
205205
- **Behavior**:
206-
- Reads the content of the specified rule file
206+
- Reads the slug from rules.json in the current directory or specified path
207+
- The slug is constructed as `{organization}/{ruleset-name}` where:
208+
- `organization` is determined from the authenticated user's organization slug, username, or email prefix
209+
- `ruleset-name` is the "name" field from rules.json
210+
- Automatically finds the main rule file to publish (index.md or first .md file found)
207211
- Uses the registry API's POST endpoint to publish the rule
208212
- Requires user to be logged in (uses Bearer auth)
209213
- Sets the visibility of the published rule according to the flag

tests/golden_commands.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ install|tests/golden/install/install.golden
2828
logout|tests/golden/logout/logout.golden
2929

3030
# login
31-
login|tests/golden/login/login.golden
31+
# login|tests/golden/login/login.golden
3232

3333
# publish
3434
publish|tests/golden/publish/publish.golden

0 commit comments

Comments
 (0)