Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,10 @@ xf init ./my-project \
xf init ./existing-xf-project --existing
xf init ./existing-xf-project --existing --up

# Composer dependencies are installed automatically when the target
# tracks a composer.json (repository checkouts). Release packages ship
# vendor/ prebuilt and are skipped.

# .env overrides (file + inline; inline wins)
xf init ./my-project \
--env-file ./my.env \
Expand Down Expand Up @@ -215,6 +219,41 @@ xf compose exec xf mysql -u root
xf exec xf ls -la
```

### Worktrees

A worktree is a second checkout of the same repository on its own branch, with
its own Docker containers and database. Worktrees are created alongside the
source checkout: `~/Sites/main` gains `~/Sites/main.worktrees/<branch>`, named
after the branch's last segment.

By default `create` clones the source environment — database, `data/` and
`internal_data/` — and points the cloned board at its own URL, labelling its
title with the worktree name.

```bash
# Create a worktree and set up its environment
xf worktree create dev/24x/feature

# Branch from somewhere other than the current HEAD
xf worktree create dev/24x/feature --base main

# Create the worktree without setting anything up
xf worktree create dev/24x/feature --no-setup

# List worktrees (this project / all known projects)
xf worktree list
xf worktree list-all

# Print the path of a worktree (bare output, shell-substitution safe)
cd "$(xf worktree path dev/24x/feature)"

# Remove a worktree and its containers and volumes
xf worktree remove dev/24x/feature

# Drop registry entries for worktrees that no longer exist
xf worktree prune
```

### PHP / Composer / Debug

```bash
Expand Down
72 changes: 72 additions & 0 deletions cmd/xf/composerdetect_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package main

import (
"os"
"path/filepath"
"testing"
)

func TestShouldRunComposer(t *testing.T) {
t.Parallel()

tests := []struct {
name string
files []string
want bool
}{
{
name: "composer.json present",
files: []string{"composer.json"},
want: true,
},
{
name: "composer.json and lock present",
files: []string{"composer.json", "composer.lock"},
want: true,
},
{
name: "no composer files",
files: nil,
want: false,
},
{
name: "lock without json is not a composer project",
files: []string{"composer.lock"},
want: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

dir := t.TempDir()

for _, name := range tt.files {
if err := os.WriteFile(filepath.Join(dir, name), []byte("{}"), 0o600); err != nil {
t.Fatalf("write %s: %v", name, err)
}
}

if got := shouldRunComposer(dir); got != tt.want {
t.Errorf("shouldRunComposer = %v, want %v", got, tt.want)
}
})
}
}

// TestShouldRunComposerIgnoresDirectory guards against a directory named
// composer.json being mistaken for a manifest.
func TestShouldRunComposerIgnoresDirectory(t *testing.T) {
t.Parallel()

dir := t.TempDir()

if err := os.MkdirAll(filepath.Join(dir, "composer.json"), 0o750); err != nil {
t.Fatalf("mkdir: %v", err)
}

if shouldRunComposer(dir) {
t.Error("a directory named composer.json must not count as a manifest")
}
}
141 changes: 140 additions & 1 deletion cmd/xf/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ Fresh Install Mode (default):
4. Sets up Docker configuration
5. Configures the .env file
6. Runs 'up' to start the containers
7. Runs 'xf:install' to complete the installation
7. Runs 'composer install' if composer.json is present
8. Runs 'xf:install' to complete the installation

Existing Directory Mode (--existing flag):
For core developers who already have XenForo source files checked out.
Expand All @@ -46,6 +47,10 @@ Existing Directory Mode (--existing flag):
3. Configures the .env file
4. Optionally starts containers (with --up flag)

Repository checkouts track composer.json, so dependencies are installed
automatically once the containers are running. Release packages ship
vendor/ prebuilt and have no manifest, so they are skipped.

Examples:
# Fresh install (interactive)
xf init ./my-project
Expand Down Expand Up @@ -86,6 +91,7 @@ type InitOptions struct {
InstanceName string
SkipUp bool
SkipInstall bool
SkipComposer bool
ExistingOnly bool
Contexts []string
StartContainers bool
Expand All @@ -109,6 +115,7 @@ var (
flagInitInstance string
flagInitSkipUp bool
flagInitSkipInstall bool
flagInitSkipComposer bool
flagInitExisting bool
flagInitContexts []string
flagInitUp bool
Expand All @@ -127,6 +134,7 @@ func init() {
initCmd.Flags().StringVar(&flagInitInstance, "instance", "", "Docker instance name")
initCmd.Flags().BoolVar(&flagInitSkipUp, "skip-up", false, "skip starting Docker containers")
initCmd.Flags().BoolVar(&flagInitSkipInstall, "skip-install", false, "skip running xf:install")
initCmd.Flags().BoolVar(&flagInitSkipComposer, "skip-composer", false, "skip running composer install")
initCmd.Flags().BoolVar(&flagInitExisting, "existing", false, "initialize Docker in an existing XenForo directory (skips download)")
initCmd.Flags().StringSliceVar(&flagInitContexts, "contexts", nil, "Docker contexts to enable (e.g., caddy,mysql,development,redis)")
initCmd.Flags().BoolVar(&flagInitUp, "up", false, "start containers after initialization (for --existing mode)")
Expand Down Expand Up @@ -162,6 +170,7 @@ func runInit(cmd *cobra.Command, args []string) error {
InstanceName: flagInitInstance,
SkipUp: flagInitSkipUp,
SkipInstall: flagInitSkipInstall,
SkipComposer: flagInitSkipComposer,
ExistingOnly: flagInitExisting,
Contexts: flagInitContexts,
StartContainers: flagInitUp,
Expand Down Expand Up @@ -232,6 +241,36 @@ func detectXenForo(path string) (bool, error) {
return false, fmt.Errorf("failed to check XenForo path: %w", err)
}

// validateAdminDetails reports whether the installer has everything it needs.
//
// installExistingXenForo passes these straight to xf:install, so a missing
// value would install a broken administrator or an empty board title instead
// of failing.
func validateAdminDetails(opts *InitOptions) error {
var missing []string

if opts.AdminUser == "" {
missing = append(missing, "--admin-user")
}

if opts.AdminPassword == "" {
missing = append(missing, "--admin-password")
}

if opts.AdminEmail == "" {
missing = append(missing, "--admin-email")
}

if len(missing) > 0 {
return newUsageError(fmt.Errorf(
"missing required flags for the XenForo installation: %s: %w",
strings.Join(missing, ", "), ErrInvalidInput,
))
}

return nil
}

func initExisting(ctx context.Context, opts *InitOptions) error {
ui.Println(ui.Bold.Render("Initializing Docker environment in existing XenForo directory..."))
ui.Println()
Expand All @@ -251,6 +290,11 @@ func initExisting(ctx context.Context, opts *InitOptions) error {
ui.PrintSuccess("Docker Compose is available")
ui.Println()

cfg, err := config.Load()
if err != nil {
return fmt.Errorf("failed to load configuration: %w", err)
}

step := 1
totalSteps := 3

Expand Down Expand Up @@ -279,6 +323,11 @@ func initExisting(ctx context.Context, opts *InitOptions) error {

ui.PrintStep(step, totalSteps, "Starting environment")

// Detection can fail or return nothing, and installing --url= empty would
// leave the board with no address at all, so the predictable instance URL
// is the starting point.
siteURL := fallbackBoardURL(opts.InstanceName)

if opts.StartContainers {
runner, err := dockercompose.NewRunner(xfDir)
if err != nil {
Expand All @@ -291,8 +340,43 @@ func initExisting(ctx context.Context, opts *InitOptions) error {

url, err := runner.GetURL(ctx)
if err == nil && url != "" {
siteURL = url

ui.PrintDetail("Site: " + url)
}

// Composer and the installer both run inside the container, so they can
// only follow a successful start.
if shouldRunComposer(xfDir) && !opts.SkipComposer {
ui.Println()

if err := runComposerInstall(ctx, runner, cfg.Verbose); err != nil {
return err
}
}

if !opts.SkipInstall && opts.AdminUser != "" {
// The installer receives these verbatim, so an empty value becomes
// an empty board title or an unusable administrator rather than a
// reported error.
if opts.SiteTitle == "" {
opts.SiteTitle = opts.EnvResolved["XF_TITLE"]
}

if opts.SiteTitle == "" {
opts.SiteTitle = fmt.Sprintf("XenForo [%s]", opts.InstanceName)
}

if err := validateAdminDetails(opts); err != nil {
return err
}

ui.Println()

if err := installExistingXenForo(ctx, runner, opts, siteURL, cfg.Verbose); err != nil {
return err
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Comment thread
chrisdeeming marked this conversation as resolved.
} else {
ui.PrintDetail("Skipped (use --up flag to start containers)")
}
Expand Down Expand Up @@ -576,3 +660,58 @@ func runInteractiveSetup(ctx context.Context, opts *InitOptions) error {

return nil
}

// installExistingXenForo runs xf:install in an already-configured environment.
//
// The password is passed through the environment rather than the command line,
// so it does not appear in the container's process list.
func installExistingXenForo(
ctx context.Context,
runner *dockercompose.Runner,
opts *InitOptions,
siteURL string,
verbose bool,
) error {
if err := runner.WaitForDatabase(ctx, 2*time.Second); err != nil {
return fmt.Errorf("failed waiting for database to become ready: %w", err)
}

installArgs := []string{
"xf:install",
"--no-interaction",
"--clear",
"--user=" + opts.AdminUser,
"--email=" + opts.AdminEmail,
"--title=" + opts.SiteTitle,
"--url=" + siteURL,
}

installEnv := map[string]string{"XF_INSTALL_PASSWORD": opts.AdminPassword}
shellInstallArgs := []string{"sh", "-c", installShellCommand(installArgs)}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if verbose {
ui.PrintSubstep("Running XenForo installation...")

if err := runner.ExecOrRunWithEnv(ctx, "xf", true, installEnv, shellInstallArgs...); err != nil {
return fmt.Errorf("failed to install XenForo: %w", err)
}

return nil
}

spinner := ui.NewSpinner("Installing XenForo...")
spinner.Start()

tracker := newPhaseTrackerWriter(spinner, "Installing XenForo", installPhaseRules())

if err := runner.ExecOrRunWithEnvAndOutput(ctx, "xf", true, installEnv, tracker, tracker, shellInstallArgs...); err != nil {
spinner.Stop()
printHiddenOutputTail("Installer output", tracker.TailLines())

return fmt.Errorf("failed to install XenForo: %w", err)
}

spinner.StopWithMessage("success", "XenForo installed")

return nil
}
Loading