From 5b8893550130ad8ffe39a6523a11994757493691 Mon Sep 17 00:00:00 2001 From: Clawd Date: Sun, 15 Feb 2026 18:47:15 -0800 Subject: feat(v2): add output and detect packages - internal/output: JSON response types, error codes, exit codes, pretty output - internal/detect: auto-detection of project type (static/docker/binary) - PROGRESS.md: track rebuild progress Foundation for agent-first JSON interface per SPEC.md --- internal/detect/detect.go | 105 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 internal/detect/detect.go (limited to 'internal/detect') diff --git a/internal/detect/detect.go b/internal/detect/detect.go new file mode 100644 index 0000000..f3efd22 --- /dev/null +++ b/internal/detect/detect.go @@ -0,0 +1,105 @@ +// Package detect provides auto-detection of project types. +package detect + +import ( + "os" + "path/filepath" + + "github.com/bdw/ship/internal/output" +) + +// ProjectType represents the detected deployment type +type ProjectType string + +const ( + TypeStatic ProjectType = "static" + TypeDocker ProjectType = "docker" + TypeBinary ProjectType = "binary" +) + +// Result is the detection outcome +type Result struct { + Type ProjectType + Path string // Absolute path to deploy + Error *output.ErrorResponse +} + +// Detect examines a path and determines how to deploy it. +// Follows the logic from SPEC.md: +// - File: must be executable → binary +// - Directory with Dockerfile → docker +// - Directory with index.html → static +// - Go/Node without Dockerfile → error with guidance +// - Empty or unknown → error +func Detect(path string) Result { + absPath, err := filepath.Abs(path) + if err != nil { + return Result{Error: output.Err(output.ErrInvalidPath, "cannot resolve path: "+err.Error())} + } + + info, err := os.Stat(absPath) + if err != nil { + if os.IsNotExist(err) { + return Result{Error: output.Err(output.ErrInvalidPath, "path does not exist: "+path)} + } + return Result{Error: output.Err(output.ErrInvalidPath, "cannot access path: "+err.Error())} + } + + // File: must be executable binary + if !info.IsDir() { + return detectFile(absPath, info) + } + + // Directory: check contents + return detectDirectory(absPath) +} + +func detectFile(path string, info os.FileInfo) Result { + // Check if executable + if info.Mode()&0111 == 0 { + return Result{Error: output.Err(output.ErrUnknownProjectType, "file is not executable")} + } + return Result{Type: TypeBinary, Path: path} +} + +func detectDirectory(path string) Result { + // Check for Dockerfile first (highest priority) + if hasFile(path, "Dockerfile") { + return Result{Type: TypeDocker, Path: path} + } + + // Check for static site + if hasFile(path, "index.html") || hasFile(path, "index.htm") { + return Result{Type: TypeStatic, Path: path} + } + + // Check for Go project without Dockerfile + if hasFile(path, "go.mod") { + return Result{Error: output.Err(output.ErrUnknownProjectType, + "Go project without Dockerfile. Add a Dockerfile or build a binary first.")} + } + + // Check for Node project without Dockerfile + if hasFile(path, "package.json") { + return Result{Error: output.Err(output.ErrUnknownProjectType, + "Node project without Dockerfile. Add a Dockerfile.")} + } + + // Check if empty + entries, err := os.ReadDir(path) + if err != nil { + return Result{Error: output.Err(output.ErrInvalidPath, "cannot read directory: "+err.Error())} + } + if len(entries) == 0 { + return Result{Error: output.Err(output.ErrInvalidPath, "directory is empty")} + } + + // Unknown + return Result{Error: output.Err(output.ErrUnknownProjectType, + "cannot detect project type. Add a Dockerfile or index.html.")} +} + +func hasFile(dir, name string) bool { + _, err := os.Stat(filepath.Join(dir, name)) + return err == nil +} -- cgit v1.2.3