* feat(help): Add CLI help command Fixes #136 * chore: remove binary * feat(mcp): Add TCP transport Fixes #126 * feat(io): Migrate pkg/mcp to use Medium abstraction Fixes #103 * chore(io): Migrate internal/cmd/docs/* to Medium abstraction Fixes #113 * chore(io): Migrate internal/cmd/dev/* to Medium abstraction Fixes #114 * chore(io): Migrate internal/cmd/setup/* to Medium abstraction * chore(io): Complete migration of internal/cmd/dev/* to Medium abstraction * chore(io): Migrate internal/cmd/sdk, pkgcmd, and workspace to Medium abstraction * style: fix formatting in internal/variants Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor(io): simplify local Medium implementation Rewrote to match the simpler TypeScript pattern: - path() sanitizes and returns string directly - Each method calls path() once - No complex symlink validation - Less code, less attack surface Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * test(mcp): update sandboxing tests for simplified Medium The simplified io/local.Medium implementation: - Sanitizes .. to . (no error, path is cleaned) - Allows absolute paths through (caller validates if needed) - Follows symlinks (no traversal blocking) Update tests to match this simplified behavior. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(updater): resolve PkgVersion duplicate declaration Remove var PkgVersion from updater.go since go generate creates const PkgVersion in version.go. Track version.go in git to ensure builds work without running go generate first. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
176 lines
5.2 KiB
Go
176 lines
5.2 KiB
Go
// cmd_bootstrap.go implements bootstrap mode for new workspaces.
|
|
//
|
|
// Bootstrap mode is activated when no repos.yaml exists in the current
|
|
// directory or any parent. It clones core-devops first, then uses its
|
|
// repos.yaml to present the package wizard.
|
|
|
|
package setup
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/host-uk/core/internal/cmd/workspace"
|
|
"github.com/host-uk/core/pkg/i18n"
|
|
coreio "github.com/host-uk/core/pkg/io"
|
|
"github.com/host-uk/core/pkg/repos"
|
|
)
|
|
|
|
// runSetupOrchestrator decides between registry mode and bootstrap mode.
|
|
func runSetupOrchestrator(registryPath, only string, dryRun, all bool, projectName string, runBuild bool) error {
|
|
ctx := context.Background()
|
|
|
|
// Try to find an existing registry
|
|
var foundRegistry string
|
|
var err error
|
|
|
|
if registryPath != "" {
|
|
foundRegistry = registryPath
|
|
} else {
|
|
foundRegistry, err = repos.FindRegistry()
|
|
}
|
|
|
|
// If registry exists, use registry mode
|
|
if err == nil && foundRegistry != "" {
|
|
return runRegistrySetup(ctx, foundRegistry, only, dryRun, all, runBuild)
|
|
}
|
|
|
|
// No registry found - enter bootstrap mode
|
|
return runBootstrap(ctx, only, dryRun, all, projectName, runBuild)
|
|
}
|
|
|
|
// runBootstrap handles the case where no repos.yaml exists.
|
|
func runBootstrap(ctx context.Context, only string, dryRun, all bool, projectName string, runBuild bool) error {
|
|
cwd, err := os.Getwd()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get working directory: %w", err)
|
|
}
|
|
|
|
fmt.Printf("%s %s\n", dimStyle.Render(">>"), i18n.T("cmd.setup.bootstrap_mode"))
|
|
|
|
var targetDir string
|
|
|
|
// Check if current directory is empty
|
|
empty, err := isDirEmpty(cwd)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to check directory: %w", err)
|
|
}
|
|
|
|
if empty {
|
|
// Clone into current directory
|
|
targetDir = cwd
|
|
fmt.Printf("%s %s\n", dimStyle.Render(">>"), i18n.T("cmd.setup.cloning_current_dir"))
|
|
} else {
|
|
// Directory has content - check if it's a git repo root
|
|
isRepo := isGitRepoRoot(cwd)
|
|
|
|
if isRepo && isTerminal() && !all {
|
|
// Offer choice: setup working directory or create package
|
|
choice, err := promptSetupChoice()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get choice: %w", err)
|
|
}
|
|
|
|
if choice == "setup" {
|
|
// Setup this working directory with .core/ config
|
|
return runRepoSetup(cwd, dryRun)
|
|
}
|
|
// Otherwise continue to "create package" flow
|
|
}
|
|
|
|
// Create package flow - need a project name
|
|
if projectName == "" {
|
|
if !isTerminal() || all {
|
|
projectName = defaultOrg
|
|
} else {
|
|
projectName, err = promptProjectName(defaultOrg)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get project name: %w", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
targetDir = filepath.Join(cwd, projectName)
|
|
fmt.Printf("%s %s: %s\n", dimStyle.Render(">>"), i18n.T("cmd.setup.creating_project_dir"), projectName)
|
|
|
|
if !dryRun {
|
|
if err := coreio.Local.EnsureDir(targetDir); err != nil {
|
|
return fmt.Errorf("failed to create directory: %w", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Clone core-devops first
|
|
devopsPath := filepath.Join(targetDir, devopsRepo)
|
|
if _, err := coreio.Local.List(filepath.Join(devopsPath, ".git")); err != nil {
|
|
fmt.Printf("%s %s %s...\n", dimStyle.Render(">>"), i18n.T("common.status.cloning"), devopsRepo)
|
|
|
|
if !dryRun {
|
|
if err := gitClone(ctx, defaultOrg, devopsRepo, devopsPath); err != nil {
|
|
return fmt.Errorf("failed to clone %s: %w", devopsRepo, err)
|
|
}
|
|
fmt.Printf("%s %s %s\n", successStyle.Render(">>"), devopsRepo, i18n.T("cmd.setup.cloned"))
|
|
} else {
|
|
fmt.Printf(" %s %s/%s to %s\n", i18n.T("cmd.setup.would_clone"), defaultOrg, devopsRepo, devopsPath)
|
|
}
|
|
} else {
|
|
fmt.Printf("%s %s %s\n", dimStyle.Render(">>"), devopsRepo, i18n.T("cmd.setup.already_exists"))
|
|
}
|
|
|
|
// Load the repos.yaml from core-devops
|
|
registryPath := filepath.Join(devopsPath, devopsReposYaml)
|
|
|
|
if dryRun {
|
|
fmt.Printf("\n%s %s %s\n", dimStyle.Render(">>"), i18n.T("cmd.setup.would_load_registry"), registryPath)
|
|
return nil
|
|
}
|
|
|
|
reg, err := repos.LoadRegistry(registryPath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to load registry from %s: %w", devopsRepo, err)
|
|
}
|
|
|
|
// Override base path to target directory
|
|
reg.BasePath = targetDir
|
|
|
|
// Check workspace config for default_only if no filter specified
|
|
if only == "" {
|
|
if wsConfig, err := workspace.LoadConfig(devopsPath); err == nil && wsConfig != nil && len(wsConfig.DefaultOnly) > 0 {
|
|
only = strings.Join(wsConfig.DefaultOnly, ",")
|
|
}
|
|
}
|
|
|
|
// Now run the regular setup with the loaded registry
|
|
return runRegistrySetupWithReg(ctx, reg, registryPath, only, dryRun, all, runBuild)
|
|
}
|
|
|
|
// isGitRepoRoot returns true if the directory is a git repository root.
|
|
func isGitRepoRoot(path string) bool {
|
|
_, err := coreio.Local.List(filepath.Join(path, ".git"))
|
|
return err == nil
|
|
}
|
|
|
|
// isDirEmpty returns true if the directory is empty or contains only hidden files.
|
|
func isDirEmpty(path string) (bool, error) {
|
|
entries, err := coreio.Local.List(path)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
for _, e := range entries {
|
|
name := e.Name()
|
|
// Ignore common hidden/metadata files
|
|
if name == ".DS_Store" || name == ".git" || name == ".gitignore" {
|
|
continue
|
|
}
|
|
// Any other non-hidden file means directory is not empty
|
|
if len(name) > 0 && name[0] != '.' {
|
|
return false, nil
|
|
}
|
|
}
|
|
|
|
return true, nil
|
|
}
|