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:
2026-05-06 23:59:09 +03:00
parent 47b41aa2f7
commit 561a7fd66f
12 changed files with 1380 additions and 43 deletions

57
pkg/config/home.go Normal file
View 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
View 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
View 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
}