508 lines
14 KiB
Go
508 lines
14 KiB
Go
package build
|
|
|
|
import (
|
|
"dappco.re/go/core"
|
|
"dappco.re/go/core/build/internal/ax"
|
|
"dappco.re/go/core/io"
|
|
"strings"
|
|
)
|
|
|
|
// Marker files for project type detection.
|
|
const (
|
|
markerGoMod = "go.mod"
|
|
markerGoWork = "go.work"
|
|
markerWails = "wails.json"
|
|
markerNodePackage = "package.json"
|
|
markerComposer = "composer.json"
|
|
markerMkDocs = "mkdocs.yml"
|
|
markerMkDocsYAML = "mkdocs.yaml"
|
|
markerDocsMkDocs = "docs/mkdocs.yml"
|
|
markerDocsMkDocsYAML = "docs/mkdocs.yaml"
|
|
markerPyProject = "pyproject.toml"
|
|
markerRequirements = "requirements.txt"
|
|
markerCargo = "Cargo.toml"
|
|
markerDockerfile = "Dockerfile"
|
|
markerFrontendPackage = "frontend/package.json"
|
|
markerFrontendDenoJSON = "frontend/deno.json"
|
|
markerFrontendDenoJSONC = "frontend/deno.jsonc"
|
|
markerLinuxKitYAML = "linuxkit.yml"
|
|
markerLinuxKitYAMLAlt = "linuxkit.yaml"
|
|
markerTaskfileYML = "Taskfile.yml"
|
|
markerTaskfileYAML = "Taskfile.yaml"
|
|
markerTaskfileBare = "Taskfile"
|
|
markerTaskfileLowerYML = "taskfile.yml"
|
|
markerTaskfileLowerYAML = "taskfile.yaml"
|
|
markerLinuxKitNestedYML = ".core/linuxkit/*.yml"
|
|
markerLinuxKitNestedYAML = ".core/linuxkit/*.yaml"
|
|
)
|
|
|
|
// projectMarker maps a marker file to its project type.
|
|
type projectMarker struct {
|
|
file string
|
|
projectType ProjectType
|
|
}
|
|
|
|
// markers defines the detection order. More specific types come first.
|
|
// Wails projects have both wails.json and go.mod, so wails is checked first.
|
|
var markers = []projectMarker{
|
|
{markerWails, ProjectTypeWails},
|
|
{markerGoMod, ProjectTypeGo},
|
|
{markerGoWork, ProjectTypeGo},
|
|
{markerNodePackage, ProjectTypeNode},
|
|
{markerComposer, ProjectTypePHP},
|
|
{markerMkDocs, ProjectTypeDocs},
|
|
{markerMkDocsYAML, ProjectTypeDocs},
|
|
{markerDocsMkDocs, ProjectTypeDocs},
|
|
{markerDocsMkDocsYAML, ProjectTypeDocs},
|
|
{markerPyProject, ProjectTypePython},
|
|
{markerRequirements, ProjectTypePython},
|
|
{markerCargo, ProjectTypeRust},
|
|
}
|
|
|
|
// Discover detects project types in the given directory by checking for marker files.
|
|
// Returns a slice of detected project types, ordered by priority (most specific first).
|
|
// For example, a Wails project returns [wails, go] since it has both wails.json and go.mod.
|
|
//
|
|
// types, err := build.Discover(io.Local, "/home/user/my-project") // → [go]
|
|
func Discover(fs io.Medium, dir string) ([]ProjectType, error) {
|
|
var detected []ProjectType
|
|
|
|
for _, m := range markers {
|
|
path := ax.Join(dir, m.file)
|
|
if fileExists(fs, path) {
|
|
// Avoid duplicates (shouldn't happen with current markers, but defensive)
|
|
if !core.NewArray(detected...).Contains(m.projectType) {
|
|
detected = append(detected, m.projectType)
|
|
}
|
|
}
|
|
}
|
|
|
|
additionalTypes := []struct {
|
|
projectType ProjectType
|
|
detected bool
|
|
}{
|
|
{ProjectTypeNode, IsNodeProject(fs, dir) || HasSubtreeNpm(fs, dir)},
|
|
{ProjectTypeDocs, IsMkDocsProject(fs, dir)},
|
|
{ProjectTypeDocker, IsDockerProject(fs, dir)},
|
|
{ProjectTypeLinuxKit, IsLinuxKitProject(fs, dir)},
|
|
{ProjectTypeCPP, IsCPPProject(fs, dir)},
|
|
{ProjectTypeTaskfile, IsTaskfileProject(fs, dir)},
|
|
}
|
|
for _, candidate := range additionalTypes {
|
|
if candidate.detected && !core.NewArray(detected...).Contains(candidate.projectType) {
|
|
detected = append(detected, candidate.projectType)
|
|
}
|
|
}
|
|
|
|
return detected, nil
|
|
}
|
|
|
|
// PrimaryType returns the most specific project type detected in the directory.
|
|
// Returns empty string if no project type is detected.
|
|
//
|
|
// pt, err := build.PrimaryType(io.Local, ".") // → "go"
|
|
func PrimaryType(fs io.Medium, dir string) (ProjectType, error) {
|
|
types, err := Discover(fs, dir)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if len(types) == 0 {
|
|
return "", nil
|
|
}
|
|
return types[0], nil
|
|
}
|
|
|
|
// IsGoProject checks if the directory contains a Go project (go.mod, go.work, or wails.json).
|
|
//
|
|
// if build.IsGoProject(io.Local, ".") { ... }
|
|
func IsGoProject(fs io.Medium, dir string) bool {
|
|
return fileExists(fs, ax.Join(dir, markerGoMod)) ||
|
|
fileExists(fs, ax.Join(dir, markerGoWork)) ||
|
|
fileExists(fs, ax.Join(dir, markerWails))
|
|
}
|
|
|
|
// IsWailsProject checks if the directory contains a Wails project.
|
|
//
|
|
// if build.IsWailsProject(io.Local, ".") { ... }
|
|
func IsWailsProject(fs io.Medium, dir string) bool {
|
|
return fileExists(fs, ax.Join(dir, markerWails))
|
|
}
|
|
|
|
// IsNodeProject checks if the directory contains a Node.js project.
|
|
//
|
|
// if build.IsNodeProject(io.Local, ".") { ... }
|
|
func IsNodeProject(fs io.Medium, dir string) bool {
|
|
return fileExists(fs, ax.Join(dir, markerNodePackage))
|
|
}
|
|
|
|
// IsPHPProject checks if the directory contains a PHP project.
|
|
//
|
|
// if build.IsPHPProject(io.Local, ".") { ... }
|
|
func IsPHPProject(fs io.Medium, dir string) bool {
|
|
return fileExists(fs, ax.Join(dir, markerComposer))
|
|
}
|
|
|
|
// IsCPPProject checks if the directory contains a C++ project (CMakeLists.txt).
|
|
//
|
|
// if build.IsCPPProject(io.Local, ".") { ... }
|
|
func IsCPPProject(fs io.Medium, dir string) bool {
|
|
return fileExists(fs, ax.Join(dir, "CMakeLists.txt"))
|
|
}
|
|
|
|
// IsMkDocsProject checks for MkDocs config at the project root or in docs/.
|
|
//
|
|
// ok := build.IsMkDocsProject(io.Local, ".")
|
|
func IsMkDocsProject(fs io.Medium, dir string) bool {
|
|
return ResolveMkDocsConfigPath(fs, dir) != ""
|
|
}
|
|
|
|
// ResolveMkDocsConfigPath returns the first MkDocs config path that exists.
|
|
//
|
|
// configPath := build.ResolveMkDocsConfigPath(io.Local, ".")
|
|
func ResolveMkDocsConfigPath(fs io.Medium, dir string) string {
|
|
for _, path := range []string{
|
|
ax.Join(dir, markerMkDocs),
|
|
ax.Join(dir, markerMkDocsYAML),
|
|
ax.Join(dir, "docs", "mkdocs.yml"),
|
|
ax.Join(dir, "docs", "mkdocs.yaml"),
|
|
} {
|
|
if fileExists(fs, path) {
|
|
return path
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// HasSubtreeNpm checks for package.json within depth 2 subdirectories.
|
|
// Ignores root package.json and node_modules directories.
|
|
// Returns true when a monorepo-style nested package.json is found.
|
|
//
|
|
// ok := build.HasSubtreeNpm(io.Local, ".") // true if apps/web/package.json exists
|
|
func HasSubtreeNpm(fs io.Medium, dir string) bool {
|
|
// Depth 1: list immediate subdirectories
|
|
entries, err := fs.List(dir)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
for _, entry := range entries {
|
|
if !entry.IsDir() {
|
|
continue
|
|
}
|
|
name := entry.Name()
|
|
if name == "node_modules" {
|
|
continue
|
|
}
|
|
|
|
subdir := ax.Join(dir, name)
|
|
|
|
// Depth 1: check subdir/package.json
|
|
if fileExists(fs, ax.Join(subdir, markerNodePackage)) {
|
|
return true
|
|
}
|
|
|
|
// Depth 2: list subdirectories of subdir
|
|
subEntries, err := fs.List(subdir)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for _, subEntry := range subEntries {
|
|
if !subEntry.IsDir() {
|
|
continue
|
|
}
|
|
if subEntry.Name() == "node_modules" {
|
|
continue
|
|
}
|
|
nested := ax.Join(subdir, subEntry.Name())
|
|
if fileExists(fs, ax.Join(nested, markerNodePackage)) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// IsPythonProject checks for pyproject.toml or requirements.txt at the project root.
|
|
//
|
|
// ok := build.IsPythonProject(io.Local, ".")
|
|
func IsPythonProject(fs io.Medium, dir string) bool {
|
|
return fileExists(fs, ax.Join(dir, markerPyProject)) ||
|
|
fileExists(fs, ax.Join(dir, markerRequirements))
|
|
}
|
|
|
|
// IsRustProject checks for Cargo.toml at the project root.
|
|
//
|
|
// ok := build.IsRustProject(io.Local, ".")
|
|
func IsRustProject(fs io.Medium, dir string) bool {
|
|
return fileExists(fs, ax.Join(dir, markerCargo))
|
|
}
|
|
|
|
// DiscoveryResult holds the full project analysis from DiscoverFull().
|
|
//
|
|
// result, err := build.DiscoverFull(io.Local, ".")
|
|
// fmt.Println(result.PrimaryStack) // "wails"
|
|
type DiscoveryResult struct {
|
|
// Types lists all detected project types in priority order.
|
|
Types []ProjectType
|
|
// PrimaryStack is the best stack suggestion based on detected types.
|
|
PrimaryStack string
|
|
// HasFrontend is true when a root or frontend/ package.json/deno manifest is found,
|
|
// or when a nested frontend tree is detected.
|
|
HasFrontend bool
|
|
// HasSubtreeNpm is true when a nested package.json exists within depth 2.
|
|
HasSubtreeNpm bool
|
|
// Markers records the presence of each raw marker file checked.
|
|
Markers map[string]bool
|
|
// Distro holds the detected Linux distribution version (e.g., "24.04").
|
|
// Used by ComputeOptions to inject webkit2_41 tag on Ubuntu 24.04+.
|
|
Distro string
|
|
}
|
|
|
|
// DiscoverFull returns a rich discovery result with all markers and metadata.
|
|
//
|
|
// result, err := build.DiscoverFull(io.Local, ".")
|
|
// if result.HasFrontend { ... }
|
|
func DiscoverFull(fs io.Medium, dir string) (*DiscoveryResult, error) {
|
|
types, err := Discover(fs, dir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
result := &DiscoveryResult{
|
|
Types: types,
|
|
Markers: make(map[string]bool),
|
|
}
|
|
|
|
// Record raw marker presence
|
|
allMarkers := []string{
|
|
markerGoMod, markerGoWork, markerWails, markerNodePackage, markerComposer,
|
|
markerMkDocs, markerMkDocsYAML, markerDocsMkDocs, markerDocsMkDocsYAML,
|
|
markerPyProject, markerRequirements, markerCargo,
|
|
"CMakeLists.txt", markerDockerfile, "Containerfile", "dockerfile", "containerfile",
|
|
markerFrontendPackage, markerFrontendDenoJSON, markerFrontendDenoJSONC,
|
|
markerLinuxKitYAML, markerLinuxKitYAMLAlt,
|
|
markerTaskfileYML, markerTaskfileYAML, markerTaskfileBare,
|
|
markerTaskfileLowerYML, markerTaskfileLowerYAML,
|
|
}
|
|
for _, m := range allMarkers {
|
|
result.Markers[m] = fileExists(fs, ax.Join(dir, m))
|
|
}
|
|
|
|
// Pattern-based marker: LinuxKit configs may live in .core/linuxkit/*.yml or *.yaml.
|
|
result.Markers[markerLinuxKitNestedYML] = hasYAMLInDir(fs, ax.Join(dir, ".core", "linuxkit"))
|
|
result.Markers[markerLinuxKitNestedYAML] = result.Markers[markerLinuxKitNestedYML]
|
|
|
|
// Subtree npm detection
|
|
result.HasSubtreeNpm = HasSubtreeNpm(fs, dir)
|
|
|
|
// Frontend detection: root manifests, frontend/ manifests, or nested frontend trees.
|
|
result.HasFrontend = hasFrontendManifest(fs, dir) ||
|
|
hasFrontendManifest(fs, ax.Join(dir, "frontend")) ||
|
|
hasSubtreeFrontendManifest(fs, dir) ||
|
|
result.HasSubtreeNpm
|
|
|
|
result.Types = types
|
|
|
|
// Linux distro detection: used for distro-sensitive build flags.
|
|
result.Distro = detectDistroVersion(fs)
|
|
|
|
// Primary stack: first detected type as string, or empty
|
|
if len(types) > 0 {
|
|
result.PrimaryStack = string(types[0])
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// hasFrontendManifest reports whether a frontend directory contains a supported manifest.
|
|
func hasFrontendManifest(fs io.Medium, dir string) bool {
|
|
return fs.IsFile(ax.Join(dir, markerNodePackage)) ||
|
|
fs.IsFile(ax.Join(dir, "deno.json")) ||
|
|
fs.IsFile(ax.Join(dir, "deno.jsonc"))
|
|
}
|
|
|
|
// hasSubtreeFrontendManifest checks for package.json or deno.json within depth 2 subdirectories.
|
|
func hasSubtreeFrontendManifest(fs io.Medium, dir string) bool {
|
|
entries, err := fs.List(dir)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
for _, entry := range entries {
|
|
if !entry.IsDir() {
|
|
continue
|
|
}
|
|
name := entry.Name()
|
|
if name == "node_modules" || strings.HasPrefix(name, ".") {
|
|
continue
|
|
}
|
|
|
|
subdir := ax.Join(dir, name)
|
|
if hasFrontendManifest(fs, subdir) {
|
|
return true
|
|
}
|
|
|
|
subEntries, err := fs.List(subdir)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for _, subEntry := range subEntries {
|
|
if !subEntry.IsDir() {
|
|
continue
|
|
}
|
|
if subEntry.Name() == "node_modules" || strings.HasPrefix(subEntry.Name(), ".") {
|
|
continue
|
|
}
|
|
nested := ax.Join(subdir, subEntry.Name())
|
|
if hasFrontendManifest(fs, nested) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// fileExists checks if a file exists and is not a directory.
|
|
func fileExists(fs io.Medium, path string) bool {
|
|
return fs.IsFile(path)
|
|
}
|
|
|
|
// ResolveDockerfilePath returns the first Docker manifest path that exists.
|
|
//
|
|
// dockerfile := build.ResolveDockerfilePath(io.Local, ".")
|
|
func ResolveDockerfilePath(fs io.Medium, dir string) string {
|
|
for _, path := range []string{
|
|
ax.Join(dir, "Dockerfile"),
|
|
ax.Join(dir, "Containerfile"),
|
|
ax.Join(dir, "dockerfile"),
|
|
ax.Join(dir, "containerfile"),
|
|
} {
|
|
if fileExists(fs, path) {
|
|
return path
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// IsDockerProject checks if the directory contains a Dockerfile or Containerfile.
|
|
//
|
|
// if build.IsDockerProject(io.Local, ".") { ... }
|
|
func IsDockerProject(fs io.Medium, dir string) bool {
|
|
return ResolveDockerfilePath(fs, dir) != ""
|
|
}
|
|
|
|
// IsLinuxKitProject checks for linuxkit.yml or .core/linuxkit/*.yml.
|
|
//
|
|
// ok := build.IsLinuxKitProject(io.Local, ".")
|
|
func IsLinuxKitProject(fs io.Medium, dir string) bool {
|
|
if fileExists(fs, ax.Join(dir, markerLinuxKitYAML)) ||
|
|
fileExists(fs, ax.Join(dir, markerLinuxKitYAMLAlt)) {
|
|
return true
|
|
}
|
|
return hasYAMLInDir(fs, ax.Join(dir, ".core", "linuxkit"))
|
|
}
|
|
|
|
// IsTaskfileProject checks for supported Taskfile names in the project root.
|
|
//
|
|
// ok := build.IsTaskfileProject(io.Local, ".")
|
|
func IsTaskfileProject(fs io.Medium, dir string) bool {
|
|
for _, name := range []string{
|
|
markerTaskfileYML,
|
|
markerTaskfileYAML,
|
|
markerTaskfileBare,
|
|
markerTaskfileLowerYML,
|
|
markerTaskfileLowerYAML,
|
|
} {
|
|
if fileExists(fs, ax.Join(dir, name)) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// hasYAMLInDir reports whether a directory contains at least one YAML file.
|
|
func hasYAMLInDir(fs io.Medium, dir string) bool {
|
|
if !fs.IsDir(dir) {
|
|
return false
|
|
}
|
|
|
|
entries, err := fs.List(dir)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
for _, entry := range entries {
|
|
if entry.IsDir() {
|
|
continue
|
|
}
|
|
name := strings.ToLower(entry.Name())
|
|
if strings.HasSuffix(name, ".yml") || strings.HasSuffix(name, ".yaml") {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// detectDistroVersion extracts the Ubuntu VERSION_ID from os-release data.
|
|
func detectDistroVersion(fs io.Medium) string {
|
|
if fs == nil {
|
|
return ""
|
|
}
|
|
|
|
for _, path := range []string{"/etc/os-release", "/usr/lib/os-release"} {
|
|
content, err := fs.Read(path)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
if distro := parseOSReleaseDistro(content); distro != "" {
|
|
return distro
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
// parseOSReleaseDistro returns VERSION_ID for Ubuntu-style os-release content.
|
|
func parseOSReleaseDistro(content string) string {
|
|
var id string
|
|
var idLike string
|
|
var version string
|
|
|
|
for _, line := range strings.Split(content, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" || strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
|
|
key, value, ok := strings.Cut(line, "=")
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
key = strings.TrimSpace(key)
|
|
value = strings.Trim(strings.TrimSpace(value), `"'`)
|
|
|
|
switch key {
|
|
case "ID":
|
|
id = value
|
|
case "ID_LIKE":
|
|
idLike = value
|
|
case "VERSION_ID":
|
|
version = value
|
|
}
|
|
}
|
|
|
|
if version == "" {
|
|
return ""
|
|
}
|
|
|
|
if id == "ubuntu" || strings.Contains(" "+idLike+" ", " ubuntu ") {
|
|
return version
|
|
}
|
|
|
|
return ""
|
|
}
|