| name | Railway CLI Development |
|---|---|
| description | Guidelines for developing the Railway CLI (railctl-go) - a Go-based CLI tool for Railway.app infrastructure management |
This skill provides guidelines for developing features in the railctl-go project, a Go-based CLI tool for managing Railway.app infrastructure.
railway-cli/
├── cmd/railctl/ # Go CLI main entry point
├── internal/ # Go implementation (primary focus)
│ ├── api/ # Railway GraphQL API client
│ ├── cmd/ # Cobra command implementations
│ ├── output/ # Output formatting (table, JSON, YAML)
│ ├── resolver/ # Name/ID resolution logic
│ └── types/ # Data structures
├── experiments/ # Experimental code and prototypes
│ ├── tests/ # Python E2E integration tests
│ │ └── e2e/ # End-to-end test suite
│ └── tools/ # Experimental tools
│ ├── compose-importer/ # Docker Compose to Railway importer
│ └── railctl/ # Python prototype (reference)
├── railway-cli-rust/ # Rust CLI (git submodule - reference implementation)
│ ├── src/
│ │ ├── commands/ # Command implementations
│ │ └── gql/ # GraphQL queries/mutations
│ │ ├── queries/strings/ # GraphQL query definitions
│ │ ├── mutations/strings/ # GraphQL mutation definitions
│ │ └── schema.json # Railway GraphQL schema
│ └── Cargo.toml
├── go.mod # Go module definition
├── go.sum # Go dependencies
├── SKILL.md # This file - development guidelines
├── JOURNAL.md # Development journal and history
├── IMPLEMENTATION_TASKS.md # Task tracking and progress
├── BUGS.md # Known bugs and resolutions
└── README.md # Project documentation
Always start by understanding the existing implementation:
-
Check the Rust CLI first (
railway-cli-rust/submodule):- Look for GraphQL queries in
railway-cli-rust/src/gql/queries/strings/*.graphql - Check mutations in
railway-cli-rust/src/gql/mutations/strings/*.graphql - Review command implementations in
railway-cli-rust/src/commands/*.rs - The Rust CLI is the reference implementation - it's battle-tested
- Look for GraphQL queries in
-
Study the GraphQL schema:
- Located at
railway-cli-rust/src/gql/schema.json - Understand field types, especially scalars like
DeploymentMeta(JSON) - Check for nested structures and relationships
- Located at
-
Review existing Go code:
- Check
internal/api/for similar API patterns - Look at
internal/cmd/for command structure examples - Review
internal/types/for data structures
- Check
GraphQL Queries/Mutations:
// Always define as constants with clear documentation
const queryName = `
query($param: Type!) {
field(param: $param) {
id
name
# Include all fields needed
}
}
`Response Types:
// Mirror the GraphQL response structure exactly
type responseType struct {
Field struct {
ID string `json:"id"`
Name string `json:"name"`
} `json:"field"`
}Client Methods:
// Follow this pattern for all API methods
func (c *Client) MethodName(params) (types.Result, error) {
data, err := c.execute(queryName, map[string]any{
"param": value,
})
if err != nil {
return nil, err
}
var resp responseType
if err := json.Unmarshal(data, &resp); err != nil {
return nil, err
}
// Transform to types.Result
return transformFunction(resp), nil
}Command Structure:
var commandCmd = &cobra.Command{
Use: "command <arg> [flags]",
Aliases: []string{"alias"}, // Always provide short aliases
Short: "Brief description",
Long: `Detailed description with examples.
For features requiring env vars, document them here.`,
Args: cobra.ExactArgs(1),
Example: ` railctl command example -p project
railctl command example --flag value`,
RunE: runCommand,
}Flag Patterns:
func init() {
// Document env var fallbacks in flag descriptions
cmd.Flags().StringVar(&flag, "flag", "", "Description (env: ENV_VAR_NAME)")
// Only mark truly required flags
cmd.MarkFlagRequired("required-flag")
}Command Implementation:
func runCommand(cmd *cobra.Command, args []string) error {
// 1. Get token
token, err := getToken()
if err != nil {
return err
}
// 2. Validate required flags/env vars
if requiredFlag == "" {
return fmt.Errorf("flag is required")
}
// 3. Create API client
client := newAPIClient(token)
// 4. Resolve names to IDs using resolver package
project, err := resolver.ResolveProject(projects, projectFlag)
if err != nil {
return err
}
// 5. Execute API calls
result, err := client.Method(params)
if err != nil {
return fmt.Errorf("failed to ...: %w", err)
}
// 6. Format output
format := getOutputFormat()
switch format {
case output.FormatJSON:
// JSON output
case output.FormatYAML:
// YAML output
default:
// Human-readable output
}
return nil
}Always update in this order:
-
Interface (
internal/api/interface.go):type APIClient interface { NewMethod(params) (result, error) }
-
Implementation (
internal/api/*.go):func (c *Client) NewMethod(params) (result, error) { // Implementation }
-
Mock (
internal/api/mock.go):type MockClient struct { NewMethodFunc func(params) (result, error) } func (m *MockClient) NewMethod(params) (result, error) { if m.NewMethodFunc != nil { return m.NewMethodFunc(params) } return defaultValue, nil }
Always run tests before committing:
go build -o railctl ./cmd/railctl
go test ./...Test coverage expectations:
- Unit tests for complex logic
- Integration tests for API interactions (using mocks)
- Manual testing with real Railway API
- E2E tests for command additions or flag changes (see below)
Location: tests/e2e/{account,workspace,project} — three Go test groups
keyed to Railway token scope (shared harness in tests/e2e/harness/); see
tests/e2e/README.md. Pick the group by what the token type can do: workspace
enumeration → account/, project/env lifecycle + minting → workspace/,
everything in-scope + boundary fail-fasts → project/ (the bulk).
When to update E2E tests:
- Adding a new command (e.g.,
railctl get foo) - Adding new flags to existing commands (e.g.,
--new-flag) - Changing flag behavior or output format
- Modifying error messages or validation logic
- Adding new output formats
Running E2E tests:
# Build the binary
go build -o railctl ./cmd/railctl
# Set up tokens (each group runs under exactly its token type)
export RAILWAY_ACCOUNT_TOKEN="..."
export RAILWAY_WORKSPACE_TOKEN="..."
# Run the suite (or one group)
make test-e2e
make test-e2e-project
# One test directly
RAILCTL=$(pwd)/railctl RAILWAY_WORKSPACE_TOKEN=... \
go test -tags e2e -v -run TestBoundaries ./tests/e2e/project/...
# Keep resources on failure for manual inspection
E2E_KEEP=1 make test-e2e-projectWhat to add when creating a new command:
- Add test cases for the command in the appropriate phase function
- Test all output formats: table, wide, json, yaml
- Test success cases with various flag combinations
- Test error cases (missing flags, invalid inputs, nonexistent resources)
- If the command affects deployments, update
test_deployment_lifecycle() - Update the test count in
tests/e2e/README.md
Example pattern for adding tests:
# In the appropriate test_* function:
# Success case
_test_header "new command with --flag"
rcpe new command --flag value
assert_success "new command"
# Output formats
_test_header "new command -o json"
rcpe new command -o json
assert_valid_json "new command -o json"
# Error case
_test_header "new command without required flag (expect error)"
rcpe new command
assert_failure "new command missing flag"See tests/e2e/README.md for full documentation on the E2E test suite structure and coverage.
Support three output formats:
- Table (default) - Human-readable, aligned columns
- JSON (
-o json) - Machine-readable - YAML (
-o yaml) - Configuration-friendly
Pattern:
type outputStruct struct {
Field string `json:"field" yaml:"field"`
}
func toOutput(input types.Data) outputStruct {
return outputStruct{
Field: input.Field,
}
}Pattern for all credentials/config:
// 1. Check flag first
value := flagValue
// 2. Fall back to env var
if value == "" {
value = os.Getenv("RAILCTL_VAR_NAME")
}
// 3. Validate if required
if value == "" {
return fmt.Errorf("value required via --flag or RAILCTL_VAR_NAME")
}Document in:
- Flag description:
"Description (env: RAILCTL_VAR_NAME)" - Command Long description
- README.md
Patterns:
// Wrap errors with context
return fmt.Errorf("failed to fetch service: %w", err)
// User-friendly messages
return fmt.Errorf("service '%s' not found in environment", name)
// Validation errors
if image == "" && creds == nil {
return fmt.Errorf("at least one of --image or registry credentials is required")
}Use conventional commits:
feat(railctl-go): Add feature description
fix(railctl-go): Fix issue description
docs(railctl-go): Update documentation
test(railctl-go): Add tests for feature
Multi-line commits for complex changes:
feat(railctl-go): Add deployment status to services
- Add GetBuildLogs API method
- Update ServiceDetail struct with status fields
- Show deployment errors in describe command
- Add tests for new functionality
// Use resolver package for flexible name/ID matching
import "github.com/kubenoops/railctl/internal/resolver"
project, err := resolver.ResolveProject(projects, userInput)
env, err := resolver.ResolveEnvironment(environments, userInput)import "github.com/kubenoops/railctl/internal/types"
// Use RelativeTime for human-friendly timestamps
fmt.Printf("Updated: %s\n", types.RelativeTime(service.UpdatedAt))// Always support both flags and env vars
type RegistryCredentials struct {
Username string
Password string
}
func getCredentials(flagUser, flagPass string) *RegistryCredentials {
user := flagUser
if user == "" {
user = os.Getenv("RAILCTL_REGISTRY_USERNAME")
}
pass := flagPass
if pass == "" {
pass = os.Getenv("RAILCTL_REGISTRY_PASSWORD")
}
if user != "" && pass != "" {
return &RegistryCredentials{
Username: user,
Password: pass,
}
}
return nil
}# Test GraphQL directly with curl
curl -X POST https://backboard.railway.app/graphql/v2 \
-H "Authorization: Bearer $RAILWAY_TOKEN" \
-H "Content-Type: application/json" \
-d '{"query": "query { ... }"}'# Check how Rust CLI does it
rg "mutation_name" railway-cli-rust/src/
rg "query_name" railway-cli-rust/src/gql/# Check field types in schema
jq '.data.__schema.types[] | select(.name=="TypeName")' railway-cli-rust/src/gql/schema.json-
Deployment Redeployment:
- Use
deploymentRedeploy(id: $deploymentID)mutation - NOT
serviceInstanceRedeploy(doesn't exist) - Requires the deployment ID, not service/environment IDs
- Use
-
Service Updates:
serviceInstanceUpdateautomatically triggers deployment- No need to manually call redeploy after update
- Can update image, credentials, or both
-
Error Messages:
- Deployment errors are in build logs, not in
metafield metacontains deployment configuration, not error messages- Use
buildLogsquery to fetch error details
- Deployment errors are in build logs, not in
-
Credentials:
- Private registry credentials go in
registryCredentialsfield - Format:
{username: string, password: string} - Only include if both username and password are provided
- Private registry credentials go in
-
JSON Handling:
// Use any for flexible JSON structures Meta any `json:"meta"` // Type assert when accessing if meta, ok := field.Meta.(map[string]any); ok { // Access nested fields }
-
Optional Fields:
// Use omitempty for optional JSON fields Field string `json:"field,omitempty"` // Use pointers for optional struct fields LatestDeployment *struct { ID string `json:"id"` } `json:"latestDeployment"`
-
GraphQL Input:
// Build input dynamically input := map[string]any{} if value != "" { input["field"] = value }
Pattern for API debugging:
// Add Debug field to Client struct
type Client struct {
token string
apiURL string
httpClient *http.Client
Debug bool
}
// In execute method, log requests/responses when Debug is true
if c.Debug {
fmt.Fprintf(os.Stderr, "\n[DEBUG] GraphQL Request:\n")
fmt.Fprintf(os.Stderr, "URL: %s\n", c.apiURL)
fmt.Fprintf(os.Stderr, "Query: %s\n", query)
if len(variables) > 0 {
varsJSON, _ := json.MarshalIndent(variables, "", " ")
fmt.Fprintf(os.Stderr, "Variables: %s\n", string(varsJSON))
}
}Global flag pattern:
// In root.go
var debug bool
rootCmd.PersistentFlags().BoolVar(&debug, "debug", false,
"Enable debug logging (shows GraphQL requests/responses)")
// Wire to API client
client := api.NewClient(token)
client.Debug = debugPattern for environment-specific service creation:
Railway creates services in ALL non-fork environments by default. To create in a specific environment only:
// 1. Create service (will create in all environments)
service, err := client.CreateService(input, environmentID)
// 2. Get all environments
environments, err := client.GetEnvironments(projectID)
// 3. Delete instances from non-target environments
for _, env := range environments {
if env.ID != targetEnvironmentID && !env.IsFork {
// Add delay before cleanup
time.Sleep(500 * time.Millisecond)
// Retry with exponential backoff
err := retryWithBackoff(func() error {
return client.DeleteServiceInstance(service.ID, env.ID)
}, 3, time.Second)
if err != nil {
// Log but don't fail - provide manual cleanup instructions
fmt.Fprintf(os.Stderr, "Warning: failed to cleanup service instance in %s\n", env.Name)
}
}
}Retry pattern with exponential backoff:
func retryWithBackoff(fn func() error, maxRetries int, initialDelay time.Duration) error {
var lastErr error
for i := 0; i < maxRetries; i++ {
if err := fn(); err == nil {
return nil
} else {
lastErr = err
}
if i < maxRetries-1 {
delay := initialDelay * time.Duration(1<<uint(i)) // 1s, 2s, 4s
time.Sleep(delay)
}
}
return lastErr
}Pattern for listing and deleting volumes:
// Volumes are orphaned when services are deleted
// Always clean up volumes after deleting services
// 1. Delete services first
for _, serviceName := range services {
client.DeleteService(serviceName, projectID, environmentID)
}
// 2. List all volumes in environment
volumes, err := client.GetVolumes(projectID, environmentID)
// 3. Delete each volume
for _, volume := range volumes {
client.DeleteVolume(volume.Name, projectID, environmentID)
}Note: Railway UI may not show orphaned volumes, but they still exist and consume quota. Always use the CLI to verify volume cleanup.
Pattern for bash scripts using yq:
# Helper to read config values
get_config() {
local service_dir="$1"
local path="$2"
yq eval "$path" "$service_dir/config.yaml"
}
# Create service from config
create_service_from_config() {
local service_dir="$1"
local service_name=$(get_config "$service_dir" '.service.name')
local image=$(get_config "$service_dir" '.service.image')
# Read all config and build command
railctl create service "$service_name" --image "$image" ...
}Variable expansion pattern:
# Preserve Railway service references (${{service.VAR}})
# Expand environment variables (${VAR})
if [[ "$value" == *'${{'* ]]; then
# Railway reference - keep as-is
var_args+=("$key=$value")
elif [[ "$value" == *'${'* ]]; then
# Environment variable - expand it
expanded_value=$(set +u; eval echo "\"$value\"")
var_args+=("$key=$expanded_value")
fi- Research Rust CLI implementation
- Check GraphQL schema for field types
- Define GraphQL query/mutation constant
- Create response type structs
- Implement API client method
- Update APIClient interface
- Update MockClient
- Create/update command file
- Add flags with env var documentation
- Implement command logic with proper error handling
- Support JSON/YAML/table output
- Add examples to command help
- Write/update unit tests
- Update the E2E suite (
tests/e2e/{account,workspace,project}) for new commands/flags — anddocs/railctl-skill.md(CI enforces it) - Build and test manually
- Run E2E tests:
make test-e2e - Update README if needed
- Commit with conventional commit message
- Railway GraphQL API:
https://backboard.railway.app/graphql/v2 - Schema:
railway-cli-rust/src/gql/schema.json - Rust CLI Reference:
railway-cli-rust/src/commands/*.rs - GraphQL Queries:
railway-cli-rust/src/gql/queries/strings/*.graphql - GraphQL Mutations:
railway-cli-rust/src/gql/mutations/strings/*.graphql - Python Prototype:
experiments/tools/railctl/(for reference) - Docker Compose Importer:
experiments/tools/compose-importer/