diff --git a/cmd/ask.go b/cmd/ask.go index bc94481..7958f24 100644 --- a/cmd/ask.go +++ b/cmd/ask.go @@ -31,6 +31,7 @@ import ( "github.com/bgdnvk/clanker/internal/k8s" "github.com/bgdnvk/clanker/internal/k8s/plan" "github.com/bgdnvk/clanker/internal/maker" + "github.com/bgdnvk/clanker/internal/railway" "github.com/bgdnvk/clanker/internal/resourcedb" "github.com/bgdnvk/clanker/internal/routing" tfclient "github.com/bgdnvk/clanker/internal/terraform" @@ -43,10 +44,10 @@ import ( // askCmd represents the ask command const defaultGeminiModel = "gemini-2.5-flash" -func applyDiscoveryContextDefaults(includeAWS, includeGCP, includeAzure, includeCloudflare, includeDigitalOcean, includeHetzner, includeTerraform, includeVercel, includeVerda bool) (bool, bool, bool, bool, bool, bool, bool, bool, bool) { +func applyDiscoveryContextDefaults(includeAWS, includeGCP, includeAzure, includeCloudflare, includeDigitalOcean, includeHetzner, includeTerraform, includeVercel, includeVerda, includeRailway bool) (bool, bool, bool, bool, bool, bool, bool, bool, bool, bool) { includeTerraform = true - if includeAWS || includeGCP || includeAzure || includeCloudflare || includeDigitalOcean || includeHetzner || includeVercel || includeVerda { - return includeAWS, includeGCP, includeAzure, includeCloudflare, includeDigitalOcean, includeHetzner, includeTerraform, includeVercel, includeVerda + if includeAWS || includeGCP || includeAzure || includeCloudflare || includeDigitalOcean || includeHetzner || includeVercel || includeVerda || includeRailway { + return includeAWS, includeGCP, includeAzure, includeCloudflare, includeDigitalOcean, includeHetzner, includeTerraform, includeVercel, includeVerda, includeRailway } switch routing.DefaultInfraProvider() { @@ -64,11 +65,13 @@ func applyDiscoveryContextDefaults(includeAWS, includeGCP, includeAzure, include includeVercel = true case "verda": includeVerda = true + case "railway": + includeRailway = true default: includeAWS = true } - return includeAWS, includeGCP, includeAzure, includeCloudflare, includeDigitalOcean, includeHetzner, includeTerraform, includeVercel, includeVerda + return includeAWS, includeGCP, includeAzure, includeCloudflare, includeDigitalOcean, includeHetzner, includeTerraform, includeVercel, includeVerda, includeRailway } var askCmd = &cobra.Command{ @@ -111,6 +114,7 @@ Examples: includeDigitalOcean, _ := cmd.Flags().GetBool("digitalocean") includeHetzner, _ := cmd.Flags().GetBool("hetzner") includeVercel, _ := cmd.Flags().GetBool("vercel") + includeRailway, _ := cmd.Flags().GetBool("railway") includeVerda, _ := cmd.Flags().GetBool("verda") includeTerraform, _ := cmd.Flags().GetBool("terraform") includeIAM, _ := cmd.Flags().GetBool("iam") @@ -321,6 +325,20 @@ Examples: }) } + if strings.EqualFold(strings.TrimSpace(makerPlan.Provider), "railway") { + rwToken, rwWorkspaceID, rwErr := resolveRailwayToken(ctx, debug) + if rwErr != nil { + return rwErr + } + return maker.ExecuteRailwayPlan(ctx, makerPlan, maker.ExecOptions{ + RailwayAPIToken: rwToken, + RailwayWorkspaceID: rwWorkspaceID, + Writer: os.Stdout, + Destroyer: destroyer, + Debug: debug, + }) + } + if strings.EqualFold(strings.TrimSpace(makerPlan.Provider), "verda") { verdaClientID, verdaClientSecret, verdaProjectID, vErr := resolveVerdaCredentialsWithContext(ctx, debug) if vErr != nil { @@ -471,6 +489,7 @@ Examples: explicitHetzner := cmd.Flags().Changed("hetzner") && includeHetzner explicitAzure := cmd.Flags().Changed("azure") && includeAzure explicitVercel := cmd.Flags().Changed("vercel") && includeVercel + explicitRailway := cmd.Flags().Changed("railway") && includeRailway explicitVerda := cmd.Flags().Changed("verda") && includeVerda explicitCount := 0 if explicitGCP { @@ -494,11 +513,14 @@ Examples: if explicitVercel { explicitCount++ } + if explicitRailway { + explicitCount++ + } if explicitVerda { explicitCount++ } if explicitCount > 1 { - return fmt.Errorf("cannot use multiple provider flags (--aws, --gcp, --azure, --cloudflare, --digitalocean, --hetzner, --vercel, --verda) together with --maker") + return fmt.Errorf("cannot use multiple provider flags (--aws, --gcp, --azure, --cloudflare, --digitalocean, --hetzner, --vercel, --railway, --verda) together with --maker") } switch { case explicitHetzner: @@ -522,6 +544,9 @@ Examples: case explicitVercel: makerProvider = "vercel" makerProviderReason = "explicit" + case explicitRailway: + makerProvider = "railway" + makerProviderReason = "explicit" case explicitVerda: makerProvider = "verda" makerProviderReason = "explicit" @@ -545,6 +570,9 @@ Examples: } else if svcCtx.Vercel { makerProvider = "vercel" makerProviderReason = "inferred" + } else if svcCtx.Railway { + makerProvider = "railway" + makerProviderReason = "inferred" } else if svcCtx.Verda { makerProvider = "verda" makerProviderReason = "inferred" @@ -569,6 +597,8 @@ Examples: prompt = maker.GCPPlanPromptWithMode(question, destroyer) case "vercel": prompt = maker.VercelPlanPromptWithMode(question, destroyer) + case "railway": + prompt = maker.RailwayPlanPromptWithMode(question, destroyer) case "verda": prompt = maker.VerdaPlanPromptWithMode(question, destroyer) default: @@ -631,9 +661,9 @@ Examples: plan.Provider = makerProvider - // Handle GCP, Azure, Cloudflare, Digital Ocean, Hetzner, Vercel, and Verda plans (output directly, no enrichment) + // Handle GCP, Azure, Cloudflare, Digital Ocean, Hetzner, Vercel, Verda, and Railway plans (output directly, no enrichment) providerLower := strings.ToLower(strings.TrimSpace(plan.Provider)) - if providerLower == "gcp" || providerLower == "azure" || providerLower == "cloudflare" || providerLower == "digitalocean" || providerLower == "hetzner" || providerLower == "vercel" || providerLower == "verda" { + if providerLower == "gcp" || providerLower == "azure" || providerLower == "cloudflare" || providerLower == "digitalocean" || providerLower == "hetzner" || providerLower == "vercel" || providerLower == "verda" || providerLower == "railway" { if plan.CreatedAt.IsZero() { plan.CreatedAt = time.Now().UTC() } @@ -723,7 +753,7 @@ Format as a professional compliance table suitable for government security docum // Discovery mode enables comprehensive infrastructure analysis if discovery { - includeAWS, includeGCP, includeAzure, includeCloudflare, includeDigitalOcean, includeHetzner, includeTerraform, includeVercel, includeVerda = applyDiscoveryContextDefaults( + includeAWS, includeGCP, includeAzure, includeCloudflare, includeDigitalOcean, includeHetzner, includeTerraform, includeVercel, includeVerda, includeRailway = applyDiscoveryContextDefaults( includeAWS, includeGCP, includeAzure, @@ -733,6 +763,7 @@ Format as a professional compliance table suitable for government security docum includeTerraform, includeVercel, includeVerda, + includeRailway, ) if debug { fmt.Println("Discovery mode enabled: Terraform context activated alongside the selected infrastructure provider(s)") @@ -768,12 +799,17 @@ Format as a professional compliance table suitable for government security docum return handleVercelQuery(context.Background(), question, debug) } + // Handle explicit --railway flag + if includeRailway && !makerMode { + return handleRailwayQuery(context.Background(), question, debug) + } + // Handle explicit --verda flag if includeVerda && !makerMode { return handleVerdaQuery(cmd.Context(), question, debug) } - if !includeAWS && !includeGitHub && !includeTerraform && !includeGCP && !includeAzure && !includeCloudflare && !includeDigitalOcean && !includeHetzner && !includeVercel && !includeVerda && !includeDB { + if !includeAWS && !includeGitHub && !includeTerraform && !includeGCP && !includeAzure && !includeCloudflare && !includeDigitalOcean && !includeHetzner && !includeVercel && !includeRailway && !includeVerda && !includeDB { routingQuestion := questionForRouting(question) // First, do quick keyword check for explicit terms @@ -849,6 +885,11 @@ Format as a professional compliance table suitable for government security docum return handleVercelQuery(context.Background(), routingQuestion, debug) } + // Handle Railway queries + if svcCtx.Railway { + return handleRailwayQuery(context.Background(), routingQuestion, debug) + } + // Handle Verda queries if svcCtx.Verda { return handleVerdaQuery(cmd.Context(), routingQuestion, debug) @@ -1338,6 +1379,7 @@ func init() { askCmd.Flags().Bool("digitalocean", false, "Include Digital Ocean infrastructure context") askCmd.Flags().Bool("hetzner", false, "Include Hetzner Cloud infrastructure context") askCmd.Flags().Bool("vercel", false, "Include Vercel context") + askCmd.Flags().Bool("railway", false, "Include Railway context") askCmd.Flags().Bool("verda", false, "Include Verda Cloud (GPU/AI) infrastructure context") askCmd.Flags().Bool("github", false, "Include GitHub repository context") askCmd.Flags().Bool("cicd", false, "Include CI/CD context (currently GitHub Actions)") @@ -1369,7 +1411,7 @@ func init() { askCmd.Flags().String("minimax-model", "", "MiniMax model to use (overrides config)") askCmd.Flags().String("github-model", "", "GitHub Models model to use (overrides config)") askCmd.Flags().Bool("agent-trace", false, "Show detailed coordinator agent lifecycle logs (overrides config)") - askCmd.Flags().Bool("maker", false, "Generate an AWS, GCP, Azure, Cloudflare, Digital Ocean, Hetzner, Vercel, or Verda plan (JSON) for infrastructure changes") + askCmd.Flags().Bool("maker", false, "Generate an AWS, GCP, Azure, Cloudflare, Digital Ocean, Hetzner, Vercel, Railway, or Verda plan (JSON) for infrastructure changes") askCmd.Flags().Bool("destroyer", false, "Allow destructive operations when using --maker (requires explicit confirmation in UI/workflow)") askCmd.Flags().Bool("apply", false, "Apply an approved maker plan (reads from stdin unless --plan-file is provided)") askCmd.Flags().String("plan-file", "", "Optional path to maker plan JSON file for --apply") @@ -2394,6 +2436,135 @@ func buildVercelPrompt(question, vercelContext, historyContext string) string { return sb.String() } +// resolveRailwayToken resolves the Railway account token and optional workspace +// ID from config or environment. Workspace ID may be empty for single-workspace +// accounts; the GraphQL API infers scope from the token in that case. +func resolveRailwayToken(ctx context.Context, debug bool) (apiToken string, workspaceID string, err error) { + apiToken = railway.ResolveAPIToken() + if apiToken != "" { + return apiToken, railway.ResolveWorkspaceID(), nil + } + + backendAPIKey := backend.ResolveAPIKey("") + if backendAPIKey != "" { + backendClient := backend.NewClient(backendAPIKey, debug) + creds, bErr := backendClient.GetRailwayCredentials(ctx) + if bErr == nil && strings.TrimSpace(creds.APIToken) != "" { + if debug { + fmt.Println("[backend] Using Railway credentials from backend") + } + return strings.TrimSpace(creds.APIToken), strings.TrimSpace(creds.WorkspaceID), nil + } + if debug { + fmt.Printf("[backend] No Railway credentials available (%v), falling back to local\n", bErr) + } + } + + return "", "", fmt.Errorf("Railway token not configured. Set railway.api_token in ~/.clanker.yaml or export RAILWAY_API_TOKEN") +} + +// handleRailwayQuery delegates a Railway query to the Railway agent with +// per-workspace conversation history for multi-turn context. +func handleRailwayQuery(ctx context.Context, question string, debug bool) error { + if debug { + fmt.Println("Delegating query to Railway agent...") + } + + apiToken, workspaceID, err := resolveRailwayToken(ctx, debug) + if err != nil { + return err + } + + client, err := railway.NewClient(apiToken, workspaceID, debug) + if err != nil { + return fmt.Errorf("failed to create Railway client: %w", err) + } + + conversationID := workspaceID + if conversationID == "" { + conversationID = "personal" + } + history := railway.NewConversationHistory(conversationID) + if err := history.Load(); err != nil && debug { + fmt.Fprintf(os.Stderr, "[debug] conversation history: %v\n", err) + } + + railwayContext, err := client.GetRelevantContext(ctx, question) + if err != nil { + fmt.Fprintf(os.Stderr, "[railway] warning: failed to fetch context: %v\n", err) + if strings.TrimSpace(railwayContext) == "" { + return fmt.Errorf("failed to fetch Railway context: %w", err) + } + } + + provider := viper.GetString("ai.default_provider") + if provider == "" { + provider = "openai" + } + + var apiKey string + switch provider { + case "bedrock", "claude": + apiKey = "" + case "gemini", "gemini-api": + apiKey = "" + case "openai": + apiKey = resolveOpenAIKey("") + case "anthropic": + apiKey = resolveAnthropicKey("") + case "cohere": + apiKey = resolveCohereKey("") + case "deepseek": + apiKey = resolveDeepSeekKey("") + case "minimax": + apiKey = resolveMiniMaxKey("") + default: + apiKey = viper.GetString(fmt.Sprintf("ai.providers.%s.api_key", provider)) + } + + aiClient := ai.NewClient(provider, apiKey, debug, provider) + + historyContext := history.GetRecentContext(5) + prompt := buildRailwayPrompt(question, railwayContext, historyContext) + + response, err := aiClient.AskPrompt(ctx, prompt) + if err != nil { + return fmt.Errorf("Railway AI query failed: %w", err) + } + + fmt.Println(response) + + history.AddEntry(question, response) + if err := history.Save(); err != nil && debug { + fmt.Fprintf(os.Stderr, "[debug] save history: %v\n", err) + } + + return nil +} + +// buildRailwayPrompt assembles the system prompt for a Railway ask query, +// injecting infrastructure context and recent conversation history when +// available. +func buildRailwayPrompt(question, railwayContext, historyContext string) string { + var sb strings.Builder + sb.WriteString("You are a Railway infrastructure assistant. ") + sb.WriteString("Answer questions about the user's Railway workspace (projects, services, environments, deployments, domains, variables, volumes) based on the provided context.\n\n") + if railwayContext != "" { + sb.WriteString("Railway Context:\n") + sb.WriteString(railwayContext) + sb.WriteString("\n\n") + } + if historyContext != "" { + sb.WriteString("Recent Conversation:\n") + sb.WriteString(historyContext) + sb.WriteString("\n\n") + } + sb.WriteString("User Question: ") + sb.WriteString(question) + sb.WriteString("\n\nProvide a helpful, concise response in markdown format.") + return sb.String() +} + // resolveVerdaCredentials returns the Verda client ID / client secret / project ID // resolving in this order: ~/.clanker.yaml (verda.* keys) → VERDA_* env vars → // ~/.verda/credentials (written by `verda auth login`). diff --git a/cmd/ask_test.go b/cmd/ask_test.go index 8cb9251..edf4661 100644 --- a/cmd/ask_test.go +++ b/cmd/ask_test.go @@ -60,12 +60,12 @@ func TestApplyCommandAIOverrides_RespectsConfiguredProvider(t *testing.T) { func TestApplyDiscoveryContextDefaults_UsesConfiguredHetzner(t *testing.T) { useDefaultInfraProvider(t, "hetzner") - includeAWS, includeGCP, includeAzure, includeCloudflare, includeDigitalOcean, includeHetzner, includeTerraform, includeVercel, includeVerda := applyDiscoveryContextDefaults(false, false, false, false, false, false, false, false, false) + includeAWS, includeGCP, includeAzure, includeCloudflare, includeDigitalOcean, includeHetzner, includeTerraform, includeVercel, includeVerda, includeRailway := applyDiscoveryContextDefaults(false, false, false, false, false, false, false, false, false, false) if includeAWS { t.Fatal("expected discovery defaults not to force AWS when Hetzner is configured") } - if includeGCP || includeAzure || includeCloudflare || includeDigitalOcean || includeVercel || includeVerda { + if includeGCP || includeAzure || includeCloudflare || includeDigitalOcean || includeVercel || includeVerda || includeRailway { t.Fatal("expected discovery defaults to select only the configured provider") } if !includeHetzner { @@ -79,9 +79,9 @@ func TestApplyDiscoveryContextDefaults_UsesConfiguredHetzner(t *testing.T) { func TestApplyDiscoveryContextDefaults_PreservesExplicitProviderSelection(t *testing.T) { useDefaultInfraProvider(t, "hetzner") - includeAWS, includeGCP, includeAzure, includeCloudflare, includeDigitalOcean, includeHetzner, includeTerraform, includeVercel, includeVerda := applyDiscoveryContextDefaults(false, false, false, false, false, true, false, false, false) + includeAWS, includeGCP, includeAzure, includeCloudflare, includeDigitalOcean, includeHetzner, includeTerraform, includeVercel, includeVerda, includeRailway := applyDiscoveryContextDefaults(false, false, false, false, false, true, false, false, false, false) - if includeAWS || includeGCP || includeAzure || includeCloudflare || includeDigitalOcean || includeVercel || includeVerda { + if includeAWS || includeGCP || includeAzure || includeCloudflare || includeDigitalOcean || includeVercel || includeVerda || includeRailway { t.Fatal("expected explicit provider selection to be preserved without adding other providers") } if !includeHetzner { @@ -95,12 +95,12 @@ func TestApplyDiscoveryContextDefaults_PreservesExplicitProviderSelection(t *tes func TestApplyDiscoveryContextDefaults_UsesConfiguredVercel(t *testing.T) { useDefaultInfraProvider(t, "vercel") - includeAWS, includeGCP, includeAzure, includeCloudflare, includeDigitalOcean, includeHetzner, includeTerraform, includeVercel, includeVerda := applyDiscoveryContextDefaults(false, false, false, false, false, false, false, false, false) + includeAWS, includeGCP, includeAzure, includeCloudflare, includeDigitalOcean, includeHetzner, includeTerraform, includeVercel, includeVerda, includeRailway := applyDiscoveryContextDefaults(false, false, false, false, false, false, false, false, false, false) if includeAWS { t.Fatal("expected discovery defaults not to force AWS when Vercel is configured") } - if includeGCP || includeAzure || includeCloudflare || includeDigitalOcean || includeHetzner || includeVerda { + if includeGCP || includeAzure || includeCloudflare || includeDigitalOcean || includeHetzner || includeVerda || includeRailway { t.Fatal("expected discovery defaults to select only the configured provider") } if !includeVercel { @@ -114,12 +114,12 @@ func TestApplyDiscoveryContextDefaults_UsesConfiguredVercel(t *testing.T) { func TestApplyDiscoveryContextDefaults_UsesConfiguredVerda(t *testing.T) { useDefaultInfraProvider(t, "verda") - includeAWS, includeGCP, includeAzure, includeCloudflare, includeDigitalOcean, includeHetzner, includeTerraform, includeVercel, includeVerda := applyDiscoveryContextDefaults(false, false, false, false, false, false, false, false, false) + includeAWS, includeGCP, includeAzure, includeCloudflare, includeDigitalOcean, includeHetzner, includeTerraform, includeVercel, includeVerda, includeRailway := applyDiscoveryContextDefaults(false, false, false, false, false, false, false, false, false, false) if includeAWS { t.Fatal("expected discovery defaults not to force AWS when Verda is configured") } - if includeGCP || includeAzure || includeCloudflare || includeDigitalOcean || includeHetzner || includeVercel { + if includeGCP || includeAzure || includeCloudflare || includeDigitalOcean || includeHetzner || includeVercel || includeRailway { t.Fatal("expected discovery defaults to select only the configured provider") } if !includeVerda { @@ -129,3 +129,22 @@ func TestApplyDiscoveryContextDefaults_UsesConfiguredVerda(t *testing.T) { t.Fatal("expected discovery defaults to enable Terraform context") } } + +func TestApplyDiscoveryContextDefaults_UsesConfiguredRailway(t *testing.T) { + useDefaultInfraProvider(t, "railway") + + includeAWS, includeGCP, includeAzure, includeCloudflare, includeDigitalOcean, includeHetzner, includeTerraform, includeVercel, includeVerda, includeRailway := applyDiscoveryContextDefaults(false, false, false, false, false, false, false, false, false, false) + + if includeAWS { + t.Fatal("expected discovery defaults not to force AWS when Railway is configured") + } + if includeGCP || includeAzure || includeCloudflare || includeDigitalOcean || includeHetzner || includeVercel || includeVerda { + t.Fatal("expected discovery defaults to select only the configured provider") + } + if !includeRailway { + t.Fatal("expected discovery defaults to enable Railway when configured") + } + if !includeTerraform { + t.Fatal("expected discovery defaults to enable Terraform context") + } +} diff --git a/cmd/credentials.go b/cmd/credentials.go index 49c25af..8105825 100644 --- a/cmd/credentials.go +++ b/cmd/credentials.go @@ -14,6 +14,7 @@ import ( "github.com/bgdnvk/clanker/internal/backend" "github.com/bgdnvk/clanker/internal/cloudflare" "github.com/bgdnvk/clanker/internal/hetzner" + "github.com/bgdnvk/clanker/internal/railway" "github.com/bgdnvk/clanker/internal/vercel" "github.com/bgdnvk/clanker/internal/verda" "github.com/spf13/cobra" @@ -41,7 +42,7 @@ var credentialsStoreCmd = &cobra.Command{ Short: "Store credentials in the backend", Long: `Upload local credentials to the clanker backend. -Supported providers: aws, gcp, hetzner, cloudflare, vercel, verda, k8s +Supported providers: aws, gcp, hetzner, cloudflare, vercel, railway, verda, k8s AWS: Exports credentials from local AWS CLI profile using 'aws configure export-credentials'. @@ -60,6 +61,11 @@ Vercel: (vercel.team_id / VERCEL_TEAM_ID). Override either with --api-token / --team-id flags. +Railway: + Uses account token (railway.api_token / RAILWAY_API_TOKEN) and optional + workspace_id (railway.workspace_id / RAILWAY_WORKSPACE_ID). Override + either with --api-token / --workspace-id flags. + K8s: Uploads kubeconfig file content (base64 encoded). @@ -70,6 +76,8 @@ Examples: clanker credentials store cloudflare clanker credentials store vercel clanker credentials store vercel --api-token ${VERCEL_TOKEN} --team-id team_abc + clanker credentials store railway + clanker credentials store railway --api-token ${RAILWAY_API_TOKEN} --workspace-id ws_abc clanker credentials store verda clanker credentials store verda --client-id ${VERDA_CLIENT_ID} --client-secret ${VERDA_CLIENT_SECRET} clanker credentials store k8s --kubeconfig ~/.kube/config`, @@ -97,6 +105,7 @@ Examples: clanker credentials test hetzner clanker credentials test cloudflare clanker credentials test vercel + clanker credentials test railway clanker credentials test k8s clanker credentials test`, Args: cobra.MaximumNArgs(1), @@ -112,7 +121,8 @@ Examples: clanker credentials delete aws clanker credentials delete gcp clanker credentials delete hetzner - clanker credentials delete vercel`, + clanker credentials delete vercel + clanker credentials delete railway`, Args: cobra.ExactArgs(1), RunE: runCredentialsDelete, } @@ -130,8 +140,9 @@ func init() { credentialsStoreCmd.Flags().String("service-account", "", "GCP service account JSON file path") credentialsStoreCmd.Flags().String("kubeconfig", "", "Path to kubeconfig file (default: ~/.kube/config)") credentialsStoreCmd.Flags().String("context", "", "Kubernetes context name to use") - credentialsStoreCmd.Flags().String("api-token", "", "Vercel API token (overrides vercel.api_token / VERCEL_TOKEN)") + credentialsStoreCmd.Flags().String("api-token", "", "Vercel/Railway API token (overrides .api_token / VERCEL_TOKEN / RAILWAY_API_TOKEN)") credentialsStoreCmd.Flags().String("team-id", "", "Vercel team ID (overrides vercel.team_id / VERCEL_TEAM_ID)") + credentialsStoreCmd.Flags().String("workspace-id", "", "Railway workspace ID (overrides railway.workspace_id / RAILWAY_WORKSPACE_ID)") credentialsStoreCmd.Flags().String("client-id", "", "Verda OAuth2 client ID (overrides verda.client_id / VERDA_CLIENT_ID)") credentialsStoreCmd.Flags().String("client-secret", "", "Verda OAuth2 client secret (overrides verda.client_secret / VERDA_CLIENT_SECRET)") credentialsStoreCmd.Flags().String("project-id", "", "Verda project ID (overrides verda.default_project_id / VERDA_PROJECT_ID)") @@ -172,12 +183,14 @@ func runCredentialsStore(cmd *cobra.Command, args []string) error { return storeCloudflareCredentials(ctx, cmd, client) case "vercel": return storeVercelCredentials(ctx, cmd, client) + case "railway": + return storeRailwayCredentials(ctx, cmd, client) case "verda": return storeVerdaCredentials(ctx, cmd, client) case "k8s", "kubernetes": return storeKubernetesCredentials(ctx, cmd, client) default: - return fmt.Errorf("unsupported provider: %s (supported: aws, gcp, hetzner, cloudflare, vercel, verda, k8s)", provider) + return fmt.Errorf("unsupported provider: %s (supported: aws, gcp, hetzner, cloudflare, vercel, railway, verda, k8s)", provider) } } @@ -357,6 +370,36 @@ func storeVercelCredentials(ctx context.Context, cmd *cobra.Command, client *bac return nil } +func storeRailwayCredentials(ctx context.Context, cmd *cobra.Command, client *backend.Client) error { + apiToken, _ := cmd.Flags().GetString("api-token") + if strings.TrimSpace(apiToken) == "" { + apiToken = railway.ResolveAPIToken() + } + if strings.TrimSpace(apiToken) == "" { + return fmt.Errorf("Railway API token required: use --api-token flag, set railway.api_token in config, or export RAILWAY_API_TOKEN") + } + + workspaceID, _ := cmd.Flags().GetString("workspace-id") + if strings.TrimSpace(workspaceID) == "" { + workspaceID = railway.ResolveWorkspaceID() + } + + creds := &backend.RailwayCredentials{ + APIToken: apiToken, + WorkspaceID: workspaceID, + } + + if err := client.StoreRailwayCredentials(ctx, creds); err != nil { + return fmt.Errorf("failed to store Railway credentials: %w", err) + } + + fmt.Println("Railway credentials stored successfully") + if workspaceID != "" { + fmt.Printf("Workspace ID: %s\n", workspaceID) + } + return nil +} + // storeVerdaCredentials pushes Verda OAuth2 client_id / client_secret (plus // optional project_id) to the clanker backend so other machines can pull // them via `clanker ask --verda` without re-authenticating. @@ -538,6 +581,8 @@ func testCredential(ctx context.Context, client *backend.Client, provider backen return testCloudflareCredentials(ctx, client, debug) case backend.ProviderVercel: return testVercelCredentials(ctx, client, debug) + case backend.ProviderRailway: + return testRailwayCredentials(ctx, client, debug) case backend.ProviderVerda: return testVerdaCredentials(ctx, client, debug) case backend.ProviderKubernetes: @@ -835,6 +880,90 @@ func testVercelCredentials(ctx context.Context, client *backend.Client, debug bo return nil } +func testRailwayCredentials(ctx context.Context, client *backend.Client, debug bool) error { + creds, err := client.GetRailwayCredentials(ctx) + if err != nil { + fmt.Printf(" FAILED: %v\n", err) + return err + } + + if creds.APIToken == "" { + fmt.Println(" FAILED: no API token stored") + return fmt.Errorf("no Railway API token") + } + + // Bounded context so a hanging curl does not block forever. + ctx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + + // Verify the token by posting a minimal GraphQL query for the current user. + body := `{"query":"query { me { id name email } }"}` + cmd := exec.CommandContext(ctx, "curl", "-s", "-o", "/dev/stdout", "-w", "\n%{http_code}", + "-X", "POST", + "https://backboard.railway.com/graphql/v2", + "-H", fmt.Sprintf("Authorization: Bearer %s", creds.APIToken), + "-H", "Content-Type: application/json", + "-d", body, + ) + + output, err := cmd.CombinedOutput() + if err != nil { + fmt.Printf(" FAILED: %v\n", err) + return err + } + + raw := strings.TrimSpace(string(output)) + lastNL := strings.LastIndex(raw, "\n") + var respBody, status string + if lastNL < 0 { + status = raw + } else { + respBody = raw[:lastNL] + status = strings.TrimSpace(raw[lastNL+1:]) + } + + if status != "200" { + if debug { + fmt.Printf(" Body: %s\n", respBody) + } + fmt.Printf(" FAILED: HTTP %s\n", status) + return fmt.Errorf("Railway credential test failed (HTTP %s)", status) + } + + // Railway returns errors inside a 200 response envelope — check them. + var response struct { + Data struct { + Me struct { + ID string `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + } `json:"me"` + } `json:"data"` + Errors []struct { + Message string `json:"message"` + } `json:"errors"` + } + if err := json.Unmarshal([]byte(respBody), &response); err != nil { + fmt.Println(" PASSED: token accepted by Railway") + return nil + } + + if len(response.Errors) > 0 { + fmt.Printf(" FAILED: %s\n", response.Errors[0].Message) + return fmt.Errorf("Railway rejected token: %s", response.Errors[0].Message) + } + + switch { + case response.Data.Me.Name != "": + fmt.Printf(" PASSED: authenticated as %s\n", response.Data.Me.Name) + case response.Data.Me.Email != "": + fmt.Printf(" PASSED: authenticated as %s\n", response.Data.Me.Email) + default: + fmt.Println(" PASSED: token accepted by Railway") + } + return nil +} + func testKubernetesCredentials(ctx context.Context, client *backend.Client, debug bool) error { creds, err := client.GetKubernetesCredentials(ctx) if err != nil { diff --git a/cmd/mcp.go b/cmd/mcp.go index a97cef2..120a08d 100644 --- a/cmd/mcp.go +++ b/cmd/mcp.go @@ -11,6 +11,7 @@ import ( "time" "github.com/bgdnvk/clanker/internal/ai" + "github.com/bgdnvk/clanker/internal/railway" "github.com/bgdnvk/clanker/internal/vercel" "github.com/bgdnvk/clanker/internal/verda" "github.com/mark3labs/mcp-go/mcp" @@ -47,6 +48,22 @@ type vercelListArgs struct { Project string `json:"project,omitempty" jsonschema:"description=Project ID for scoped resources (deployments and env)"` } +type railwayAskArgs struct { + Question string `json:"question" jsonschema:"description=Natural language question about Railway infrastructure,required"` + Token string `json:"token,omitempty" jsonschema:"description=Railway API token (falls back to config/env)"` + WorkspaceID string `json:"workspaceId,omitempty" jsonschema:"description=Railway workspace ID"` + Debug bool `json:"debug,omitempty" jsonschema:"description=Enable debug output"` +} + +type railwayListArgs struct { + Resource string `json:"resource" jsonschema:"description=Resource type: projects|services|deployments|domains|variables|volumes|workspaces,required"` + Token string `json:"token,omitempty" jsonschema:"description=Railway API token (falls back to config/env)"` + WorkspaceID string `json:"workspaceId,omitempty" jsonschema:"description=Railway workspace ID"` + Project string `json:"project,omitempty" jsonschema:"description=Project ID for scoped resources (services, deployments, domains, variables, volumes)"` + Environment string `json:"environment,omitempty" jsonschema:"description=Environment ID for scoping deployments/variables"` + Service string `json:"service,omitempty" jsonschema:"description=Service ID for scoping deployments/variables"` +} + type verdaAskArgs struct { Question string `json:"question" jsonschema:"description=Natural language question about Verda Cloud (GPU/AI) infrastructure,required"` ClientID string `json:"clientId,omitempty" jsonschema:"description=Verda OAuth2 client ID (falls back to config/env/credentials file)"` @@ -155,6 +172,29 @@ func newClankerMCPServer() *mcptransport.MCPServer { }), ) + server.AddTool( + mcp.NewTool( + "clanker_railway_ask", + mcp.WithDescription("Ask a natural language question about your Railway infrastructure. Gathers Railway context (projects, services, environments, deployments, domains) and uses the configured AI provider to answer."), + mcp.WithInputSchema[railwayAskArgs](), + ), + mcp.NewTypedToolHandler(func(ctx context.Context, _ mcp.CallToolRequest, args railwayAskArgs) (*mcp.CallToolResult, error) { + return handleMCPRailwayAsk(ctx, args) + }), + ) + + server.AddTool( + mcp.NewTool( + "clanker_railway_list", + mcp.WithDescription("List Railway resources (projects, services, deployments, domains, variables, volumes, workspaces). Returns JSON with the requested resource list."), + mcp.WithInputSchema[railwayListArgs](), + mcp.WithReadOnlyHintAnnotation(true), + ), + mcp.NewTypedToolHandler(func(ctx context.Context, _ mcp.CallToolRequest, args railwayListArgs) (*mcp.CallToolResult, error) { + return handleMCPRailwayList(ctx, args) + }), + ) + server.AddTool( mcp.NewTool( "clanker_verda_ask", @@ -291,6 +331,141 @@ func handleMCPVercelList(ctx context.Context, args vercelListArgs) (*mcp.CallToo return mcp.NewToolResultText(result), nil } +// handleMCPRailwayAsk resolves Railway credentials, gathers context, and asks +// the configured AI provider about the user's Railway infrastructure. +func handleMCPRailwayAsk(ctx context.Context, args railwayAskArgs) (*mcp.CallToolResult, error) { + token := args.Token + if token == "" { + token = railway.ResolveAPIToken() + } + if token == "" { + return mcp.NewToolResultError("Railway token not configured. Set railway.api_token in ~/.clanker.yaml or export RAILWAY_API_TOKEN"), nil + } + + workspaceID := args.WorkspaceID + if workspaceID == "" { + workspaceID = railway.ResolveWorkspaceID() + } + + client, err := railway.NewClient(token, workspaceID, args.Debug) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to create Railway client: %v", err)), nil + } + + railwayContext, _ := client.GetRelevantContext(ctx, args.Question) + + prompt := buildRailwayPrompt(args.Question, railwayContext, "") + + provider := viper.GetString("ai.default_provider") + if provider == "" { + provider = "openai" + } + apiKey := mcpResolveProviderKey(provider) + + aiClient := ai.NewClient(provider, apiKey, args.Debug, provider) + + response, err := aiClient.AskPrompt(ctx, prompt) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("AI query failed: %v", err)), nil + } + + return mcp.NewToolResultText(response), nil +} + +// handleMCPRailwayList resolves Railway credentials and lists the requested +// resource type using the Railway GraphQL client. +func handleMCPRailwayList(ctx context.Context, args railwayListArgs) (*mcp.CallToolResult, error) { + token := args.Token + if token == "" { + token = railway.ResolveAPIToken() + } + if token == "" { + return mcp.NewToolResultError("Railway token not configured. Set railway.api_token in ~/.clanker.yaml or export RAILWAY_API_TOKEN"), nil + } + + workspaceID := args.WorkspaceID + if workspaceID == "" { + workspaceID = railway.ResolveWorkspaceID() + } + + client, err := railway.NewClient(token, workspaceID, false) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to create Railway client: %v", err)), nil + } + + ctx, cancel := context.WithTimeout(ctx, 60*time.Second) + defer cancel() + + resource := strings.ToLower(strings.TrimSpace(args.Resource)) + + switch resource { + case "projects", "project": + projects, err := client.ListProjects(ctx) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Railway API error: %v", err)), nil + } + return mcp.NewToolResultJSON(projects) + case "services", "service": + if args.Project == "" { + return mcp.NewToolResultError("project is required to list services"), nil + } + services, err := client.ListServices(ctx, args.Project) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Railway API error: %v", err)), nil + } + return mcp.NewToolResultJSON(services) + case "deployments", "deployment": + if args.Project == "" { + return mcp.NewToolResultError("project is required to list deployments"), nil + } + deployments, err := client.ListDeployments(ctx, args.Project, args.Environment, args.Service, 20) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Railway API error: %v", err)), nil + } + return mcp.NewToolResultJSON(deployments) + case "domains", "domain": + if args.Project == "" { + return mcp.NewToolResultError("project is required to list domains"), nil + } + domains, err := client.ListDomains(ctx, args.Project) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Railway API error: %v", err)), nil + } + return mcp.NewToolResultJSON(domains) + case "variables", "variable", "vars", "env": + if args.Project == "" || args.Environment == "" || args.Service == "" { + return mcp.NewToolResultError("project, environment, and service are required to list variables"), nil + } + variables, err := client.ListVariables(ctx, args.Project, args.Environment, args.Service) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Railway API error: %v", err)), nil + } + // Scrub values — never return secrets through MCP. + scrubbed := make(map[string]string, len(variables)) + for k := range variables { + scrubbed[k] = "" + } + return mcp.NewToolResultJSON(scrubbed) + case "volumes", "volume": + if args.Project == "" { + return mcp.NewToolResultError("project is required to list volumes"), nil + } + volumes, err := client.ListVolumes(ctx, args.Project) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Railway API error: %v", err)), nil + } + return mcp.NewToolResultJSON(volumes) + case "workspaces", "workspace": + workspaces, err := client.ListWorkspaces(ctx) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Railway API error: %v", err)), nil + } + return mcp.NewToolResultJSON(workspaces) + default: + return mcp.NewToolResultError(fmt.Sprintf("unknown resource type: %s (expected: projects, services, deployments, domains, variables, volumes, workspaces)", resource)), nil + } +} + // handleMCPVerdaAsk resolves Verda credentials, gathers context, and asks the // configured AI provider about the user's Verda Cloud infrastructure. func handleMCPVerdaAsk(ctx context.Context, args verdaAskArgs) (*mcp.CallToolResult, error) { diff --git a/cmd/root.go b/cmd/root.go index 9145198..55e112f 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -10,6 +10,7 @@ import ( "github.com/bgdnvk/clanker/internal/digitalocean" "github.com/bgdnvk/clanker/internal/gcp" "github.com/bgdnvk/clanker/internal/hetzner" + "github.com/bgdnvk/clanker/internal/railway" "github.com/bgdnvk/clanker/internal/vercel" "github.com/bgdnvk/clanker/internal/verda" "github.com/spf13/cobra" @@ -113,11 +114,17 @@ func init() { hetznerCmd := hetzner.CreateHetznerCommands() rootCmd.AddCommand(hetznerCmd) - // Register Vercel static commands + ask stub (phase 1) + // Register Vercel static commands. Natural-language queries go through + // `clanker ask --vercel "..."` — the canonical path that resolves + // credentials, fetches context, and drives the configured AI provider. vercelCmd := vercel.CreateVercelCommands() - AddVercelAskCommand(vercelCmd) rootCmd.AddCommand(vercelCmd) + // Register Railway static commands. Natural-language queries go through + // `clanker ask --railway "..."`. + railwayCmd := railway.CreateRailwayCommands() + rootCmd.AddCommand(railwayCmd) + // Register Verda Cloud static commands + ask subcommand verdaCmd := verda.CreateVerdaCommands() AddVerdaAskCommand(verdaCmd) diff --git a/cmd/vercel.go b/cmd/vercel.go deleted file mode 100644 index 4419310..0000000 --- a/cmd/vercel.go +++ /dev/null @@ -1,41 +0,0 @@ -package cmd - -import ( - "fmt" - "strings" - - "github.com/spf13/cobra" -) - -// Phase-1 stub. The full conversational Vercel ask-mode (with history and -// tool-use) lands in phase 4; for now one-shot queries are served by the -// main `clanker ask --vercel "..."` flow. Keeping the subcommand registered -// from day one means existing `clanker cf ask` muscle memory carries over -// and docs can reference it without a version gate. -var vercelAskCmd = &cobra.Command{ - Use: "ask [question]", - Short: "Ask natural language questions about your Vercel account (phase 4+)", - Long: `Ask natural language questions about your Vercel account using AI. - -NOTE: conversational history and per-project tool-use arrive in phase 4. -For one-shot queries today, use ` + "`clanker ask --vercel \"...\"`" + ` — it -resolves your Vercel token/team, gathers context, and drives the configured -AI provider. You can also run ` + "`clanker vercel list projects`" + ` for raw -data.`, - Args: cobra.ExactArgs(1), - RunE: runVercelAsk, -} - -// AddVercelAskCommand attaches the ask subcommand to the `vercel` tree. -// Called from root.go after `vercel.CreateVercelCommands()` returns. -func AddVercelAskCommand(vercelCmd *cobra.Command) { - vercelCmd.AddCommand(vercelAskCmd) -} - -func runVercelAsk(cmd *cobra.Command, args []string) error { - question := strings.TrimSpace(args[0]) - if question == "" { - return fmt.Errorf("question cannot be empty") - } - return fmt.Errorf("vercel ask subcommand not yet implemented — use 'clanker ask --vercel %s' instead", question) -} diff --git a/go.mod b/go.mod index a6a833e..03593a7 100644 --- a/go.mod +++ b/go.mod @@ -33,6 +33,7 @@ require ( github.com/spf13/viper v1.18.2 golang.org/x/crypto v0.49.0 golang.org/x/oauth2 v0.36.0 + golang.org/x/sync v0.20.0 golang.org/x/text v0.35.0 google.golang.org/api v0.271.0 google.golang.org/genai v1.19.0 @@ -120,7 +121,6 @@ require ( go.uber.org/multierr v1.9.0 // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect golang.org/x/net v0.52.0 // indirect - golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.42.0 // indirect golang.org/x/time v0.15.0 // indirect google.golang.org/genproto v0.0.0-20260217215200-42d3e9bedb6d // indirect diff --git a/internal/backend/client.go b/internal/backend/client.go index b86b3e7..651f4d3 100644 --- a/internal/backend/client.go +++ b/internal/backend/client.go @@ -515,6 +515,61 @@ func (c *Client) StoreVercelCredentials(ctx context.Context, creds *VercelCreden return nil } +// GetRailwayCredentials retrieves Railway credentials from the backend. The +// clanker backend may return 404 today; callers treat that as "fall back to +// local creds" so behaviour degrades gracefully. +func (c *Client) GetRailwayCredentials(ctx context.Context) (*RailwayCredentials, error) { + respBody, err := c.doRequest(ctx, http.MethodGet, "/api/v1/cli/credentials/railway", nil) + if err != nil { + return nil, err + } + + var response struct { + Success bool `json:"success"` + Data struct { + Provider string `json:"provider"` + Credentials RailwayCredentials `json:"credentials"` + } `json:"data"` + } + + if err := json.Unmarshal(respBody, &response); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + if !response.Success { + return nil, fmt.Errorf("failed to get Railway credentials") + } + + return &response.Data.Credentials, nil +} + +// StoreRailwayCredentials stores Railway credentials in the backend +func (c *Client) StoreRailwayCredentials(ctx context.Context, creds *RailwayCredentials) error { + body := map[string]interface{}{ + "provider": "railway", + "credentials": creds, + } + + respBody, err := c.doRequest(ctx, http.MethodPut, "/api/v1/secrets/railway", body) + if err != nil { + return err + } + + var response APIResponse + if err := json.Unmarshal(respBody, &response); err != nil { + return fmt.Errorf("failed to parse response: %w", err) + } + + if !response.Success { + if response.Error != "" { + return fmt.Errorf("failed to store credentials: %s", response.Error) + } + return fmt.Errorf("failed to store credentials") + } + + return nil +} + // ListCredentials lists all credentials stored in the backend func (c *Client) ListCredentials(ctx context.Context) ([]CredentialEntry, error) { respBody, err := c.doRequest(ctx, http.MethodGet, "/api/v1/cli/credentials", nil) diff --git a/internal/backend/types.go b/internal/backend/types.go index 450d904..9edb51c 100644 --- a/internal/backend/types.go +++ b/internal/backend/types.go @@ -54,6 +54,14 @@ type VercelCredentials struct { TeamID string `json:"team_id,omitempty"` } +// RailwayCredentials represents Railway credentials stored in the backend. +// WorkspaceID is optional — single-workspace accounts can omit it and the +// GraphQL API infers scope from the account token. +type RailwayCredentials struct { + APIToken string `json:"api_token"` + WorkspaceID string `json:"workspace_id,omitempty"` +} + // VerdaCredentials represents Verda Cloud (ex-DataCrunch) credentials stored // in the backend. Verda uses OAuth2 Client Credentials so we need both IDs. // ProjectID is optional. @@ -75,6 +83,7 @@ const ( ProviderHetzner CredentialProvider = "hetzner" ProviderKubernetes CredentialProvider = "kubernetes" ProviderVercel CredentialProvider = "vercel" + ProviderRailway CredentialProvider = "railway" ProviderVerda CredentialProvider = "verda" ) diff --git a/internal/deploy/analyzer.go b/internal/deploy/analyzer.go index 089f88c..f258bc7 100644 --- a/internal/deploy/analyzer.go +++ b/internal/deploy/analyzer.go @@ -760,6 +760,7 @@ func readKeyFiles(dir string) map[string]string { "render.yaml", "Procfile", "vercel.json", + "railway.json", "railway.toml", "netlify.toml", "wrangler.toml", "wrangler.jsonc", "wrangler.json", ".env.example", ".env.sample", ".env.template", diff --git a/internal/maker/exec.go b/internal/maker/exec.go index 01e6a13..006f48e 100644 --- a/internal/maker/exec.go +++ b/internal/maker/exec.go @@ -235,6 +235,10 @@ type ExecOptions struct { VercelAPIToken string VercelTeamID string + // Railway options + RailwayAPIToken string + RailwayWorkspaceID string + // Verda Cloud options (OAuth2 client credentials — both required) VerdaClientID string VerdaClientSecret string diff --git a/internal/maker/exec_railway.go b/internal/maker/exec_railway.go new file mode 100644 index 0000000..da3f52d --- /dev/null +++ b/internal/maker/exec_railway.go @@ -0,0 +1,175 @@ +package maker + +import ( + "bytes" + "context" + "fmt" + "io" + "os" + "os/exec" + "strings" +) + +// ExecuteRailwayPlan executes a Railway infrastructure plan by shelling out +// to the `railway` CLI. The pattern mirrors ExecuteVercelPlan. +func ExecuteRailwayPlan(ctx context.Context, plan *Plan, opts ExecOptions) error { + if plan == nil { + return fmt.Errorf("nil plan") + } + if opts.Writer == nil { + return fmt.Errorf("missing output writer") + } + if opts.RailwayAPIToken == "" { + return fmt.Errorf("missing railway API token") + } + + bindings := make(map[string]string) + + for idx, cmdSpec := range plan.Commands { + args := make([]string, 0, len(cmdSpec.Args)+4) + args = append(args, cmdSpec.Args...) + args = applyPlanBindings(args, bindings) + + if err := validateRailwayCommand(args, opts.Destroyer); err != nil { + return fmt.Errorf("command %d rejected after binding: %w", idx+1, err) + } + + stdinData := cmdSpec.Stdin + if stdinData != "" && !strings.HasSuffix(stdinData, "\n") { + stdinData += "\n" + } + + if hasUnresolvedPlaceholders(args) { + return fmt.Errorf("command %d has unresolved placeholders after substitutions", idx+1) + } + + _, _ = fmt.Fprintf(opts.Writer, "[maker] running %d/%d: %s\n", idx+1, len(plan.Commands), formatRailwayArgsForLog(args)) + + out, runErr := runRailwayCommandStreamingWithStdin(ctx, args, stdinData, opts, opts.Writer) + if runErr != nil { + return fmt.Errorf("railway command %d failed: %w", idx+1, runErr) + } + + learnPlanBindingsFromProduces(cmdSpec.Produces, out, bindings) + } + + return nil +} + +// validateRailwayCommand validates that a command is a legitimate railway CLI +// invocation and not an attempt to escape into other tools. Only commands +// whose first token is literally "railway" are allowed — everything else is +// rejected unconditionally. +func validateRailwayCommand(args []string, allowDestructive bool) error { + if len(args) == 0 || strings.ToLower(strings.TrimSpace(args[0])) != "railway" { + return fmt.Errorf("only railway commands are allowed, got: %q", args) + } + + for _, a := range args { + lower := strings.ToLower(a) + if strings.Contains(lower, ";") || strings.Contains(lower, "|") || strings.Contains(lower, "&&") || strings.Contains(lower, "||") || + strings.ContainsAny(a, "\n\r") || + strings.Contains(a, "`") || strings.Contains(a, "$(") { + return fmt.Errorf("shell operators are not allowed") + } + + if !allowDestructive { + destructiveVerbs := []string{"down", "delete", "remove", "destroy", "rm"} + for _, verb := range destructiveVerbs { + if lower == verb { + return fmt.Errorf("destructive verbs are blocked (use --destroyer to allow)") + } + } + } + } + + return nil +} + +// runRailwayCommandStreamingWithStdin executes a railway CLI command with +// streaming output and optional stdin data. +func runRailwayCommandStreamingWithStdin(ctx context.Context, args []string, stdinData string, opts ExecOptions, w io.Writer) (string, error) { + bin, err := exec.LookPath("railway") + if err != nil { + return "", fmt.Errorf("railway CLI not found in PATH (install from https://docs.railway.com/guides/cli): %w", err) + } + + // Strip "railway" from args if present (the binary name is already the + // executable). + cmdArgs := args + if len(args) > 0 && strings.ToLower(strings.TrimSpace(args[0])) == "railway" { + cmdArgs = args[1:] + } + + cmd := exec.CommandContext(ctx, bin, cmdArgs...) + + // Inject authentication via environment variables. v1 account tokens + // belong to RAILWAY_API_TOKEN; RAILWAY_TOKEN is project-scoped and NOT + // what we want here. + cmd.Env = append(os.Environ(), fmt.Sprintf("RAILWAY_API_TOKEN=%s", opts.RailwayAPIToken)) + if opts.RailwayWorkspaceID != "" { + cmd.Env = append(cmd.Env, fmt.Sprintf("RAILWAY_WORKSPACE_ID=%s", opts.RailwayWorkspaceID)) + } + + if stdinData != "" { + cmd.Stdin = strings.NewReader(stdinData) + } + + var buf bytes.Buffer + mw := io.MultiWriter(w, &buf) + cmd.Stdout = mw + cmd.Stderr = mw + + err = cmd.Run() + out := buf.String() + if err != nil { + return out, err + } + return out, nil +} + +// isRailwayVariableSetCommand returns true if the args represent a `railway +// variable set` command. These commands accept `KEY=VALUE` positional args, +// so we may want to mask the value in logs. +func isRailwayVariableSetCommand(args []string) bool { + if len(args) < 3 { + return false + } + lower := make([]string, 0, 3) + for _, a := range args[:3] { + lower = append(lower, strings.ToLower(strings.TrimSpace(a))) + } + // `railway variable set KEY=VAL` or `railway variables set KEY=VAL` + if lower[0] != "railway" { + return false + } + if lower[1] != "variable" && lower[1] != "variables" && lower[1] != "var" && lower[1] != "vars" { + return false + } + return lower[2] == "set" +} + +// formatRailwayArgsForLog formats command args for logging, masking secret +// values so they don't appear in plan logs. +func formatRailwayArgsForLog(args []string) string { + if len(args) == 0 { + return "" + } + + masked := make([]string, len(args)) + copy(masked, args) + + // `railway variable set KEY=VAL` -> mask the value portion of KEY=VAL. + if isRailwayVariableSetCommand(masked) && len(masked) >= 4 { + for i := 3; i < len(masked); i++ { + if k, _, ok := strings.Cut(masked[i], "="); ok && k != "" { + masked[i] = k + "=***" + } + } + } + + if strings.ToLower(strings.TrimSpace(masked[0])) == "railway" { + return strings.Join(masked, " ") + } + return "railway " + strings.Join(masked, " ") +} diff --git a/internal/maker/plan.go b/internal/maker/plan.go index 1b7dc50..e09094d 100644 --- a/internal/maker/plan.go +++ b/internal/maker/plan.go @@ -162,6 +162,8 @@ func inferProviderFromCommands(cmds []Command) string { switch first { case "vercel": return "vercel" + case "railway": + return "railway" case "wrangler": return "cloudflare" case "hcloud": diff --git a/internal/maker/railway_prompts.go b/internal/maker/railway_prompts.go new file mode 100644 index 0000000..36d0ae7 --- /dev/null +++ b/internal/maker/railway_prompts.go @@ -0,0 +1,177 @@ +package maker + +import "fmt" + +// RailwayPlanPrompt returns a system prompt instructing the LLM to produce a +// Railway CLI execution plan for the given user question. +func RailwayPlanPrompt(question string) string { + return RailwayPlanPromptWithMode(question, false) +} + +// RailwayPlanPromptWithMode returns a Railway plan prompt with optional +// destructive-operation support (destroyer mode). +func RailwayPlanPromptWithMode(question string, destroyer bool) string { + destructiveRule := "- Avoid any destructive operations (down/delete/remove/destroy/rm)." + if destroyer { + destructiveRule = "- Destructive operations are allowed ONLY if the user explicitly asked for deletion." + } + + return fmt.Sprintf(`You are an infrastructure maker planner for Railway. + +Your job: produce a concrete, minimal Railway CLI execution plan to satisfy the user request. + +Constraints: +- Output ONLY valid JSON. +- Use this schema exactly: +{ + "version": 1, + "createdAt": "RFC3339 timestamp", + "provider": "railway", + "question": "original user question", + "summary": "short summary of what will be created/changed", + "commands": [ + { + "args": ["railway", "subcommand", "arg1", "..."], + "reason": "why this command is needed", + "produces": { + "OPTIONAL_BINDING_NAME": "$.json.path.to.value" + } + } + ], + "notes": ["optional notes"] +} + +Rules for commands: +- The "commands" array MUST contain at least 1 command. +- Provide args as an array; do NOT provide a single string. +- Commands MUST be railway CLI only. Every command args MUST start with "railway". +- Do NOT include any non-railway programs (no aws/gcloud/az/python/node/bash/curl/terraform/npm/doctl/hcloud/wrangler/etc). +- Do NOT include shell operators, pipes, redirects, or subshells. +- Prefer idempotent operations where possible. +- If the user request is ambiguous or missing required details, output a DISCOVERY-ONLY plan: + - Still output a NON-EMPTY commands array. + - Use READ-ONLY commands to gather missing inputs (examples: ["railway", "list"], ["railway", "status"], ["railway", "variable", "ls"]). + +%s + +Placeholders and bindings: +- You MAY use placeholder tokens like "", "", "", "". +- If you use ANY placeholder, ensure an earlier command includes "produces" mapping that populates it from the preceding command output. + +Authentication context: +- The executor injects RAILWAY_API_TOKEN automatically. Do NOT emit login / token commands. +- Use -s/--service to target a service, -e/--environment to select an environment. + +Common Railway operations: + +Deploy current directory to a service: +{ + "args": ["railway", "up", "--service", "", "--environment", ""], + "reason": "Deploy current working directory to the target service+environment" +} + +Deploy in detached mode (return immediately): +{ + "args": ["railway", "up", "--detach", "--service", ""], + "reason": "Kick off a deploy without blocking on the build" +} + +Redeploy an existing deployment: +{ + "args": ["railway", "redeploy", "", "-y"], + "reason": "Redeploy the referenced deployment" +} + +Cancel an in-progress deployment: +{ + "args": ["railway", "down", ""], + "reason": "Cancel an in-progress deployment (requires --destroyer)" +} + +Link the working directory to a project: +{ + "args": ["railway", "link", ""], + "reason": "Associate the local dir with a Railway project before running deploy/variable commands" +} + +Set a service environment variable: +{ + "args": ["railway", "variable", "set", "DATABASE_URL=postgres://user:pass@host/db", "--service", "", "--environment", ""], + "reason": "Set DATABASE_URL for the target service+environment" +} + +Remove an environment variable: +{ + "args": ["railway", "variable", "delete", "DATABASE_URL", "--service", "", "--environment", ""], + "reason": "Remove DATABASE_URL from the target service+environment" +} + +List environment variables: +{ + "args": ["railway", "variable", "ls", "--service", "", "--environment", ""], + "reason": "List variables scoped to the target service+environment" +} + +Add a custom domain: +{ + "args": ["railway", "domain", "example.com", "--service", "", "--environment", ""], + "reason": "Attach example.com to the target service" +} + +List domains: +{ + "args": ["railway", "domain"], + "reason": "Print attached domains for the linked service" +} + +Create a new environment: +{ + "args": ["railway", "environment", "new", "staging"], + "reason": "Create a staging environment inside the linked project" +} + +Remove an environment (destructive — requires --destroyer): +{ + "args": ["railway", "environment", "delete", "staging", "--yes"], + "reason": "Delete the staging environment" +} + +List environments: +{ + "args": ["railway", "environment"], + "reason": "List environments in the linked project" +} + +List projects / services / deployments (discovery): +{ + "args": ["railway", "list", "--json"], + "reason": "Enumerate all projects, services, and deployments as JSON for the executor to parse" +} + +Fetch recent runtime logs: +{ + "args": ["railway", "logs", "-n", "100"], + "reason": "Print the 100 most recent runtime log lines for the linked service" +} + +Fetch build logs: +{ + "args": ["railway", "logs", "--build", "-n", "100"], + "reason": "Print build-phase logs for the most recent deployment" +} + +Attach a managed database plugin (Postgres/Redis/MySQL): +{ + "args": ["railway", "add", "--database", "postgres"], + "reason": "Provision a managed Postgres plugin on the linked project" +} + +List persistent volumes: +{ + "args": ["railway", "volume", "list"], + "reason": "Enumerate volumes attached to services in the linked project" +} + +User request: +%q`, destructiveRule, question) +} diff --git a/internal/railway/client.go b/internal/railway/client.go new file mode 100644 index 0000000..86389ff --- /dev/null +++ b/internal/railway/client.go @@ -0,0 +1,1031 @@ +// Package railway provides a client for the Railway GraphQL API and the +// official `railway` CLI. Mirrors the shape of the Vercel package so wiring +// into cmd/, routing, ask-mode and the desktop backend stays uniform. +// +// Railway exposes a single GraphQL endpoint (Backboard v2) for both queries +// and mutations. Auth is a bearer account token; workspace scoping is passed +// either as a query variable or via `RAILWAY_WORKSPACE_ID`. +package railway + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "strconv" + "strings" + "time" + + "github.com/spf13/viper" + "golang.org/x/sync/errgroup" +) + +// graphqlEndpoint is the Railway public GraphQL (Backboard) URL. +const graphqlEndpoint = "https://backboard.railway.com/graphql/v2" + +// Client wraps the Railway GraphQL API and the official `railway` CLI. +type Client struct { + apiToken string + workspaceID string + debug bool + // raw, when set, causes static CLI commands to print unformatted JSON + // responses instead of pretty-printed summaries. + raw bool +} + +// ResolveAPIToken returns the Railway API token from config or environment. +// Resolution order: `railway.api_token` → RAILWAY_API_TOKEN. +// +// Note: RAILWAY_TOKEN is intentionally NOT checked — that variable is a +// project-scoped deploy token used by the Railway CLI and cannot be used +// against the public GraphQL API as a Bearer token. +func ResolveAPIToken() string { + if t := strings.TrimSpace(viper.GetString("railway.api_token")); t != "" { + return t + } + if env := strings.TrimSpace(os.Getenv("RAILWAY_API_TOKEN")); env != "" { + return env + } + return "" +} + +// ResolveWorkspaceID returns the Railway workspace (team) ID from config or env. +// Resolution order: `railway.workspace_id` → RAILWAY_WORKSPACE_ID. +// Workspace scoping is optional — personal accounts have no workspace ID. +func ResolveWorkspaceID() string { + if t := strings.TrimSpace(viper.GetString("railway.workspace_id")); t != "" { + return t + } + if env := strings.TrimSpace(os.Getenv("RAILWAY_WORKSPACE_ID")); env != "" { + return env + } + return "" +} + +// NewClient creates a new Railway client. +func NewClient(apiToken, workspaceID string, debug bool) (*Client, error) { + if strings.TrimSpace(apiToken) == "" { + return nil, fmt.Errorf("railway api_token is required") + } + return &Client{ + apiToken: apiToken, + workspaceID: workspaceID, + debug: debug, + }, nil +} + +// BackendRailwayCredentials represents Railway credentials retrieved from +// the backend credential store (clanker-backend). +type BackendRailwayCredentials struct { + APIToken string + WorkspaceID string +} + +// NewClientWithCredentials creates a new Railway client using backend +// credentials. +func NewClientWithCredentials(creds *BackendRailwayCredentials, debug bool) (*Client, error) { + if creds == nil { + return nil, fmt.Errorf("credentials cannot be nil") + } + if strings.TrimSpace(creds.APIToken) == "" { + return nil, fmt.Errorf("railway api_token is required") + } + return &Client{ + apiToken: creds.APIToken, + workspaceID: creds.WorkspaceID, + debug: debug, + }, nil +} + +// GetAPIToken returns the API token. +func (c *Client) GetAPIToken() string { return c.apiToken } + +// GetWorkspaceID returns the workspace ID (may be empty for personal accounts). +func (c *Client) GetWorkspaceID() string { return c.workspaceID } + +// gqlRequest is the standard GraphQL request envelope. +type gqlRequest struct { + Query string `json:"query"` + Variables map[string]any `json:"variables,omitempty"` +} + +// gqlError is the standard GraphQL error entry. +type gqlError struct { + Message string `json:"message"` + Path []any `json:"path,omitempty"` + Extensions map[string]any `json:"extensions,omitempty"` +} + +// gqlResponse is the envelope Railway returns for every GraphQL call. +type gqlResponse struct { + Data json.RawMessage `json:"data,omitempty"` + Errors []gqlError `json:"errors,omitempty"` +} + +// RunGQL executes a GraphQL query/mutation and decodes the `data` field +// into out. Returns an error for transport failures, HTTP non-2xx, or any +// non-empty `errors[]` array in the response body. +// +// The call is made via `curl` to stay consistent with the rest of the code +// base (matches the Vercel client pattern). Three attempts with exponential +// backoff; honours Retry-After when present. +func (c *Client) RunGQL(ctx context.Context, query string, vars map[string]any, out any) error { + if _, err := exec.LookPath("curl"); err != nil { + return fmt.Errorf("curl not found in PATH") + } + if strings.TrimSpace(query) == "" { + return fmt.Errorf("railway: empty graphql query") + } + + reqBody := gqlRequest{Query: query, Variables: vars} + bodyBytes, err := json.Marshal(reqBody) + if err != nil { + return fmt.Errorf("failed to marshal graphql request: %w", err) + } + + // backoffs has three entries, meaning we make up to 3 total attempts + // (the initial try + 2 retries) before giving up — matches the Vercel + // client's retry budget. + backoffs := []time.Duration{200 * time.Millisecond, 500 * time.Millisecond, 1200 * time.Millisecond} + var lastErr error + var lastStderr string + var lastBody string + + for attempt := 0; attempt < len(backoffs); attempt++ { + args := []string{ + "-s", + "-w", "\n%{http_code}\n%header{Retry-After}", + "-X", "POST", + graphqlEndpoint, + "-H", "Content-Type: application/json", + "-H", fmt.Sprintf("Authorization: Bearer %s", c.apiToken), + "--data-binary", "@-", + } + + if c.debug { + // Do not log the bearer token. + fmt.Printf("[railway] POST %s (query=%d bytes, vars=%v)\n", graphqlEndpoint, len(query), redactVars(vars)) + } + + cmd := exec.CommandContext(ctx, "curl", args...) + cmd.Stdin = bytes.NewReader(bodyBytes) + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + runErr := cmd.Run() + if runErr != nil { + lastErr = runErr + lastStderr = strings.TrimSpace(stderr.String()) + if ctx.Err() != nil { + break + } + if !isRetryableError(lastStderr) { + break + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(backoffs[attempt]): + } + continue + } + + raw := stdout.String() + body, status, retryAfter := splitCurlStatus(raw) + lastBody = body + + if status < 200 || status >= 300 { + // Retry on 429 / 5xx; honour Retry-After when present. + if (status == 429 || status >= 500) && attempt < len(backoffs)-1 { + wait := backoffs[attempt] + if retryAfter != "" { + if secs, err := strconv.Atoi(retryAfter); err == nil && secs > 0 { + wait = time.Duration(secs) * time.Second + } + } + if c.debug { + fmt.Printf("[railway] http %d (retry in %s)\n", status, wait) + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(wait): + } + continue + } + return fmt.Errorf("railway graphql http %d: %s%s", status, truncateForError(body), errorHint(body)) + } + + var envelope gqlResponse + if err := json.Unmarshal([]byte(body), &envelope); err != nil { + return fmt.Errorf("railway graphql: failed to parse response: %w (body: %s)", err, truncateForError(body)) + } + + if len(envelope.Errors) > 0 { + msgs := make([]string, 0, len(envelope.Errors)) + retryable := false + for _, e := range envelope.Errors { + msgs = append(msgs, e.Message) + if isRetryableError(e.Message) { + retryable = true + } + } + combined := strings.Join(msgs, "; ") + if retryable && attempt < len(backoffs)-1 { + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(backoffs[attempt]): + } + continue + } + return fmt.Errorf("railway graphql errors: %s%s", combined, errorHint(combined)) + } + + if out == nil { + return nil + } + if len(envelope.Data) == 0 { + return fmt.Errorf("railway graphql: empty data envelope") + } + if err := json.Unmarshal(envelope.Data, out); err != nil { + return fmt.Errorf("railway graphql: decode data: %w", err) + } + return nil + } + + if lastErr == nil && lastBody != "" { + return fmt.Errorf("railway graphql call failed after retries: %s", truncateForError(lastBody)) + } + if lastErr == nil { + return fmt.Errorf("railway graphql call failed") + } + return fmt.Errorf("railway graphql call failed: %w, stderr: %s%s", lastErr, lastStderr, errorHint(lastStderr)) +} + +// RunRailwayCLI executes the `railway` CLI, injecting the API token via env. +// Used for operations the GraphQL API does not expose directly (e.g. `railway +// up`). Returns combined stdout on success. +func (c *Client) RunRailwayCLI(ctx context.Context, args ...string) (string, error) { + return c.RunRailwayCLIWithStdin(ctx, "", args...) +} + +// RunRailwayCLIWithStdin executes the `railway` CLI piping stdin to the +// subprocess. Used for commands like `railway variable set` that accept +// multi-line input. +func (c *Client) RunRailwayCLIWithStdin(ctx context.Context, stdinData string, args ...string) (string, error) { + if _, err := exec.LookPath("railway"); err != nil { + return "", fmt.Errorf("railway CLI not found in PATH (install from https://docs.railway.com/guides/cli)") + } + + cmd := exec.CommandContext(ctx, "railway", args...) + cmd.Env = append(os.Environ(), fmt.Sprintf("RAILWAY_API_TOKEN=%s", c.apiToken)) + if c.workspaceID != "" { + cmd.Env = append(cmd.Env, fmt.Sprintf("RAILWAY_WORKSPACE_ID=%s", c.workspaceID)) + } + + if stdinData != "" { + cmd.Stdin = strings.NewReader(stdinData) + } + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if c.debug { + if stdinData != "" { + fmt.Printf("[railway] railway %s (stdin piped)\n", strings.Join(args, " ")) + } else { + fmt.Printf("[railway] railway %s\n", strings.Join(args, " ")) + } + } + + if err := cmd.Run(); err != nil { + stderrStr := strings.TrimSpace(stderr.String()) + return stdout.String(), fmt.Errorf("railway CLI failed: %w, stderr: %s%s", err, stderrStr, errorHint(stderrStr)) + } + + return stdout.String(), nil +} + +// --- Typed GraphQL wrappers --- + +// GetUser returns the authenticated Railway user. +func (c *Client) GetUser(ctx context.Context) (*User, error) { + var out struct { + Me User `json:"me"` + } + q := `query Me { me { id name email } }` + if err := c.RunGQL(ctx, q, nil, &out); err != nil { + return nil, err + } + return &out.Me, nil +} + +// ListWorkspaces returns all workspaces visible to the caller. v1 public API +// exposes a limited surface so we fall back to the `me.workspaces` sub-field +// when the top-level `workspaces` query is unavailable. +func (c *Client) ListWorkspaces(ctx context.Context) ([]Workspace, error) { + var out struct { + Me struct { + Workspaces []Workspace `json:"workspaces"` + } `json:"me"` + } + q := `query Workspaces { me { workspaces { id name slug } } }` + if err := c.RunGQL(ctx, q, nil, &out); err != nil { + return nil, err + } + return out.Me.Workspaces, nil +} + +// ListProjects returns all projects accessible to the current token, scoped +// to the workspace when one is configured. +func (c *Client) ListProjects(ctx context.Context) ([]Project, error) { + var out struct { + Projects struct { + Edges []struct { + Node Project `json:"node"` + } `json:"edges"` + } `json:"projects"` + } + q := `query Projects { projects { edges { node { id name description teamId createdAt updatedAt } } } }` + if err := c.RunGQL(ctx, q, nil, &out); err != nil { + return nil, err + } + projects := make([]Project, 0, len(out.Projects.Edges)) + for _, e := range out.Projects.Edges { + projects = append(projects, e.Node) + } + return projects, nil +} + +// GetProject returns a single project (with environments and services) by ID. +func (c *Client) GetProject(ctx context.Context, projectID string) (*Project, error) { + q := `query Project($id: String!) { + project(id: $id) { + id name description teamId createdAt updatedAt + environments { edges { node { id name projectId createdAt updatedAt } } } + services { edges { node { id name projectId createdAt updatedAt } } } + } + }` + var out struct { + Project struct { + Project + Environments struct { + Edges []struct { + Node Environment `json:"node"` + } `json:"edges"` + } `json:"environments"` + Services struct { + Edges []struct { + Node Service `json:"node"` + } `json:"edges"` + } `json:"services"` + } `json:"project"` + } + if err := c.RunGQL(ctx, q, map[string]any{"id": projectID}, &out); err != nil { + return nil, err + } + proj := out.Project.Project + for _, e := range out.Project.Environments.Edges { + proj.Environments = append(proj.Environments, e.Node) + } + for _, e := range out.Project.Services.Edges { + proj.Services = append(proj.Services, e.Node) + } + return &proj, nil +} + +// ListServices returns services for a given project. +func (c *Client) ListServices(ctx context.Context, projectID string) ([]Service, error) { + q := `query Services($projectId: String!) { + project(id: $projectId) { services { edges { node { id name projectId createdAt updatedAt } } } } + }` + var out struct { + Project struct { + Services struct { + Edges []struct { + Node Service `json:"node"` + } `json:"edges"` + } `json:"services"` + } `json:"project"` + } + if err := c.RunGQL(ctx, q, map[string]any{"projectId": projectID}, &out); err != nil { + return nil, err + } + services := make([]Service, 0, len(out.Project.Services.Edges)) + for _, e := range out.Project.Services.Edges { + services = append(services, e.Node) + } + return services, nil +} + +// ListDeployments returns deployments matching the given filters. Any of the +// filter strings may be empty. +func (c *Client) ListDeployments(ctx context.Context, projectID, environmentID, serviceID string, limit int) ([]Deployment, error) { + if limit <= 0 { + limit = 20 + } + q := `query Deployments($input: DeploymentListInput!, $first: Int) { + deployments(input: $input, first: $first) { + edges { node { + id status url staticUrl projectId serviceId environmentId createdAt updatedAt + canRedeploy canRollback + meta { commitHash commitMessage branch } + } } + } + }` + input := map[string]any{} + if projectID != "" { + input["projectId"] = projectID + } + if environmentID != "" { + input["environmentId"] = environmentID + } + if serviceID != "" { + input["serviceId"] = serviceID + } + var out struct { + Deployments struct { + Edges []struct { + Node Deployment `json:"node"` + } `json:"edges"` + } `json:"deployments"` + } + if err := c.RunGQL(ctx, q, map[string]any{"input": input, "first": limit}, &out); err != nil { + return nil, err + } + deployments := make([]Deployment, 0, len(out.Deployments.Edges)) + for _, e := range out.Deployments.Edges { + deployments = append(deployments, e.Node) + } + return deployments, nil +} + +// GetDeployment fetches a single deployment by ID. +func (c *Client) GetDeployment(ctx context.Context, deploymentID string) (*Deployment, error) { + q := `query Deployment($id: String!) { + deployment(id: $id) { + id status url staticUrl projectId serviceId environmentId createdAt updatedAt + canRedeploy canRollback + meta { commitHash commitMessage branch } + } + }` + var out struct { + Deployment Deployment `json:"deployment"` + } + if err := c.RunGQL(ctx, q, map[string]any{"id": deploymentID}, &out); err != nil { + return nil, err + } + return &out.Deployment, nil +} + +// CancelDeployment cancels an in-progress deployment. +func (c *Client) CancelDeployment(ctx context.Context, deploymentID string) error { + q := `mutation DeploymentCancel($id: String!) { deploymentCancel(id: $id) }` + return c.RunGQL(ctx, q, map[string]any{"id": deploymentID}, nil) +} + +// RedeployDeployment triggers a redeploy of an existing deployment. +func (c *Client) RedeployDeployment(ctx context.Context, deploymentID string) error { + q := `mutation DeploymentRedeploy($id: String!) { deploymentRedeploy(id: $id) { id } }` + return c.RunGQL(ctx, q, map[string]any{"id": deploymentID}, nil) +} + +// ListDomains returns service + custom domains for a project. Service +// domains (xxx.up.railway.app) and custom domains are returned in a single +// slice; IsCustom distinguishes them. +func (c *Client) ListDomains(ctx context.Context, projectID string) ([]Domain, error) { + q := `query Domains($projectId: String!) { + domains(projectId: $projectId) { + customDomains { id domain serviceId environmentId projectId status targetPort createdAt } + serviceDomains { id domain serviceId environmentId projectId targetPort createdAt } + } + }` + var out struct { + Domains struct { + CustomDomains []Domain `json:"customDomains"` + ServiceDomains []Domain `json:"serviceDomains"` + } `json:"domains"` + } + if err := c.RunGQL(ctx, q, map[string]any{"projectId": projectID}, &out); err != nil { + return nil, err + } + domains := make([]Domain, 0, len(out.Domains.CustomDomains)+len(out.Domains.ServiceDomains)) + for i := range out.Domains.CustomDomains { + out.Domains.CustomDomains[i].IsCustom = true + domains = append(domains, out.Domains.CustomDomains[i]) + } + for i := range out.Domains.ServiceDomains { + out.Domains.ServiceDomains[i].IsCustom = false + domains = append(domains, out.Domains.ServiceDomains[i]) + } + return domains, nil +} + +// ListVariables returns environment variables for a service+environment. +// Values are never returned unless the caller has explicitly elevated scope. +func (c *Client) ListVariables(ctx context.Context, projectID, environmentID, serviceID string) (map[string]string, error) { + q := `query Variables($projectId: String!, $environmentId: String!, $serviceId: String) { + variables(projectId: $projectId, environmentId: $environmentId, serviceId: $serviceId) + }` + vars := map[string]any{ + "projectId": projectID, + "environmentId": environmentID, + } + if serviceID != "" { + vars["serviceId"] = serviceID + } + var out struct { + Variables map[string]string `json:"variables"` + } + if err := c.RunGQL(ctx, q, vars, &out); err != nil { + return nil, err + } + if out.Variables == nil { + out.Variables = map[string]string{} + } + return out.Variables, nil +} + +// UpsertVariable creates or updates an environment variable. +func (c *Client) UpsertVariable(ctx context.Context, projectID, environmentID, serviceID, name, value string) error { + q := `mutation VariableUpsert($input: VariableUpsertInput!) { variableUpsert(input: $input) }` + input := map[string]any{ + "projectId": projectID, + "environmentId": environmentID, + "name": name, + "value": value, + } + if serviceID != "" { + input["serviceId"] = serviceID + } + return c.RunGQL(ctx, q, map[string]any{"input": input}, nil) +} + +// DeleteVariable removes a variable. +func (c *Client) DeleteVariable(ctx context.Context, projectID, environmentID, serviceID, name string) error { + q := `mutation VariableDelete($input: VariableDeleteInput!) { variableDelete(input: $input) }` + input := map[string]any{ + "projectId": projectID, + "environmentId": environmentID, + "name": name, + } + if serviceID != "" { + input["serviceId"] = serviceID + } + return c.RunGQL(ctx, q, map[string]any{"input": input}, nil) +} + +// ListVolumes returns volumes for a project. +func (c *Client) ListVolumes(ctx context.Context, projectID string) ([]Volume, error) { + q := `query Volumes($projectId: String!) { + project(id: $projectId) { + volumes { edges { node { id name projectId createdAt } } } + } + }` + var out struct { + Project struct { + Volumes struct { + Edges []struct { + Node Volume `json:"node"` + } `json:"edges"` + } `json:"volumes"` + } `json:"project"` + } + if err := c.RunGQL(ctx, q, map[string]any{"projectId": projectID}, &out); err != nil { + return nil, err + } + volumes := make([]Volume, 0, len(out.Project.Volumes.Edges)) + for _, e := range out.Project.Volumes.Edges { + volumes = append(volumes, e.Node) + } + return volumes, nil +} + +// GetUsage returns a workspace-level usage summary. If no workspace ID is +// provided, the client's configured workspace is used. +func (c *Client) GetUsage(ctx context.Context, workspaceID string) (*UsageSummary, error) { + wid := strings.TrimSpace(workspaceID) + if wid == "" { + wid = c.workspaceID + } + if wid == "" { + return nil, fmt.Errorf("railway: workspace_id required for usage query") + } + q := `query Usage($workspaceId: String!) { usage(workspaceId: $workspaceId) }` + var out struct { + Usage json.RawMessage `json:"usage"` + } + if err := c.RunGQL(ctx, q, map[string]any{"workspaceId": wid}, &out); err != nil { + return nil, err + } + var summary UsageSummary + if len(out.Usage) > 0 { + // Railway returns a JSON scalar here; surface decode failures so + // callers can distinguish "no data" from "bad payload". + if err := json.Unmarshal(out.Usage, &summary); err != nil { + return nil, fmt.Errorf("decode usage payload: %w", err) + } + } + return &summary, nil +} + +// ListDeploymentLogs retrieves build or runtime logs for a deployment. +// buildLogs=true returns build output; false returns runtime logs. +func (c *Client) ListDeploymentLogs(ctx context.Context, deploymentID string, buildLogs bool, limit int) ([]map[string]any, error) { + if limit <= 0 { + limit = 100 + } + field := "deploymentLogs" + if buildLogs { + field = "buildLogs" + } + q := fmt.Sprintf(`query Logs($id: String!, $limit: Int) { %s(deploymentId: $id, limit: $limit) { timestamp message severity } }`, field) + var raw map[string]json.RawMessage + if err := c.RunGQL(ctx, q, map[string]any{"id": deploymentID, "limit": limit}, &raw); err != nil { + return nil, err + } + body, ok := raw[field] + if !ok || len(body) == 0 { + return nil, nil + } + var entries []map[string]any + if err := json.Unmarshal(body, &entries); err != nil { + return nil, fmt.Errorf("failed to decode %s: %w", field, err) + } + return entries, nil +} + +// GetRelevantContext gathers Railway context for LLM queries. The output is +// a best-effort dump of the resources most likely relevant to the user's +// question. Sections are keyword-gated to keep the context compact. +// +// Fan-out across projects is capped with errgroup so we don't hammer the +// Railway API with N parallel round-trips on large workspaces. +func (c *Client) GetRelevantContext(ctx context.Context, question string) (string, error) { + questionLower := strings.ToLower(strings.TrimSpace(question)) + + // Projects are cheap and many sections need them; fetch once and share. + var ( + cachedProjects []Project + cachedProjectsErr error + cachedProjectsOK bool + ) + getProjects := func() ([]Project, error) { + if cachedProjectsOK || cachedProjectsErr != nil { + return cachedProjects, cachedProjectsErr + } + cachedProjects, cachedProjectsErr = c.ListProjects(ctx) + cachedProjectsOK = cachedProjectsErr == nil + return cachedProjects, cachedProjectsErr + } + + type section struct { + name string + keys []string + run func() (string, error) + } + + sections := []section{ + { + name: "Projects", + keys: []string{"project", "railway", "deploy", "service", "app", "nixpacks", "environment"}, + run: func() (string, error) { + projects, err := getProjects() + if err != nil { + return "", err + } + return jsonPretty(projects) + }, + }, + { + name: "Services", + keys: []string{"service", "container", "worker", "web", "api", "backend"}, + run: func() (string, error) { + projects, err := getProjects() + if err != nil { + return "", err + } + results := make([]string, len(projects)) + g, gctx := errgroup.WithContext(ctx) + g.SetLimit(3) + for i, p := range projects { + i, p := i, p + g.Go(func() error { + services, err := c.ListServices(gctx, p.ID) + if err != nil { + return err + } + if len(services) == 0 { + return nil + } + var sb strings.Builder + sb.WriteString(fmt.Sprintf("Project %s (%s):\n", p.Name, p.ID)) + for _, s := range services { + sb.WriteString(fmt.Sprintf(" - %s (%s)\n", s.Name, s.ID)) + } + results[i] = sb.String() + return nil + }) + } + if err := g.Wait(); err != nil { + return "", err + } + return strings.Join(filterEmpty(results), ""), nil + }, + }, + { + name: "Deployments", + keys: []string{"deployment", "deploy", "preview", "production", "build", "rollback", "redeploy", "logs"}, + run: func() (string, error) { + deployments, err := c.ListDeployments(ctx, "", "", "", 20) + if err != nil { + return "", err + } + return jsonPretty(deployments) + }, + }, + { + name: "Domains", + keys: []string{"domain", "dns", "custom domain", "up.railway.app"}, + run: func() (string, error) { + projects, err := getProjects() + if err != nil { + return "", err + } + results := make([]string, len(projects)) + g, gctx := errgroup.WithContext(ctx) + g.SetLimit(3) + for i, p := range projects { + i, p := i, p + g.Go(func() error { + domains, err := c.ListDomains(gctx, p.ID) + if err != nil { + // non-fatal; skip projects we can't enumerate + return nil + } + if len(domains) == 0 { + return nil + } + var sb strings.Builder + sb.WriteString(fmt.Sprintf("Project %s:\n", p.Name)) + for _, d := range domains { + kind := "service" + if d.IsCustom { + kind = "custom" + } + sb.WriteString(fmt.Sprintf(" - %s [%s]\n", d.Domain, kind)) + } + results[i] = sb.String() + return nil + }) + } + if err := g.Wait(); err != nil { + return "", err + } + return strings.Join(filterEmpty(results), ""), nil + }, + }, + { + name: "Variables", + keys: []string{"variable", "env", "environment variable", "secret", "config"}, + run: func() (string, error) { + // Variables require project+environment+service scope, so we + // still need GetProject here (ListProjects doesn't return + // environments). Cap parallelism to avoid N round-trips. + projects, err := getProjects() + if err != nil || len(projects) == 0 { + return "", err + } + results := make([]string, len(projects)) + g, gctx := errgroup.WithContext(ctx) + g.SetLimit(3) + for i, p := range projects { + i, p := i, p + g.Go(func() error { + proj, err := c.GetProject(gctx, p.ID) + if err != nil { + return nil + } + if len(proj.Environments) == 0 { + return nil + } + envID := proj.Environments[0].ID + envName := proj.Environments[0].Name + var sb strings.Builder + for _, svc := range proj.Services { + keys, err := c.ListVariables(gctx, p.ID, envID, svc.ID) + if err != nil { + continue + } + if len(keys) == 0 { + continue + } + sb.WriteString(fmt.Sprintf("Project %s / env %s / service %s:\n", p.Name, envName, svc.Name)) + for k := range keys { + sb.WriteString(fmt.Sprintf(" - %s\n", k)) + } + } + results[i] = sb.String() + return nil + }) + } + if err := g.Wait(); err != nil { + return "", err + } + return strings.Join(filterEmpty(results), ""), nil + }, + }, + { + name: "Usage", + keys: []string{"usage", "cost", "billing", "cpu", "memory", "bandwidth", "egress"}, + run: func() (string, error) { + if c.workspaceID == "" { + return "", fmt.Errorf("workspace_id required for usage") + } + usage, err := c.GetUsage(ctx, c.workspaceID) + if err != nil { + return "", err + } + return jsonPretty(usage) + }, + }, + } + + defaults := map[string]bool{ + "Projects": true, + } + + var out strings.Builder + var warnings []string + + for _, s := range sections { + if questionLower != "" && len(s.keys) > 0 { + matched := false + for _, key := range s.keys { + if strings.Contains(questionLower, key) { + matched = true + break + } + } + if !matched && !defaults[s.name] { + continue + } + } + + body, err := s.run() + if err != nil { + warnings = append(warnings, fmt.Sprintf("%s: %v", s.name, err)) + continue + } + if strings.TrimSpace(body) == "" { + continue + } + out.WriteString(s.name + ":\n") + out.WriteString(body) + if !strings.HasSuffix(body, "\n") { + out.WriteString("\n") + } + out.WriteString("\n") + } + + if len(warnings) > 0 { + out.WriteString("Railway Warnings:\n") + for i, w := range warnings { + if i >= 8 { + out.WriteString("- (additional warnings omitted)\n") + break + } + out.WriteString("- ") + out.WriteString(w) + out.WriteString("\n") + } + out.WriteString("\n") + } + + if strings.TrimSpace(out.String()) == "" { + return "No Railway data available (missing permissions or no resources).", nil + } + return out.String(), nil +} + +// --- helpers --- + +func jsonPretty(v any) (string, error) { + b, err := json.MarshalIndent(v, "", " ") + if err != nil { + return "", err + } + return string(b), nil +} + +// splitCurlStatus parses the stdout produced by our curl invocation, which +// appends the HTTP status and Retry-After header via `-w`. Returns the body, +// the parsed status code, and the Retry-After header (if any). +func splitCurlStatus(raw string) (body string, status int, retryAfter string) { + lines := strings.Split(raw, "\n") + if len(lines) < 2 { + return strings.TrimSpace(raw), 0, "" + } + // Last line: Retry-After (may be empty). Second-to-last: status code. + // Everything before that is the response body. + retryAfter = strings.TrimSpace(lines[len(lines)-1]) + statusLine := strings.TrimSpace(lines[len(lines)-2]) + bodyLines := lines[:len(lines)-2] + body = strings.Join(bodyLines, "\n") + if code, err := strconv.Atoi(statusLine); err == nil { + status = code + } + return strings.TrimSpace(body), status, retryAfter +} + +// redactVars returns a version of vars safe to log (redacts values for +// anything that looks like a secret or a long string). +func redactVars(vars map[string]any) map[string]any { + if vars == nil { + return nil + } + out := make(map[string]any, len(vars)) + for k, v := range vars { + ks := strings.ToLower(k) + if ks == "value" || strings.Contains(ks, "token") || strings.Contains(ks, "secret") || strings.Contains(ks, "password") { + out[k] = "***" + continue + } + out[k] = v + } + return out +} + +func truncateForError(body string) string { + const maxLen = 600 + trimmed := strings.TrimSpace(body) + if len(trimmed) > maxLen { + return trimmed[:maxLen] + "..." + } + return trimmed +} + +// isRetryableError determines whether to retry a Railway API failure. +func isRetryableError(s string) bool { + lower := strings.ToLower(s) + retryableCodes := []string{ + "rate limit", + "rate_limited", + "too many requests", + "internal server error", + "bad gateway", + "service unavailable", + "gateway timeout", + "try again", + } + for _, code := range retryableCodes { + if strings.Contains(lower, code) { + return true + } + } + if strings.Contains(lower, "timeout") || strings.Contains(lower, "timed out") { + return true + } + if strings.Contains(lower, "connection refused") || strings.Contains(lower, "connection reset") { + return true + } + if strings.Contains(lower, "temporarily unavailable") { + return true + } + return false +} + +// filterEmpty drops empty strings so callers can Join over partial fan-out +// results without stray blank lines. +func filterEmpty(ss []string) []string { + out := ss[:0] + for _, s := range ss { + if s != "" { + out = append(out, s) + } + } + return out +} + +// errorHint returns an actionable hint for common Railway error messages. +func errorHint(stderr string) string { + lower := strings.ToLower(stderr) + switch { + case strings.Contains(lower, "unauthorized") || strings.Contains(lower, "invalid token") || strings.Contains(lower, "not authenticated"): + return " (hint: check your RAILWAY_API_TOKEN is valid — v1 requires an account token, not a project deploy token)" + case strings.Contains(lower, "forbidden"): + return " (hint: your Railway token may be missing workspace scope)" + case strings.Contains(lower, "not found"): + return " (hint: resource not found — check project/service/deployment IDs)" + case strings.Contains(lower, "rate limit"): + return " (hint: rate limited, retrying with backoff)" + default: + return "" + } +} diff --git a/internal/railway/conversation.go b/internal/railway/conversation.go new file mode 100644 index 0000000..7ca78b7 --- /dev/null +++ b/internal/railway/conversation.go @@ -0,0 +1,213 @@ +package railway + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +// ConversationEntry represents a single Q&A exchange. +type ConversationEntry struct { + Timestamp time.Time `json:"timestamp"` + Question string `json:"question"` + Answer string `json:"answer"` +} + +// ConversationHistory maintains conversation state for Railway ask mode. +type ConversationHistory struct { + Entries []ConversationEntry `json:"entries"` + WorkspaceID string `json:"workspace_id"` + mu sync.Mutex +} + +// MaxHistoryEntries limits the conversation history size. +const MaxHistoryEntries = 20 + +// MaxAnswerLengthInContext limits how much of previous answers to include in context. +const MaxAnswerLengthInContext = 500 + +// NewConversationHistory creates a new conversation history for a workspace +// (or personal account). +func NewConversationHistory(workspaceID string) *ConversationHistory { + return &ConversationHistory{ + Entries: make([]ConversationEntry, 0), + WorkspaceID: workspaceID, + } +} + +// AddEntry adds a new conversation entry and prunes old entries. +func (h *ConversationHistory) AddEntry(question, answer string) { + h.mu.Lock() + defer h.mu.Unlock() + + entry := ConversationEntry{ + Timestamp: time.Now(), + Question: question, + Answer: answer, + } + + h.Entries = append(h.Entries, entry) + + if len(h.Entries) > MaxHistoryEntries { + h.Entries = h.Entries[len(h.Entries)-MaxHistoryEntries:] + } +} + +// GetRecentContext returns recent conversation context as a formatted string +// for inclusion in LLM prompts. +func (h *ConversationHistory) GetRecentContext(maxEntries int) string { + h.mu.Lock() + defer h.mu.Unlock() + + if len(h.Entries) == 0 { + return "" + } + + start := 0 + if len(h.Entries) > maxEntries { + start = len(h.Entries) - maxEntries + } + + var sb strings.Builder + for i, entry := range h.Entries[start:] { + if i > 0 { + sb.WriteString("\n") + } + sb.WriteString(fmt.Sprintf("Q: %s\n", entry.Question)) + sb.WriteString(fmt.Sprintf("A: %s\n", truncateAnswer(entry.Answer, MaxAnswerLengthInContext))) + } + + return sb.String() +} + +// Save persists the conversation history to disk using atomic write +// (temp file + rename). The mutex is only held while snapshotting and +// marshalling state — disk I/O happens with the lock released so concurrent +// callers aren't blocked on filesystem latency. +func (h *ConversationHistory) Save() error { + h.mu.Lock() + if len(h.Entries) > MaxHistoryEntries { + h.Entries = h.Entries[len(h.Entries)-MaxHistoryEntries:] + } + snapshot := struct { + Entries []ConversationEntry `json:"entries"` + WorkspaceID string `json:"workspace_id"` + }{ + Entries: append([]ConversationEntry(nil), h.Entries...), + WorkspaceID: h.WorkspaceID, + } + workspaceID := h.WorkspaceID + h.mu.Unlock() + + data, err := json.MarshalIndent(snapshot, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal conversation history: %w", err) + } + + dir, err := conversationDir() + if err != nil { + return err + } + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create conversation directory: %w", err) + } + + filename := filepath.Join(dir, fmt.Sprintf("railway_%s.json", sanitizeID(workspaceID))) + + // Atomic write: temp + rename. + tmp := filename + ".tmp" + if err := os.WriteFile(tmp, data, 0644); err != nil { + return fmt.Errorf("failed to write temp conversation file: %w", err) + } + if err := os.Rename(tmp, filename); err != nil { + _ = os.Remove(tmp) + return fmt.Errorf("failed to rename conversation file: %w", err) + } + + return nil +} + +// Load loads conversation history from disk. +func (h *ConversationHistory) Load() error { + h.mu.Lock() + defer h.mu.Unlock() + + path, err := h.filePath() + if err != nil { + return err + } + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("failed to read conversation file: %w", err) + } + + var loaded struct { + Entries []ConversationEntry `json:"entries"` + WorkspaceID string `json:"workspace_id"` + } + + if err := json.Unmarshal(data, &loaded); err != nil { + return fmt.Errorf("failed to parse conversation history: %w", err) + } + + h.Entries = loaded.Entries + h.WorkspaceID = loaded.WorkspaceID + + return nil +} + +// filePath returns the on-disk path for this history file. +func (h *ConversationHistory) filePath() (string, error) { + dir, err := conversationDir() + if err != nil { + return "", err + } + return filepath.Join(dir, fmt.Sprintf("railway_%s.json", sanitizeID(h.WorkspaceID))), nil +} + +// conversationDir returns ~/.clanker/conversations. +func conversationDir() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home directory: %w", err) + } + return filepath.Join(home, ".clanker", "conversations"), nil +} + +// sanitizeID replaces characters that are invalid in filenames. An empty +// input yields the default "personal" bucket so callers never produce a +// filename like "railway_.json". +func sanitizeID(s string) string { + if s == "" { + return "personal" + } + replacer := strings.NewReplacer( + "/", "_", + "\\", "_", + ":", "_", + "*", "_", + "?", "_", + "\"", "_", + "<", "_", + ">", "_", + "|", "_", + " ", "_", + ) + return replacer.Replace(s) +} + +// truncateAnswer truncates text to maxLen characters, adding an ellipsis +// when truncated. +func truncateAnswer(text string, maxLen int) string { + if len(text) <= maxLen { + return text + } + return text[:maxLen] + "..." +} diff --git a/internal/railway/static_commands.go b/internal/railway/static_commands.go new file mode 100644 index 0000000..9184394 --- /dev/null +++ b/internal/railway/static_commands.go @@ -0,0 +1,940 @@ +package railway + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +// CreateRailwayCommands creates the Railway command tree for static commands. +// Registered from cmd/root.go as a sibling of `cf`, `do`, `hetzner`, `vercel`. +func CreateRailwayCommands() *cobra.Command { + railwayCmd := &cobra.Command{ + Use: "railway", + Short: "Query Railway projects, services, and deployments directly", + Long: "Query your Railway account without AI interpretation. Useful for getting raw data.", + Aliases: []string{"rw"}, + } + + railwayCmd.PersistentFlags().String("api-token", "", "Railway API token (overrides RAILWAY_API_TOKEN)") + railwayCmd.PersistentFlags().String("workspace-id", "", "Railway workspace ID (overrides RAILWAY_WORKSPACE_ID)") + railwayCmd.PersistentFlags().Bool("raw", false, "Output raw JSON instead of formatted") + + railwayCmd.AddCommand(createRailwayListCmd()) + railwayCmd.AddCommand(createRailwayGetCmd()) + railwayCmd.AddCommand(createRailwayLogsCmd()) + railwayCmd.AddCommand(createRailwayAnalyticsCmd()) + railwayCmd.AddCommand(createRailwayDeployCmd()) + railwayCmd.AddCommand(createRailwayRedeployCmd()) + railwayCmd.AddCommand(createRailwayCancelCmd()) + railwayCmd.AddCommand(createRailwayVariableCmd()) + railwayCmd.AddCommand(createRailwayDomainCmd()) + railwayCmd.AddCommand(createRailwayEnvironmentCmd()) + + return railwayCmd +} + +func createRailwayListCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "list [resource]", + Short: "List Railway resources", + Long: `List Railway resources of a specific type. + +Supported resources: + projects - Railway projects + services - Services (requires --project) + deployments - Deployments (supports --project / --service / --environment) + domains - Service + custom domains (requires --project) + variables - Environment variables (requires --project / --environment / --service) + environments - Project environments (requires --project) + volumes - Persistent volumes (requires --project) + workspaces - Workspaces accessible to the current token`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + resource := strings.ToLower(args[0]) + projectID, _ := cmd.Flags().GetString("project") + serviceID, _ := cmd.Flags().GetString("service") + environmentID, _ := cmd.Flags().GetString("environment") + + client, err := newClientFromFlags(cmd) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + switch resource { + case "projects", "project": + return listProjectsCmd(ctx, client) + case "services", "service": + if projectID == "" { + return fmt.Errorf("--project is required to list services") + } + return listServicesCmd(ctx, client, projectID) + case "deployments", "deployment": + return listDeploymentsCmd(ctx, client, projectID, environmentID, serviceID) + case "domains", "domain": + if projectID == "" { + return fmt.Errorf("--project is required to list domains") + } + return listDomainsCmd(ctx, client, projectID) + case "variables", "variable", "vars", "var", "env": + if projectID == "" || environmentID == "" { + return fmt.Errorf("--project and --environment are required to list variables") + } + return listVariablesCmd(ctx, client, projectID, environmentID, serviceID) + case "environments", "environment": + if projectID == "" { + return fmt.Errorf("--project is required to list environments") + } + return listEnvironmentsCmd(ctx, client, projectID) + case "volumes", "volume": + if projectID == "" { + return fmt.Errorf("--project is required to list volumes") + } + return listVolumesCmd(ctx, client, projectID) + case "workspaces", "workspace", "teams", "team": + return listWorkspacesCmd(ctx, client) + default: + return fmt.Errorf("unknown resource type: %s", resource) + } + }, + } + cmd.Flags().String("project", "", "Project ID (scopes deployments / services / env listings)") + cmd.Flags().String("service", "", "Service ID (optional deployment/variable filter)") + cmd.Flags().String("environment", "", "Environment ID (required for variables)") + return cmd +} + +func createRailwayGetCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "get ", + Short: "Get a single Railway resource", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + kind := strings.ToLower(args[0]) + id := args[1] + + client, err := newClientFromFlags(cmd) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + switch kind { + case "project": + return getProjectCmd(ctx, client, id) + case "service": + return getServiceCmd(ctx, client, id) + case "deployment": + return getDeploymentCmd(ctx, client, id) + default: + return fmt.Errorf("unknown resource kind: %s (expected project|service|deployment)", kind) + } + }, + } + return cmd +} + +func createRailwayLogsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "logs ", + Short: "Fetch build + runtime events for a deployment", + Long: `Fetch events for a deployment. By default returns a one-shot snapshot of recent events. +Use --follow to stream events continuously (when supported by the transport). +Use --build to fetch build logs instead of runtime logs.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + deploymentID := args[0] + follow, _ := cmd.Flags().GetBool("follow") + build, _ := cmd.Flags().GetBool("build") + limit, _ := cmd.Flags().GetInt("limit") + + client, err := newClientFromFlags(cmd) + if err != nil { + return err + } + if follow { + // Streaming support requires the CLI — fall back to a snapshot + // from the GraphQL API for parity with Vercel. + fmt.Fprintln(cmd.OutOrStderr(), "[railway] --follow falls back to a snapshot via GraphQL API; use `railway logs` CLI for true streaming.") + } + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + return getDeploymentLogsCmd(ctx, client, deploymentID, build, limit) + }, + } + cmd.Flags().Bool("follow", false, "Follow log output (snapshot in phase 1)") + cmd.Flags().Bool("build", false, "Fetch build logs instead of runtime logs") + cmd.Flags().Int("limit", 100, "Number of log entries to fetch") + return cmd +} + +func createRailwayAnalyticsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "analytics", + Short: "Show recent usage summary (CPU, memory, network)", + RunE: func(cmd *cobra.Command, args []string) error { + client, err := newClientFromFlags(cmd) + if err != nil { + return err + } + if client.GetWorkspaceID() == "" { + return fmt.Errorf("analytics requires --workspace-id (or railway.workspace_id / RAILWAY_WORKSPACE_ID)") + } + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + return getUsageCmd(ctx, client) + }, + } + return cmd +} + +// newClientFromFlags resolves credentials from flags > config > env and +// builds a Client. +func newClientFromFlags(cmd *cobra.Command) (*Client, error) { + apiToken, _ := cmd.Flags().GetString("api-token") + if apiToken == "" { + apiToken = ResolveAPIToken() + } + if apiToken == "" { + return nil, fmt.Errorf("railway api_token is required (set railway.api_token, RAILWAY_API_TOKEN, or --api-token)") + } + + workspaceID, _ := cmd.Flags().GetString("workspace-id") + if workspaceID == "" { + workspaceID = ResolveWorkspaceID() + } + + debug := viper.GetBool("debug") + client, err := NewClient(apiToken, workspaceID, debug) + if err != nil { + return nil, err + } + if raw, _ := cmd.Flags().GetBool("raw"); raw { + client.raw = true + } + return client, nil +} + +// rawOutput reports whether the user asked to print unformatted JSON. +func rawOutput(c *Client) bool { + if c == nil { + return false + } + return c.raw +} + +// --- Listers --- + +func listProjectsCmd(ctx context.Context, client *Client) error { + projects, err := client.ListProjects(ctx) + if err != nil { + return err + } + if rawOutput(client) { + return printJSON(projects) + } + if len(projects) == 0 { + fmt.Println("No Railway projects found.") + return nil + } + fmt.Printf("Railway Projects (%d):\n\n", len(projects)) + for _, p := range projects { + fmt.Printf(" %s\n", p.Name) + fmt.Printf(" ID: %s\n", p.ID) + if p.Description != "" { + fmt.Printf(" Description: %s\n", p.Description) + } + if p.TeamID != "" { + fmt.Printf(" Team: %s\n", p.TeamID) + } + fmt.Println() + } + return nil +} + +func listServicesCmd(ctx context.Context, client *Client, projectID string) error { + services, err := client.ListServices(ctx, projectID) + if err != nil { + return err + } + if rawOutput(client) { + return printJSON(services) + } + if len(services) == 0 { + fmt.Printf("No services found for project %s.\n", projectID) + return nil + } + fmt.Printf("Railway Services for project %s (%d):\n\n", projectID, len(services)) + for _, s := range services { + fmt.Printf(" %s\n ID: %s\n\n", s.Name, s.ID) + } + return nil +} + +func listDeploymentsCmd(ctx context.Context, client *Client, projectID, environmentID, serviceID string) error { + deployments, err := client.ListDeployments(ctx, projectID, environmentID, serviceID, 20) + if err != nil { + return err + } + if rawOutput(client) { + return printJSON(deployments) + } + if len(deployments) == 0 { + fmt.Println("No deployments found.") + return nil + } + fmt.Printf("Railway Deployments (%d):\n\n", len(deployments)) + for _, d := range deployments { + fmt.Printf(" %s [%s]\n", d.ID, d.Status) + if d.URL != "" { + fmt.Printf(" URL: %s\n", d.URL) + } else if d.StaticURL != "" { + fmt.Printf(" URL: %s\n", d.StaticURL) + } + if d.ServiceID != "" { + fmt.Printf(" Service: %s\n", d.ServiceID) + } + if d.Meta.CommitHash != "" { + fmt.Printf(" Commit: %s (%s)\n", d.Meta.CommitHash, d.Meta.Branch) + } + fmt.Println() + } + return nil +} + +func listDomainsCmd(ctx context.Context, client *Client, projectID string) error { + domains, err := client.ListDomains(ctx, projectID) + if err != nil { + return err + } + if rawOutput(client) { + return printJSON(domains) + } + if len(domains) == 0 { + fmt.Println("No domains found.") + return nil + } + fmt.Printf("Railway Domains for project %s (%d):\n\n", projectID, len(domains)) + for _, d := range domains { + kind := "service" + if d.IsCustom { + kind = "custom" + } + fmt.Printf(" %s [%s]\n", d.Domain, kind) + if d.TargetPort > 0 { + fmt.Printf(" Target port: %d\n", d.TargetPort) + } + if d.Status != "" { + fmt.Printf(" Status: %s\n", d.Status) + } + } + return nil +} + +func listVariablesCmd(ctx context.Context, client *Client, projectID, environmentID, serviceID string) error { + vars, err := client.ListVariables(ctx, projectID, environmentID, serviceID) + if err != nil { + return err + } + if rawOutput(client) { + return printJSON(vars) + } + if len(vars) == 0 { + fmt.Println("No variables found.") + return nil + } + fmt.Printf("Railway variables (%d):\n\n", len(vars)) + for k := range vars { + // Values intentionally omitted unless --raw. + fmt.Printf(" %s\n", k) + } + return nil +} + +func listEnvironmentsCmd(ctx context.Context, client *Client, projectID string) error { + proj, err := client.GetProject(ctx, projectID) + if err != nil { + return err + } + if rawOutput(client) { + return printJSON(proj.Environments) + } + if len(proj.Environments) == 0 { + fmt.Println("No environments found.") + return nil + } + fmt.Printf("Railway environments (%d):\n\n", len(proj.Environments)) + for _, e := range proj.Environments { + fmt.Printf(" %s (%s)\n", e.Name, e.ID) + } + return nil +} + +func listVolumesCmd(ctx context.Context, client *Client, projectID string) error { + volumes, err := client.ListVolumes(ctx, projectID) + if err != nil { + return err + } + if rawOutput(client) { + return printJSON(volumes) + } + if len(volumes) == 0 { + fmt.Println("No volumes found.") + return nil + } + fmt.Printf("Railway volumes (%d):\n\n", len(volumes)) + for _, v := range volumes { + fmt.Printf(" %s (%s)\n", v.Name, v.ID) + if v.MountPath != "" { + fmt.Printf(" Mount: %s\n", v.MountPath) + } + } + return nil +} + +func listWorkspacesCmd(ctx context.Context, client *Client) error { + workspaces, err := client.ListWorkspaces(ctx) + if err != nil { + return err + } + if rawOutput(client) { + return printJSON(workspaces) + } + if len(workspaces) == 0 { + fmt.Println("No workspaces found (personal account).") + return nil + } + fmt.Printf("Railway Workspaces (%d):\n\n", len(workspaces)) + for _, w := range workspaces { + fmt.Printf(" %s", w.Name) + if w.Slug != "" { + fmt.Printf(" (%s)", w.Slug) + } + fmt.Printf("\n ID: %s\n\n", w.ID) + } + return nil +} + +// --- Getters --- + +func getProjectCmd(ctx context.Context, client *Client, id string) error { + proj, err := client.GetProject(ctx, id) + if err != nil { + return err + } + if rawOutput(client) { + return printJSON(proj) + } + fmt.Printf("Railway Project: %s\n\n", proj.Name) + fmt.Printf(" ID: %s\n", proj.ID) + if proj.Description != "" { + fmt.Printf(" Description: %s\n", proj.Description) + } + if len(proj.Environments) > 0 { + fmt.Println(" Environments:") + for _, e := range proj.Environments { + fmt.Printf(" - %s (%s)\n", e.Name, e.ID) + } + } + if len(proj.Services) > 0 { + fmt.Println(" Services:") + for _, s := range proj.Services { + fmt.Printf(" - %s (%s)\n", s.Name, s.ID) + } + } + return nil +} + +func getServiceCmd(ctx context.Context, client *Client, id string) error { + q := `query Service($id: String!) { service(id: $id) { id name projectId createdAt updatedAt } }` + var out struct { + Service Service `json:"service"` + } + if err := client.RunGQL(ctx, q, map[string]any{"id": id}, &out); err != nil { + return err + } + if rawOutput(client) { + return printJSON(out.Service) + } + fmt.Printf("Railway Service: %s\n\n", out.Service.Name) + fmt.Printf(" ID: %s\n", out.Service.ID) + if out.Service.ProjectID != "" { + fmt.Printf(" Project: %s\n", out.Service.ProjectID) + } + return nil +} + +func getDeploymentCmd(ctx context.Context, client *Client, id string) error { + d, err := client.GetDeployment(ctx, id) + if err != nil { + return err + } + if rawOutput(client) { + return printJSON(d) + } + fmt.Printf("Railway Deployment: %s\n\n", d.ID) + fmt.Printf(" Status: %s\n", d.Status) + if d.URL != "" { + fmt.Printf(" URL: %s\n", d.URL) + } + if d.StaticURL != "" { + fmt.Printf(" Static URL: %s\n", d.StaticURL) + } + if d.ServiceID != "" { + fmt.Printf(" Service: %s\n", d.ServiceID) + } + if d.Meta.CommitHash != "" { + fmt.Printf(" Commit: %s (%s)\n", d.Meta.CommitHash, d.Meta.Branch) + } + return nil +} + +func getDeploymentLogsCmd(ctx context.Context, client *Client, id string, buildLogs bool, limit int) error { + entries, err := client.ListDeploymentLogs(ctx, id, buildLogs, limit) + if err != nil { + return err + } + if rawOutput(client) { + return printJSON(entries) + } + if len(entries) == 0 { + fmt.Println("No deployment log entries found.") + return nil + } + kind := "runtime" + if buildLogs { + kind = "build" + } + fmt.Printf("Railway %s logs (%d entries):\n\n", kind, len(entries)) + for _, e := range entries { + ts, _ := e["timestamp"].(string) + msg, _ := e["message"].(string) + sev, _ := e["severity"].(string) + if ts != "" { + fmt.Printf(" [%s] %-5s %s\n", ts, sev, msg) + } else { + fmt.Printf(" %-5s %s\n", sev, msg) + } + } + return nil +} + +func getUsageCmd(ctx context.Context, client *Client) error { + usage, err := client.GetUsage(ctx, client.GetWorkspaceID()) + if err != nil { + return err + } + return printJSON(usage) +} + +// --- Deploy / Redeploy / Cancel --- + +func createRailwayDeployCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "deploy [path]", + Short: "Deploy the current directory (or a specific path) to Railway", + Long: `Deploy by shelling out to 'railway up'. Use --service to target a specific +service and --environment to select a non-default environment. Pass --detach to return +immediately after scheduling the deploy.`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := newClientFromFlags(cmd) + if err != nil { + return err + } + + cliArgs := []string{"up"} + if svc, _ := cmd.Flags().GetString("service"); svc != "" { + cliArgs = append(cliArgs, "--service", svc) + } + if env, _ := cmd.Flags().GetString("environment"); env != "" { + cliArgs = append(cliArgs, "--environment", env) + } + if detach, _ := cmd.Flags().GetBool("detach"); detach { + cliArgs = append(cliArgs, "--detach") + } + if len(args) > 0 { + cliArgs = append(cliArgs, "--path", args[0]) + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + out, err := client.RunRailwayCLI(ctx, cliArgs...) + if err != nil { + return err + } + fmt.Print(out) + return nil + }, + } + cmd.Flags().StringP("service", "s", "", "Target service name or ID") + cmd.Flags().StringP("environment", "e", "", "Target environment name or ID") + cmd.Flags().Bool("detach", false, "Return immediately after scheduling the deploy") + return cmd +} + +func createRailwayRedeployCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "redeploy ", + Short: "Redeploy an existing Railway deployment", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := newClientFromFlags(cmd) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + if err := client.RedeployDeployment(ctx, args[0]); err != nil { + return err + } + fmt.Printf("Redeploy triggered for %s\n", args[0]) + return nil + }, + } + return cmd +} + +func createRailwayCancelCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "cancel ", + Short: "Cancel an in-progress Railway deployment", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := newClientFromFlags(cmd) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + if err := client.CancelDeployment(ctx, args[0]); err != nil { + return err + } + fmt.Printf("Deployment %s cancelled\n", args[0]) + return nil + }, + } + return cmd +} + +// --- Variable subcommands --- + +func createRailwayVariableCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "variable", + Aliases: []string{"variables", "var", "vars", "env"}, + Short: "Manage Railway service environment variables", + } + cmd.PersistentFlags().String("project", "", "Project ID (required)") + cmd.PersistentFlags().String("environment", "", "Environment ID (required)") + cmd.PersistentFlags().String("service", "", "Service ID (optional — omit for shared vars)") + + cmd.AddCommand(createRailwayVariableSetCmd()) + cmd.AddCommand(createRailwayVariableRmCmd()) + cmd.AddCommand(createRailwayVariableLsCmd()) + return cmd +} + +func createRailwayVariableSetCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "set ", + Aliases: []string{"add"}, + Short: "Set an environment variable (KEY=VALUE)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + projectID, _ := cmd.Flags().GetString("project") + environmentID, _ := cmd.Flags().GetString("environment") + serviceID, _ := cmd.Flags().GetString("service") + if projectID == "" || environmentID == "" { + return fmt.Errorf("--project and --environment are required") + } + + key, value, ok := strings.Cut(args[0], "=") + if !ok || key == "" { + return fmt.Errorf("expected KEY=VALUE, got %q", args[0]) + } + + client, err := newClientFromFlags(cmd) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if err := client.UpsertVariable(ctx, projectID, environmentID, serviceID, key, value); err != nil { + return err + } + fmt.Printf("Variable %s set\n", key) + return nil + }, + } + return cmd +} + +func createRailwayVariableRmCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "rm ", + Short: "Remove an environment variable", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + projectID, _ := cmd.Flags().GetString("project") + environmentID, _ := cmd.Flags().GetString("environment") + serviceID, _ := cmd.Flags().GetString("service") + if projectID == "" || environmentID == "" { + return fmt.Errorf("--project and --environment are required") + } + client, err := newClientFromFlags(cmd) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if err := client.DeleteVariable(ctx, projectID, environmentID, serviceID, args[0]); err != nil { + return err + } + fmt.Printf("Variable %s removed\n", args[0]) + return nil + }, + } + return cmd +} + +func createRailwayVariableLsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "ls", + Short: "List environment variables", + RunE: func(cmd *cobra.Command, args []string) error { + projectID, _ := cmd.Flags().GetString("project") + environmentID, _ := cmd.Flags().GetString("environment") + serviceID, _ := cmd.Flags().GetString("service") + if projectID == "" || environmentID == "" { + return fmt.Errorf("--project and --environment are required") + } + client, err := newClientFromFlags(cmd) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + return listVariablesCmd(ctx, client, projectID, environmentID, serviceID) + }, + } + return cmd +} + +// --- Domain subcommands --- + +func createRailwayDomainCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "domain", + Short: "Manage Railway custom domains", + } + cmd.PersistentFlags().String("project", "", "Project ID (required)") + cmd.PersistentFlags().String("environment", "", "Environment ID (required for add)") + cmd.PersistentFlags().String("service", "", "Service ID (required for add)") + + cmd.AddCommand(createRailwayDomainAddCmd()) + cmd.AddCommand(createRailwayDomainRmCmd()) + cmd.AddCommand(createRailwayDomainLsCmd()) + return cmd +} + +func createRailwayDomainAddCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "add ", + Short: "Add a custom domain to a service", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + environmentID, _ := cmd.Flags().GetString("environment") + serviceID, _ := cmd.Flags().GetString("service") + if environmentID == "" || serviceID == "" { + return fmt.Errorf("--environment and --service are required") + } + client, err := newClientFromFlags(cmd) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + q := `mutation CustomDomainCreate($input: CustomDomainCreateInput!) { + customDomainCreate(input: $input) { id domain } + }` + input := map[string]any{ + "domain": args[0], + "environmentId": environmentID, + "serviceId": serviceID, + } + if err := client.RunGQL(ctx, q, map[string]any{"input": input}, nil); err != nil { + return err + } + fmt.Printf("Custom domain %s added\n", args[0]) + return nil + }, + } + return cmd +} + +func createRailwayDomainRmCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "rm ", + Short: "Remove a custom domain", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := newClientFromFlags(cmd) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + q := `mutation CustomDomainDelete($id: String!) { customDomainDelete(id: $id) }` + if err := client.RunGQL(ctx, q, map[string]any{"id": args[0]}, nil); err != nil { + return err + } + fmt.Printf("Custom domain %s removed\n", args[0]) + return nil + }, + } + return cmd +} + +func createRailwayDomainLsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "ls", + Short: "List domains for a project", + RunE: func(cmd *cobra.Command, args []string) error { + projectID, _ := cmd.Flags().GetString("project") + if projectID == "" { + return fmt.Errorf("--project is required") + } + client, err := newClientFromFlags(cmd) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + return listDomainsCmd(ctx, client, projectID) + }, + } + return cmd +} + +// --- Environment subcommands --- + +func createRailwayEnvironmentCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "environment", + Aliases: []string{"env-cmd", "envs"}, + Short: "Manage Railway environments", + } + cmd.PersistentFlags().String("project", "", "Project ID (required)") + cmd.AddCommand(createRailwayEnvironmentNewCmd()) + cmd.AddCommand(createRailwayEnvironmentRmCmd()) + cmd.AddCommand(createRailwayEnvironmentLsCmd()) + return cmd +} + +func createRailwayEnvironmentNewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "new ", + Short: "Create a new environment", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + projectID, _ := cmd.Flags().GetString("project") + if projectID == "" { + return fmt.Errorf("--project is required") + } + client, err := newClientFromFlags(cmd) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + q := `mutation EnvironmentCreate($input: EnvironmentCreateInput!) { + environmentCreate(input: $input) { id name } + }` + input := map[string]any{ + "projectId": projectID, + "name": args[0], + } + if err := client.RunGQL(ctx, q, map[string]any{"input": input}, nil); err != nil { + return err + } + fmt.Printf("Environment %s created\n", args[0]) + return nil + }, + } + return cmd +} + +func createRailwayEnvironmentRmCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "rm ", + Short: "Delete an environment", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := newClientFromFlags(cmd) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + q := `mutation EnvironmentDelete($id: String!) { environmentDelete(id: $id) }` + if err := client.RunGQL(ctx, q, map[string]any{"id": args[0]}, nil); err != nil { + return err + } + fmt.Printf("Environment %s deleted\n", args[0]) + return nil + }, + } + return cmd +} + +func createRailwayEnvironmentLsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "ls", + Short: "List environments for a project", + RunE: func(cmd *cobra.Command, args []string) error { + projectID, _ := cmd.Flags().GetString("project") + if projectID == "" { + return fmt.Errorf("--project is required") + } + client, err := newClientFromFlags(cmd) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + return listEnvironmentsCmd(ctx, client, projectID) + }, + } + return cmd +} + +// printJSON prints v as pretty JSON. +func printJSON(v any) error { + b, err := json.MarshalIndent(v, "", " ") + if err != nil { + return err + } + fmt.Println(string(b)) + return nil +} diff --git a/internal/railway/types.go b/internal/railway/types.go new file mode 100644 index 0000000..79007e9 --- /dev/null +++ b/internal/railway/types.go @@ -0,0 +1,127 @@ +package railway + +// User represents the authenticated Railway user (from the `me` query). +type User struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` + Email string `json:"email,omitempty"` +} + +// Workspace represents a Railway workspace / team. v1 public API has limited +// workspace surface so we keep this minimal. +type Workspace struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` + Slug string `json:"slug,omitempty"` +} + +// Project represents a Railway project. Projects hold environments, services, +// and plugins. +type Project struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + TeamID string `json:"teamId,omitempty"` + CreatedAt string `json:"createdAt,omitempty"` + UpdatedAt string `json:"updatedAt,omitempty"` + Environments []Environment `json:"environments,omitempty"` + Services []Service `json:"services,omitempty"` + Plugins []Plugin `json:"plugins,omitempty"` +} + +// Environment represents a Railway environment (e.g. production, staging). +type Environment struct { + ID string `json:"id"` + Name string `json:"name"` + ProjectID string `json:"projectId,omitempty"` + CreatedAt string `json:"createdAt,omitempty"` + UpdatedAt string `json:"updatedAt,omitempty"` +} + +// Service represents a Railway service (container/app) inside a project. +type Service struct { + ID string `json:"id"` + Name string `json:"name"` + ProjectID string `json:"projectId,omitempty"` + CreatedAt string `json:"createdAt,omitempty"` + UpdatedAt string `json:"updatedAt,omitempty"` +} + +// Deployment represents a Railway deployment for a given service+environment. +type Deployment struct { + ID string `json:"id"` + Status string `json:"status,omitempty"` // SUCCESS, FAILED, BUILDING, DEPLOYING, REMOVED, CRASHED, INITIALIZING, QUEUED, SKIPPED + StaticURL string `json:"staticUrl,omitempty"` + URL string `json:"url,omitempty"` + ServiceID string `json:"serviceId,omitempty"` + EnvironmentID string `json:"environmentId,omitempty"` + ProjectID string `json:"projectId,omitempty"` + CreatedAt string `json:"createdAt,omitempty"` + UpdatedAt string `json:"updatedAt,omitempty"` + CanRedeploy bool `json:"canRedeploy,omitempty"` + CanRollback bool `json:"canRollback,omitempty"` + Meta struct { + CommitHash string `json:"commitHash,omitempty"` + CommitMessage string `json:"commitMessage,omitempty"` + Branch string `json:"branch,omitempty"` + } `json:"meta,omitempty"` +} + +// Domain represents either a Railway-managed service domain (xxx.up.railway.app) +// or a user-configured custom domain. IsCustom distinguishes the two. +type Domain struct { + ID string `json:"id"` + Domain string `json:"domain"` + IsCustom bool `json:"isCustom"` + ServiceID string `json:"serviceId,omitempty"` + EnvironmentID string `json:"environmentId,omitempty"` + ProjectID string `json:"projectId,omitempty"` + Status string `json:"status,omitempty"` + TargetPort int `json:"targetPort,omitempty"` + CreatedAt string `json:"createdAt,omitempty"` +} + +// Variable represents a Railway service environment variable. +// The Value field is only returned when the caller explicitly requests raw +// values; listings typically return keys only. +type Variable struct { + Name string `json:"name"` + Value string `json:"value,omitempty"` + ServiceID string `json:"serviceId,omitempty"` + EnvironmentID string `json:"environmentId,omitempty"` +} + +// Volume represents a persistent volume attached to a Railway service. +type Volume struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` + ProjectID string `json:"projectId,omitempty"` + ServiceID string `json:"serviceId,omitempty"` + MountPath string `json:"mountPath,omitempty"` + SizeMB int64 `json:"sizeMB,omitempty"` + CreatedAt string `json:"createdAt,omitempty"` +} + +// Plugin represents a managed plugin resource inside a Railway project +// (e.g. Railway-managed Postgres, Redis, MySQL). +type Plugin struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` + ProjectID string `json:"projectId,omitempty"` + Status string `json:"status,omitempty"` + CreatedAt string `json:"createdAt,omitempty"` +} + +// UsageSummary is a best-effort view of aggregate workspace usage (CPU, +// memory, egress) over the configured billing window. Shape mirrors the +// Vercel UsageSummary so the desktop UI can render it uniformly. +type UsageSummary struct { + CPUSeconds float64 `json:"cpuSeconds,omitempty"` + MemoryGBHours float64 `json:"memoryGbHours,omitempty"` + NetworkEgressGB float64 `json:"networkEgressGb,omitempty"` + DiskGBHours float64 `json:"diskGbHours,omitempty"` + EstimatedCostUSD float64 `json:"estimatedCostUsd,omitempty"` + BillingPeriodFrom string `json:"billingPeriodFrom,omitempty"` + BillingPeriodTo string `json:"billingPeriodTo,omitempty"` + Period string `json:"period,omitempty"` +} diff --git a/internal/routing/routing.go b/internal/routing/routing.go index 05c957b..b68af3a 100644 --- a/internal/routing/routing.go +++ b/internal/routing/routing.go @@ -25,6 +25,7 @@ type ServiceContext struct { DigitalOcean bool Hetzner bool Vercel bool + Railway bool Verda bool IAM bool Code bool @@ -42,7 +43,7 @@ type Classification struct { func DefaultInfraProvider() string { p := strings.ToLower(strings.TrimSpace(viper.GetString("infra.default_provider"))) switch p { - case "aws", "gcp", "azure", "cloudflare", "digitalocean", "hetzner", "vercel", "verda": + case "aws", "gcp", "azure", "cloudflare", "digitalocean", "hetzner", "vercel", "railway", "verda": return p default: return "aws" @@ -60,6 +61,7 @@ func applyConfiguredDefaultContext(ctx *ServiceContext) { ctx.DigitalOcean = false ctx.Hetzner = false ctx.Vercel = false + ctx.Railway = false ctx.Verda = false ctx.IAM = false @@ -76,6 +78,8 @@ func applyConfiguredDefaultContext(ctx *ServiceContext) { ctx.Hetzner = true case "vercel": ctx.Vercel = true + case "railway": + ctx.Railway = true case "verda": ctx.Verda = true default: @@ -265,6 +269,24 @@ func InferContext(question string) ServiceContext { "edge middleware", } + railwayKeywords := []string{ + // Only match when Railway is explicitly referenced. Generic deploy/ + // service/env phrasing is intentionally excluded so we do not + // mistakenly route AWS/GCP questions through the Railway agent. + "railway", + "railway.app", + "railway project", + "railway service", + "railway deployment", + "railway domain", + "railway volume", + "railway environment", + "railway plugin", + "nixpacks", + "railway.json", + "railway.toml", + } + verdaKeywords := []string{ // Match explicit Verda mentions + DataCrunch (the old brand) + Verda- // specific resource nouns. GPU model codes alone (h100, a100, etc.) are @@ -370,6 +392,13 @@ func InferContext(question string) ServiceContext { } } + for _, keyword := range railwayKeywords { + if contains(questionLower, keyword) { + ctx.Railway = true + break + } + } + for _, keyword := range verdaKeywords { if contains(questionLower, keyword) { ctx.Verda = true @@ -424,12 +453,13 @@ IMPORTANT RULES: 5. Only classify as "digitalocean" if the query EXPLICITLY mentions Digital Ocean, doctl, droplets, DOKS, or Digital Ocean-specific products 6. Only classify as "hetzner" if the query EXPLICITLY mentions Hetzner, hcloud, or Hetzner-specific products 7. Only classify as "vercel" if the query EXPLICITLY mentions Vercel, vercel.app, a Vercel deployment/project, or Vercel-specific products (Edge Config, Vercel KV / Blob / Postgres) -8. Only classify as "verda" if the query EXPLICITLY mentions Verda, DataCrunch, Verda clusters/instances, or an Instant Cluster (Verda's managed cluster product) -9. If uncertain, classify as "%s" (the configured default cloud provider) +8. Only classify as "railway" if the query EXPLICITLY mentions Railway, railway.app, a Railway project/service/deployment/volume/environment, Nixpacks, or a railway.json/railway.toml file +9. Only classify as "verda" if the query EXPLICITLY mentions Verda, DataCrunch, Verda clusters/instances, or an Instant Cluster (Verda's managed cluster product) +10. If uncertain, classify as "%s" (the configured default cloud provider) Respond with ONLY a JSON object: { - "service": "cloudflare|aws|iam|k8s|gcp|azure|digitalocean|hetzner|vercel|verda|github|terraform|general", + "service": "cloudflare|aws|iam|k8s|gcp|azure|digitalocean|hetzner|vercel|railway|verda|github|terraform|general", "confidence": "high|medium|low", "reason": "brief explanation of why this classification" }`, question, defaultProvider, defaultProvider) @@ -531,6 +561,9 @@ func NeedsLLMClassification(ctx ServiceContext) bool { if ctx.Vercel { count++ } + if ctx.Railway { + count++ + } if ctx.Verda { count++ } @@ -546,7 +579,7 @@ func NeedsLLMClassification(ctx ServiceContext) bool { // 5. Vercel was inferred (verify it's actually Vercel-related) // 6. Verda was inferred (verify it's actually Verda-related) // 7. IAM was inferred (verify it's actually IAM-related for disambiguation) - return count > 1 || ctx.Cloudflare || ctx.DigitalOcean || ctx.Hetzner || ctx.Vercel || ctx.Verda || ctx.IAM + return count > 1 || ctx.Cloudflare || ctx.DigitalOcean || ctx.Hetzner || ctx.Vercel || ctx.Railway || ctx.Verda || ctx.IAM } // ApplyLLMClassification updates the ServiceContext based on LLM classification result @@ -561,6 +594,7 @@ func ApplyLLMClassification(ctx *ServiceContext, llmService string) { ctx.DigitalOcean = false ctx.Hetzner = false ctx.Vercel = false + ctx.Railway = false ctx.Verda = false ctx.IAM = false case "k8s": @@ -571,6 +605,7 @@ func ApplyLLMClassification(ctx *ServiceContext, llmService string) { ctx.DigitalOcean = false ctx.Hetzner = false ctx.Vercel = false + ctx.Railway = false ctx.Verda = false ctx.IAM = false case "gcp": @@ -581,6 +616,7 @@ func ApplyLLMClassification(ctx *ServiceContext, llmService string) { ctx.DigitalOcean = false ctx.Hetzner = false ctx.Vercel = false + ctx.Railway = false ctx.Verda = false ctx.IAM = false case "azure": @@ -592,6 +628,7 @@ func ApplyLLMClassification(ctx *ServiceContext, llmService string) { ctx.DigitalOcean = false ctx.Hetzner = false ctx.Vercel = false + ctx.Railway = false ctx.Verda = false ctx.IAM = false case "digitalocean": @@ -603,6 +640,7 @@ func ApplyLLMClassification(ctx *ServiceContext, llmService string) { ctx.Azure = false ctx.Hetzner = false ctx.Vercel = false + ctx.Railway = false ctx.Verda = false ctx.IAM = false case "hetzner": @@ -614,6 +652,7 @@ func ApplyLLMClassification(ctx *ServiceContext, llmService string) { ctx.Azure = false ctx.DigitalOcean = false ctx.Vercel = false + ctx.Railway = false ctx.Verda = false ctx.IAM = false case "vercel": @@ -637,6 +676,19 @@ func ApplyLLMClassification(ctx *ServiceContext, llmService string) { ctx.DigitalOcean = false ctx.Hetzner = false ctx.Vercel = false + ctx.Railway = false + ctx.IAM = false + case "railway": + ctx.Railway = true + ctx.AWS = false + ctx.GCP = false + ctx.Cloudflare = false + ctx.K8s = false + ctx.Azure = false + ctx.DigitalOcean = false + ctx.Hetzner = false + ctx.Vercel = false + ctx.Verda = false ctx.IAM = false case "aws": ctx.AWS = true @@ -647,6 +699,7 @@ func ApplyLLMClassification(ctx *ServiceContext, llmService string) { ctx.DigitalOcean = false ctx.Hetzner = false ctx.Vercel = false + ctx.Railway = false ctx.Verda = false ctx.IAM = false case "iam": @@ -659,6 +712,7 @@ func ApplyLLMClassification(ctx *ServiceContext, llmService string) { ctx.DigitalOcean = false ctx.Hetzner = false ctx.Vercel = false + ctx.Railway = false ctx.Verda = false case "terraform": ctx.Terraform = true @@ -666,6 +720,7 @@ func ApplyLLMClassification(ctx *ServiceContext, llmService string) { ctx.DigitalOcean = false ctx.Hetzner = false ctx.Vercel = false + ctx.Railway = false ctx.Verda = false case "github": ctx.GitHub = true @@ -673,6 +728,7 @@ func ApplyLLMClassification(ctx *ServiceContext, llmService string) { ctx.DigitalOcean = false ctx.Hetzner = false ctx.Vercel = false + ctx.Railway = false ctx.Verda = false default: // "general" - default to the configured infrastructure provider @@ -681,6 +737,7 @@ func ApplyLLMClassification(ctx *ServiceContext, llmService string) { ctx.DigitalOcean = false ctx.Hetzner = false ctx.Vercel = false + ctx.Railway = false ctx.Verda = false ctx.Azure = false ctx.GCP = false @@ -698,6 +755,8 @@ func ApplyLLMClassification(ctx *ServiceContext, llmService string) { ctx.Hetzner = true case "vercel": ctx.Vercel = true + case "railway": + ctx.Railway = true case "verda": ctx.Verda = true default: