feat: L0b bootstrap scripts + L1 Go CLI structure
L0b (factory bootstrap): - bootstrap.ps1 (Windows): pre-checks, interactive config, clone .factory, home.toml - bootstrap.sh (Linux/Mac): same functionality for bash - Idempotent re-run with home.toml verification L1 (Go CLI): - factory.yaml: manifest schema with components, deps, health-checks - cmd/factory: Cobra CLI (install/update/status/diagnose) - pkg/config: home.toml reader - pkg/manifest: factory.yaml parser + topological sort - pkg/health: health-check logic (file/dir/mcp_tool/command) Scripts: - build.sh: cross-compile for linux/win/darwin × amd64/arm64 - build.ps1: PowerShell cross-compile Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
57
pkg/config/home.go
Normal file
57
pkg/config/home.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
)
|
||||
|
||||
type HomeConfig struct {
|
||||
ProjectsDir string `toml:"projects_dir"`
|
||||
Client string `toml:"client"`
|
||||
InstalledAt time.Time `toml:"installed_at"`
|
||||
}
|
||||
|
||||
func GetHomePath() string {
|
||||
homeDir, _ := os.UserHomeDir()
|
||||
return filepath.Join(homeDir, ".config", "factory", "home.toml")
|
||||
}
|
||||
|
||||
func LoadHome() (*HomeConfig, error) {
|
||||
path := GetHomePath()
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("home.toml not found at %s (run L0b bootstrap first)", path)
|
||||
}
|
||||
|
||||
var cfg HomeConfig
|
||||
if err := toml.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse home.toml: %w", err)
|
||||
}
|
||||
|
||||
// Validate
|
||||
if cfg.ProjectsDir == "" {
|
||||
return nil, fmt.Errorf("projects_dir is required in home.toml")
|
||||
}
|
||||
if cfg.Client == "" {
|
||||
cfg.Client = "claude-code"
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func (h *HomeConfig) GetFactoryDir() string {
|
||||
return filepath.Join(h.ProjectsDir, ".factory")
|
||||
}
|
||||
|
||||
func (h *HomeConfig) GetManifestPath() string {
|
||||
return filepath.Join(h.GetFactoryDir(), "factory.yaml")
|
||||
}
|
||||
|
||||
func (h *HomeConfig) GetCommonDir() string {
|
||||
return filepath.Join(h.ProjectsDir, ".common")
|
||||
}
|
||||
131
pkg/health/health.go
Normal file
131
pkg/health/health.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package health
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/OpeItcLoc03/factory/pkg/manifest"
|
||||
)
|
||||
|
||||
type Result struct {
|
||||
Name string `json:"name"`
|
||||
Healthy bool `json:"healthy"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
func Check(comp *manifest.Component, vars map[string]string) (*Result, error) {
|
||||
if comp.HealthCheck == nil {
|
||||
return &Result{
|
||||
Name: comp.Name,
|
||||
Healthy: true,
|
||||
Message: "no health check defined",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Substitute variables in path
|
||||
path := comp.HealthCheck.Path
|
||||
for k, v := range vars {
|
||||
path = filepath.Clean(strings.ReplaceAll(path, "{"+k+"}", v))
|
||||
}
|
||||
|
||||
switch comp.HealthCheck.Type {
|
||||
case "file_exists":
|
||||
return checkFileExists(comp.Name, path)
|
||||
case "directory_exists":
|
||||
return checkDirExists(comp.Name, path)
|
||||
case "mcp_tool":
|
||||
return checkMCPTool(comp.Name, comp.HealthCheck.Tool)
|
||||
case "command":
|
||||
return checkCommand(comp.Name, comp.HealthCheck.Command)
|
||||
default:
|
||||
return &Result{
|
||||
Name: comp.Name,
|
||||
Healthy: false,
|
||||
Message: fmt.Sprintf("unknown health check type: %s", comp.HealthCheck.Type),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func checkFileExists(name, path string) (*Result, error) {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return &Result{Name: name, Healthy: true}, nil
|
||||
}
|
||||
return &Result{
|
||||
Name: name,
|
||||
Healthy: false,
|
||||
Message: fmt.Sprintf("file not found: %s", path),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func checkDirExists(name, path string) (*Result, error) {
|
||||
if info, err := os.Stat(path); err == nil && info.IsDir() {
|
||||
return &Result{Name: name, Healthy: true}, nil
|
||||
}
|
||||
return &Result{
|
||||
Name: name,
|
||||
Healthy: false,
|
||||
Message: fmt.Sprintf("directory not found: %s", path),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func checkMCPTool(name, tool string) (*Result, error) {
|
||||
// Check if tool is available in ~/.claude.json
|
||||
home, _ := os.UserHomeDir()
|
||||
configPath := filepath.Join(home, ".claude.json")
|
||||
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
return &Result{
|
||||
Name: name,
|
||||
Healthy: false,
|
||||
Message: "claude.json not found",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Simple string check (proper parsing would be better)
|
||||
toolKey := `"` + tool + `"`
|
||||
if strings.Contains(string(data), toolKey) {
|
||||
return &Result{Name: name, Healthy: true}, nil
|
||||
}
|
||||
|
||||
return &Result{
|
||||
Name: name,
|
||||
Healthy: false,
|
||||
Message: fmt.Sprintf("MCP tool not found: %s", tool),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func checkCommand(name, cmd string) (*Result, error) {
|
||||
var shell string
|
||||
var args []string
|
||||
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
shell = "pwsh"
|
||||
args = []string{"-Command", cmd}
|
||||
default:
|
||||
shell = "bash"
|
||||
args = []string{"-c", cmd}
|
||||
}
|
||||
|
||||
execCmd := exec.Command(shell, args...)
|
||||
output, err := execCmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return &Result{
|
||||
Name: name,
|
||||
Healthy: false,
|
||||
Message: fmt.Sprintf("command failed: %s", output),
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &Result{
|
||||
Name: name,
|
||||
Healthy: true,
|
||||
Message: strings.TrimSpace(string(output)),
|
||||
}, nil
|
||||
}
|
||||
123
pkg/manifest/manifest.go
Normal file
123
pkg/manifest/manifest.go
Normal file
@@ -0,0 +1,123 @@
|
||||
package manifest
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/goccy/go-yaml"
|
||||
)
|
||||
|
||||
type GitSource struct {
|
||||
Type string `yaml:"type"`
|
||||
URL string `yaml:"url"`
|
||||
Branch string `yaml:"branch"`
|
||||
}
|
||||
|
||||
type PostInstall struct {
|
||||
Shell string `yaml:"shell"`
|
||||
Script string `yaml:"script"`
|
||||
ShellAlt string `yaml:"shell_alt"`
|
||||
ScriptAlt string `yaml:"script_alt"`
|
||||
}
|
||||
|
||||
type HealthCheck struct {
|
||||
Type string `yaml:"type"`
|
||||
Path string `yaml:"path"`
|
||||
Tool string `yaml:"tool"`
|
||||
Command string `yaml:"command"`
|
||||
}
|
||||
|
||||
type Component struct {
|
||||
Name string `yaml:"name"`
|
||||
DisplayName string `yaml:"display_name"`
|
||||
Source *GitSource `yaml:"source"`
|
||||
Target string `yaml:"target"`
|
||||
Deps []string `yaml:"deps"`
|
||||
SetupSkill string `yaml:"setup_skill"`
|
||||
PostInstall *PostInstall `yaml:"post_install"`
|
||||
HealthCheck *HealthCheck `yaml:"health_check"`
|
||||
Optional bool `yaml:"optional"`
|
||||
Clients []string `yaml:"clients"`
|
||||
}
|
||||
|
||||
type Manifest struct {
|
||||
Version string `yaml:"version"`
|
||||
GitHost string `yaml:"git_host"`
|
||||
GitOrg string `yaml:"git_org"`
|
||||
Components []Component `yaml:"components"`
|
||||
}
|
||||
|
||||
func Load(path string, vars map[string]string) (*Manifest, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read manifest: %w", err)
|
||||
}
|
||||
|
||||
// Substitute variables
|
||||
content := string(data)
|
||||
for k, v := range vars {
|
||||
content = strings.ReplaceAll(content, "{"+k+"}", v)
|
||||
}
|
||||
|
||||
var m Manifest
|
||||
if err := yaml.Unmarshal([]byte(content), &m); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse manifest: %w", err)
|
||||
}
|
||||
|
||||
// Set defaults
|
||||
if m.GitHost == "" {
|
||||
m.GitHost = "https://github.com"
|
||||
}
|
||||
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
func (m *Manifest) GetComponent(name string) (*Component, bool) {
|
||||
for i := range m.Components {
|
||||
if m.Components[i].Name == name {
|
||||
return &m.Components[i], true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (m *Manifest) GetDependencyOrder() []string {
|
||||
// Topological sort for component installation order
|
||||
visited := make(map[string]bool)
|
||||
order := []string{}
|
||||
|
||||
var visit func(string)
|
||||
visit = func(name string) {
|
||||
if visited[name] {
|
||||
return
|
||||
}
|
||||
visited[name] = true
|
||||
|
||||
c, ok := m.GetComponent(name)
|
||||
if ok {
|
||||
for _, dep := range c.Deps {
|
||||
visit(dep)
|
||||
}
|
||||
}
|
||||
order = append(order, name)
|
||||
}
|
||||
|
||||
for _, c := range m.Components {
|
||||
visit(c.Name)
|
||||
}
|
||||
|
||||
return order
|
||||
}
|
||||
|
||||
func (c *Component) IsApplicable(client string) bool {
|
||||
if len(c.Clients) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, cl := range c.Clients {
|
||||
if cl == client {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user