diff --git a/generate.go b/generate.go new file mode 100644 index 0000000..90758f5 --- /dev/null +++ b/generate.go @@ -0,0 +1,194 @@ +package main + +import ( + "strings" + + "github.com/railwayapp/railpack/core" + "github.com/railwayapp/railpack/core/app" + c "github.com/railwayapp/railpack/core/config" + "github.com/railwayapp/railpack/core/generate" + "github.com/railwayapp/railpack/core/logger" + "github.com/railwayapp/railpack/core/providers" + "github.com/railwayapp/railpack/core/providers/cpp" + "github.com/railwayapp/railpack/core/providers/deno" + "github.com/railwayapp/railpack/core/providers/dotnet" + "github.com/railwayapp/railpack/core/providers/elixir" + "github.com/railwayapp/railpack/core/providers/gleam" + "github.com/railwayapp/railpack/core/providers/golang" + "github.com/railwayapp/railpack/core/providers/java" + "github.com/railwayapp/railpack/core/providers/php" + "github.com/railwayapp/railpack/core/providers/procfile" + "github.com/railwayapp/railpack/core/providers/python" + "github.com/railwayapp/railpack/core/providers/ruby" + "github.com/railwayapp/railpack/core/providers/rust" + "github.com/railwayapp/railpack/core/providers/shell" + "github.com/railwayapp/railpack/core/providers/staticfile" +) + +// generateBuildPlan mirrors railpack's core.GenerateBuildPlan but accepts an +// explicit provider list, letting sugapack inject or extend providers without +// patching railpack itself. Every railpack symbol it touches is exported, so +// this compiles against an unmodified railpack dependency. +// +// Adapted from github.com/railwayapp/railpack v0.23.0 core.GenerateBuildPlan. +// Keep in sync when bumping the railpack version. +func generateBuildPlan(a *app.App, env *app.Environment, options *core.GenerateBuildPlanOptions, allProviders []providers.Provider) *core.BuildResult { + log := logger.NewLogger() + + config, err := core.GetConfig(a, env, options, log) + if err != nil { + log.LogError("%s", err.Error()) + return &core.BuildResult{Success: false, Logs: log.Logs} + } + + ctx, err := generate.NewGenerateContext(a, env, config, log) + if err != nil { + log.LogError("%s", err.Error()) + return &core.BuildResult{Success: false, Logs: log.Logs} + } + + if options.PreviousVersions != nil { + for name, version := range options.PreviousVersions { + ctx.Resolver.SetPreviousVersion(name, version) + } + } + + providerToUse, detectedProviderName := selectProvider(ctx, config, allProviders) + ctx.Metadata.Set("providers", detectedProviderName) + + if providerToUse != nil { + if err := providerToUse.Plan(ctx); err != nil { + log.LogError("%s", err.Error()) + return &core.BuildResult{Success: false, Logs: log.Logs} + } + } + + // Support apps that declare a start command in a Procfile. + procfileProvider := &procfile.ProcfileProvider{} + if _, err := procfileProvider.Plan(ctx); err != nil { + log.LogError("%s", err.Error()) + return &core.BuildResult{Success: false, Logs: log.Logs} + } + + buildPlan, resolvedPackages, err := ctx.Generate() + if err != nil { + log.LogError("%s", err.Error()) + return &core.BuildResult{Success: false, Logs: log.Logs} + } + + if providerToUse != nil { + providerToUse.CleansePlan(buildPlan) + } + + if !core.ValidatePlan(buildPlan, a, log, &core.ValidatePlanOptions{ + ErrorMissingStartCommand: options.ErrorMissingStartCommand, + ProviderToUse: providerToUse, + }) { + return &core.BuildResult{Success: false, Logs: log.Logs} + } + + return &core.BuildResult{ + RailpackVersion: options.RailpackVersion, + Plan: buildPlan, + ResolvedPackages: resolvedPackages, + Metadata: ctx.Metadata.Properties, + DetectedProviders: []string{detectedProviderName}, + Logs: log.Logs, + Success: true, + } +} + +// selectProvider mirrors railpack's unexported core.getProviders, but takes the +// provider list as a parameter instead of hardcoding GetLanguageProviders(). +// This is the single hook the PR was trying to add upstream. +// +// Adapted from github.com/railwayapp/railpack v0.23.0 core.getProviders. +func selectProvider(ctx *generate.GenerateContext, config *c.Config, allProviders []providers.Provider) (providers.Provider, string) { + var providerToUse providers.Provider + var detectedProvider string + + // Detect regardless of an explicit config.Provider so we can still report + // what kind of app this is. + for _, provider := range allProviders { + matched, err := provider.Detect(ctx) + if err != nil { + ctx.Logger.LogWarn("Failed to detect provider `%s`: %s", provider.Name(), err.Error()) + continue + } + + if matched { + detectedProvider = provider.Name() + + if config.Provider == nil { + if err := provider.Initialize(ctx); err != nil { + ctx.Logger.LogWarn("Failed to initialize provider `%s`: %s", provider.Name(), err.Error()) + continue + } + ctx.Logger.LogInfo("Detected %s", capitalizeFirst(provider.Name())) + providerToUse = provider + } + break + } + } + + if config.Provider != nil { + provider := providerByName(*config.Provider, allProviders) + if provider == nil { + ctx.Logger.LogWarn("Provider `%s` not found", *config.Provider) + return providerToUse, detectedProvider + } + if err := provider.Initialize(ctx); err != nil { + ctx.Logger.LogWarn("Failed to initialize provider `%s`: %s", *config.Provider, err.Error()) + return providerToUse, detectedProvider + } + ctx.Logger.LogInfo("Using provider %s from config", capitalizeFirst(*config.Provider)) + providerToUse = provider + } + + return providerToUse, detectedProvider +} + +// providerByName looks up a provider by name (case-insensitive). Replaces +// providers.GetProvider, which only searches railpack's built-in list. +func providerByName(name string, list []providers.Provider) providers.Provider { + for _, p := range list { + if strings.EqualFold(p.Name(), name) { + return p + } + } + return nil +} + +// capitalizeFirst reimplements railpack's internal/utils.CapitalizeFirst, which +// lives under an internal/ path and cannot be imported from here. +func capitalizeFirst(s string) string { + if s == "" { + return s + } + return strings.ToUpper(s[:1]) + s[1:] +} + +// sugapackProviders returns the provider list used for plan generation. It +// mirrors railpack's providers.GetLanguageProviders() with the Node provider +// swapped for sugapack's provenance-emitting wrapper. The list is reproduced +// explicitly (rather than mutating the built-in slice) so the ordering — which +// determines detection priority — is visible and any upstream drift shows up as +// a compile error. Keep in sync with GetLanguageProviders when bumping railpack. +func sugapackProviders() []providers.Provider { + return []providers.Provider{ + &php.PhpProvider{}, + &golang.GoProvider{}, + &java.JavaProvider{}, + &rust.RustProvider{}, + &ruby.RubyProvider{}, + &elixir.ElixirProvider{}, + &python.PythonProvider{}, + &deno.DenoProvider{}, + &dotnet.DotnetProvider{}, + &nodeProvenanceProvider{}, // wraps node.NodeProvider + &gleam.GleamProvider{}, + &cpp.CppProvider{}, + &staticfile.StaticfileProvider{}, + &shell.ShellProvider{}, + } +} diff --git a/go.mod b/go.mod index 5b62be9..160a861 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/moby/buildkit v0.28.1 github.com/opencontainers/image-spec v1.1.1 github.com/railwayapp/railpack v0.23.0 + github.com/stretchr/testify v1.11.1 github.com/urfave/cli/v3 v3.8.0 ) @@ -32,6 +33,7 @@ require ( github.com/containerd/platforms v1.0.0-rc.3 // indirect github.com/containerd/ttrpc v1.2.8 // indirect github.com/containerd/typeurl/v2 v2.2.3 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/distribution/reference v0.6.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-logfmt/logfmt v0.6.1 // indirect @@ -56,6 +58,7 @@ require ( github.com/opencontainers/go-digest v1.0.0 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/secure-systems-lab/go-securesystemslib v0.10.0 // indirect github.com/shibumi/go-pathspec v1.3.0 // indirect diff --git a/planner.go b/planner.go index 2968a8e..1c1fb2d 100644 --- a/planner.go +++ b/planner.go @@ -38,7 +38,7 @@ func runPlanner(opts PlannerOptions) error { StartCommand: opts.StartCmd, } - result := core.GenerateBuildPlan(a, env, genOpts) + result := generateBuildPlan(a, env, genOpts, sugapackProviders()) printRailpackLogs(os.Stderr, result.Logs) if !result.Success { return fmt.Errorf("plan generation failed: %s", railpackErrorSummary(result.Logs)) diff --git a/provider_node.go b/provider_node.go new file mode 100644 index 0000000..0906155 --- /dev/null +++ b/provider_node.go @@ -0,0 +1,93 @@ +package main + +import ( + "fmt" + + "github.com/railwayapp/railpack/core/generate" + "github.com/railwayapp/railpack/core/providers/node" +) + +// nodeProvenanceProvider wraps railpack's node.NodeProvider to add "decision +// provenance": after the wrapped provider builds its plan, it explains *why* the +// app was classified the way it was and warns when a project that likely wants a +// server was deployed as a static SPA (e.g. a TanStack Start app with no `start` +// script — the motivating case from railpack PR #588). +// +// This is a prototype demonstrating that providers can be extended entirely from +// sugapack: it embeds the built-in provider (so Detect/Initialize/CleansePlan/ +// etc. are inherited unchanged) and only reads *exported* railpack surface +// (Metadata, Config.Deploy, GetPackageJson). No railpack patch required. +type nodeProvenanceProvider struct { + node.NodeProvider +} + +// Plan runs the wrapped provider's planning, then annotates provenance. +func (p *nodeProvenanceProvider) Plan(ctx *generate.GenerateContext) error { + if err := p.NodeProvider.Plan(ctx); err != nil { + return err + } + p.recordProvenance(ctx) + return nil +} + +// recordProvenance inspects the metadata the wrapped provider already set +// (nodeIsSPA, nodeSPAFramework — see node.SetNodeMetadata) plus the user's +// configured start command and package.json, and surfaces an explanation. +func (p *nodeProvenanceProvider) recordProvenance(ctx *generate.GenerateContext) { + meta := ctx.Metadata + + if meta.Properties["nodeIsSPA"] != "true" { + meta.Set("provenance.node.classification", "server") + return + } + + framework := meta.Properties["nodeSPAFramework"] + meta.Set("provenance.node.classification", "spa") + if framework != "" { + meta.Set("provenance.node.spaFramework", framework) + } + + // A custom start command disables SPA classification (node.isSPA), so if we + // reached the SPA branch, either no start command was found or an output dir + // was forced via RAILPACK_SPA_OUTPUT_DIR. + forcedOutputDir, _ := ctx.Env.GetConfigVariable(node.OUTPUT_DIR_VAR) + + var reason string + switch { + case forcedOutputDir != "": + reason = fmt.Sprintf("RAILPACK_%s is set (%q), forcing a static SPA deploy.", node.OUTPUT_DIR_VAR, forcedOutputDir) + case framework != "": + reason = fmt.Sprintf("Detected the %s static-site framework and found no start command, so the app is served as a static SPA via Caddy.", framework) + default: + reason = "No start command was found, so the app is served as a static SPA via Caddy." + } + meta.Set("provenance.node.reason", reason) + ctx.Logger.LogInfo("Build decision: %s", reason) + + // The motivating case: a framework that can run a server was built as a SPA + // only because it had no start command. Nudge the user toward the fix. + if forcedOutputDir == "" && serverCapableFramework(ctx, p) { + hint := "This project uses a framework that can run as a server (e.g. TanStack Start), " + + "but was built as a static SPA because no `start` script was found. " + + "Add a \"start\" script to package.json (or set RAILPACK_NO_SPA=1) to deploy it as a server." + meta.Set("provenance.node.hint", hint) + ctx.Logger.LogWarn("%s", hint) + } +} + +// serverCapableFramework reports whether the project depends on a framework that +// commonly runs its own server but can also be built as a static SPA. Reads +// package.json via the wrapped provider's exported GetPackageJson. +func serverCapableFramework(ctx *generate.GenerateContext, p *nodeProvenanceProvider) bool { + pkg, err := p.NodeProvider.GetPackageJson(ctx.App) + if err != nil || pkg == nil { + return false + } + for dep := range pkg.Dependencies { + switch dep { + case "@tanstack/react-start", "@tanstack/start": + return true + } + } + return false +} diff --git a/provider_node_test.go b/provider_node_test.go new file mode 100644 index 0000000..4cada57 --- /dev/null +++ b/provider_node_test.go @@ -0,0 +1,74 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + + "github.com/railwayapp/railpack/core" + "github.com/railwayapp/railpack/core/app" + "github.com/stretchr/testify/require" +) + +// writeFixture writes a minimal app source tree and returns its directory. +func writeFixture(t *testing.T, files map[string]string) string { + t.Helper() + dir := t.TempDir() + for name, content := range files { + path := filepath.Join(dir, name) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte(content), 0o644)) + } + return dir +} + +func planFor(t *testing.T, dir string) *core.BuildResult { + t.Helper() + a, err := app.NewApp(dir) + require.NoError(t, err) + env := app.NewEnvironment(nil) + result := generateBuildPlan(a, env, &core.GenerateBuildPlanOptions{}, sugapackProviders()) + printRailpackLogs(os.Stderr, result.Logs) + return result +} + +// A TanStack Start app (Vite-based) with no `start` script is classified as a +// static SPA. The provenance wrapper should explain why and warn that a +// server-capable framework was built as a SPA. +func TestNodeProvenance_TanStackStartTreatedAsSPA(t *testing.T) { + dir := writeFixture(t, map[string]string{ + "package.json": `{ + "name": "tanstack-app", + "scripts": { "build": "vite build" }, + "dependencies": { "vite": "^5.0.0", "@tanstack/react-start": "^1.0.0" } + }`, + "vite.config.ts": `export default {}`, + }) + + result := planFor(t, dir) + require.True(t, result.Success, "plan generation should succeed") + + require.Equal(t, "spa", result.Metadata["provenance.node.classification"]) + require.Equal(t, "vite", result.Metadata["provenance.node.spaFramework"]) + require.Contains(t, result.Metadata["provenance.node.reason"], "static SPA") + require.Contains(t, result.Metadata["provenance.node.hint"], "start") +} + +// A plain Node server app (start script, no static framework) should be +// classified as a server with no SPA hint. +func TestNodeProvenance_ServerApp(t *testing.T) { + dir := writeFixture(t, map[string]string{ + "package.json": `{ + "name": "server-app", + "scripts": { "start": "node index.js" }, + "dependencies": { "express": "^4.0.0" } + }`, + "index.js": `require("express")()`, + }) + + result := planFor(t, dir) + require.True(t, result.Success, "plan generation should succeed") + + require.Equal(t, "server", result.Metadata["provenance.node.classification"]) + require.Empty(t, result.Metadata["provenance.node.hint"]) +}