captaincore/cmd/drift.go
Austin Ginder bb9039704b 👌 IMPROVE: Drift
2026-06-11 13:59:44 -04:00

1920 lines
55 KiB
Go

package cmd
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"text/tabwriter"
"github.com/CaptainCore/captaincore/config"
"github.com/CaptainCore/captaincore/models"
"github.com/spf13/cobra"
)
var (
flagDriftJSON bool
flagDriftPlugin string
flagDriftTheme string
flagDriftCore bool
flagDriftThemes bool
flagDriftTarget string
flagDriftProvider string
flagDriftEnvironment string
flagDriftTop int
flagDriftSort string
flagDriftSteer bool
flagDriftHashes bool
flagDriftDiffSummary bool
flagDriftDiffHash string
)
var driftCmd = &cobra.Command{
Use: "drift",
Short: "Show version distribution across sites",
Long: `Show how plugin, theme, or WordPress core versions are distributed
across environments. Use --target to identify sites that have drifted
from a specific version. Run with no flags to see the top drifting plugins.`,
Run: func(cmd *cobra.Command, args []string) {
resolveNativeOrWP(cmd, args, driftNative)
},
}
type driftSiteInfo struct {
Site string `json:"site"`
SiteID uint `json:"site_id"`
Name string `json:"name"`
Provider string `json:"provider"`
HomeURL string `json:"home_url"`
Environment string `json:"environment,omitempty"`
}
type driftEntry struct {
Version string `json:"version"`
Count int `json:"count"`
Sites []driftSiteInfo `json:"sites,omitempty"`
}
type pluginDriftSummary struct {
Slug string `json:"slug"`
Latest string `json:"latest"`
Total int `json:"total"`
OnLatest int `json:"on_latest"`
Drifted int `json:"drifted"`
Versions int `json:"versions"`
}
type driftHashEntry struct {
Hash string `json:"hash"`
Count int `json:"count"`
Sites []driftSiteInfo `json:"sites,omitempty"`
}
type driftVersionHashEntry struct {
Version string `json:"version"`
Count int `json:"count"`
Hashes []*driftHashEntry `json:"hashes"`
Sites []driftSiteInfo `json:"sites,omitempty"`
}
func driftNative(cmd *cobra.Command, args []string) {
// Determine mode
hasPlugin := flagDriftPlugin != ""
hasTheme := flagDriftTheme != ""
hasCore := flagDriftCore
singleMode := hasPlugin || hasTheme || hasCore
// Validate: can't combine single-component flags
modeCount := 0
if hasPlugin {
modeCount++
}
if hasTheme {
modeCount++
}
if hasCore {
modeCount++
}
if modeCount > 1 {
fmt.Println("Error: Specify only one of --plugin, --theme, or --core")
return
}
if flagDriftHashes {
if !hasPlugin && !hasTheme {
fmt.Println("Error: --hashes requires --plugin or --theme")
return
}
if hasCore {
fmt.Println("Error: --hashes is not supported with --core")
return
}
if flagDriftTarget != "" {
fmt.Println("Error: --hashes is not yet supported with --target")
return
}
}
// Normalize environment
env := normalizeEnv(flagDriftEnvironment)
if singleMode {
driftSingleComponent(env, hasPlugin, hasTheme, hasCore)
} else {
driftOverview(env)
}
}
// normalizeEnv normalizes the environment flag to match DB values.
func normalizeEnv(env string) string {
switch strings.ToLower(env) {
case "production":
return "Production"
case "staging":
return "Staging"
case "all":
return "all"
default:
return env
}
}
// driftSingleComponent handles --plugin, --theme, or --core mode.
func driftSingleComponent(env string, hasPlugin, hasTheme, hasCore bool) {
var results []models.SiteEnvironmentResult
query := models.DB.Table("captaincore_sites").
Select("captaincore_sites.site, captaincore_sites.site_id, captaincore_sites.name, captaincore_sites.provider, captaincore_environments.environment, captaincore_environments.home_url, captaincore_environments.core, captaincore_environments.plugins, captaincore_environments.themes").
Joins("INNER JOIN captaincore_environments ON captaincore_sites.site_id = captaincore_environments.site_id").
Where("captaincore_sites.status = ?", "active")
if env != "all" {
query = query.Where("captaincore_environments.environment = ?", env)
}
if flagDriftProvider != "" {
query = query.Where("captaincore_sites.provider = ?", flagDriftProvider)
}
if hasPlugin {
query = query.Where("captaincore_environments.plugins LIKE ?", `%"name":"`+flagDriftPlugin+`"%`)
}
if hasTheme {
query = query.Where("captaincore_environments.themes LIKE ?", `%"name":"`+flagDriftTheme+`"%`)
}
query.Order("captaincore_sites.name ASC").Find(&results)
// Hash-aware path
if flagDriftHashes {
vhMap := make(map[string]*driftVersionHashEntry)
for _, r := range results {
version, hash := extractVersionAndHash(r, flagDriftPlugin, flagDriftTheme)
if version == "" {
continue
}
vhEntry, ok := vhMap[version]
if !ok {
vhEntry = &driftVersionHashEntry{Version: version}
vhMap[version] = vhEntry
}
vhEntry.Count++
site := driftSiteInfo{
Site: r.Site,
SiteID: r.SiteID,
Name: r.Name,
Provider: r.Provider,
HomeURL: r.HomeURL,
Environment: r.Environment,
}
vhEntry.Sites = append(vhEntry.Sites, site)
// Find or create hash sub-entry
found := false
for _, he := range vhEntry.Hashes {
if he.Hash == hash {
he.Count++
he.Sites = append(he.Sites, site)
found = true
break
}
}
if !found {
vhEntry.Hashes = append(vhEntry.Hashes, &driftHashEntry{
Hash: hash, Count: 1, Sites: []driftSiteInfo{site},
})
}
}
if len(vhMap) == 0 {
label, slug := componentLabel(hasPlugin, hasTheme, hasCore)
fmt.Printf("No sites found with %s \"%s\" installed.\n", label, slug)
suggestSlugs(results, slug, hasPlugin)
return
}
sorted := sortedVersionsFromHashMap(vhMap)
label, slug := componentLabel(hasPlugin, hasTheme, hasCore)
outputDistributionWithHashes(sorted, vhMap, label, slug, env)
return
}
// Aggregate by version
versionMap := make(map[string]*driftEntry)
for _, r := range results {
version := extractVersion(r, flagDriftPlugin, flagDriftTheme, hasCore)
if version == "" {
continue
}
entry, ok := versionMap[version]
if !ok {
entry = &driftEntry{Version: version}
versionMap[version] = entry
}
entry.Count++
entry.Sites = append(entry.Sites, driftSiteInfo{
Site: r.Site,
SiteID: r.SiteID,
Name: r.Name,
Provider: r.Provider,
HomeURL: r.HomeURL,
Environment: r.Environment,
})
}
if len(versionMap) == 0 {
label, slug := componentLabel(hasPlugin, hasTheme, hasCore)
fmt.Printf("No sites found with %s \"%s\" installed.\n", label, slug)
if hasPlugin || hasTheme {
suggestSlugs(results, slug, hasPlugin)
}
return
}
// Sort versions descending
sorted := sortedVersions(versionMap)
// Determine component label for output
label, slug := componentLabel(hasPlugin, hasTheme, hasCore)
if flagDriftSteer {
if hasCore {
fmt.Println("Error: --steer is not supported with --core")
return
}
driftSteer(sorted, versionMap, slug, hasPlugin, env, results)
return
}
if flagDriftTarget != "" {
outputTarget(sorted, versionMap, label, slug, env)
} else {
outputDistribution(sorted, versionMap, label, slug, env)
}
}
// driftOverview handles the default overview mode — top drifting plugins/themes.
func driftOverview(env string) {
var results []models.SiteEnvironmentResult
query := models.DB.Table("captaincore_sites").
Select("captaincore_sites.site, captaincore_sites.site_id, captaincore_sites.name, captaincore_sites.provider, captaincore_environments.environment, captaincore_environments.home_url, captaincore_environments.core, captaincore_environments.plugins, captaincore_environments.themes").
Joins("INNER JOIN captaincore_environments ON captaincore_sites.site_id = captaincore_environments.site_id").
Where("captaincore_sites.status = ?", "active")
if env != "all" {
query = query.Where("captaincore_environments.environment = ?", env)
}
if flagDriftProvider != "" {
query = query.Where("captaincore_sites.provider = ?", flagDriftProvider)
}
query.Order("captaincore_sites.name ASC").Find(&results)
// Build map: slug -> version -> count
type versionCounts map[string]int
slugVersions := make(map[string]versionCounts)
componentType := "plugins"
if flagDriftThemes {
componentType = "themes"
}
for _, r := range results {
var jsonData string
if componentType == "plugins" {
jsonData = r.Plugins
} else {
jsonData = r.Themes
}
if jsonData == "" {
continue
}
var items []map[string]interface{}
if err := json.Unmarshal([]byte(jsonData), &items); err != nil {
continue
}
for _, item := range items {
name, _ := item["name"].(string)
version, _ := item["version"].(string)
if name == "" || version == "" {
continue
}
if slugVersions[name] == nil {
slugVersions[name] = make(versionCounts)
}
slugVersions[name][version]++
}
}
// Build summaries for slugs with 2+ versions
var summaries []pluginDriftSummary
for slug, versions := range slugVersions {
if len(versions) < 2 {
continue
}
// Find latest version and total count
var latest string
total := 0
for v, count := range versions {
total += count
if latest == "" || compareVersions(v, latest) > 0 {
latest = v
}
}
onLatest := versions[latest]
summaries = append(summaries, pluginDriftSummary{
Slug: slug,
Latest: latest,
Total: total,
OnLatest: onLatest,
Drifted: total - onLatest,
Versions: len(versions),
})
}
// Sort
if flagDriftSort == "spread" {
sort.Slice(summaries, func(i, j int) bool {
if summaries[i].Versions != summaries[j].Versions {
return summaries[i].Versions > summaries[j].Versions
}
return summaries[i].Drifted > summaries[j].Drifted
})
} else {
sort.Slice(summaries, func(i, j int) bool {
if summaries[i].Drifted != summaries[j].Drifted {
return summaries[i].Drifted > summaries[j].Drifted
}
return summaries[i].Versions > summaries[j].Versions
})
}
// Limit to top N
totalWithDrift := len(summaries)
if flagDriftTop > 0 && len(summaries) > flagDriftTop {
summaries = summaries[:flagDriftTop]
}
if len(summaries) == 0 {
typeLabel := "plugins"
if flagDriftThemes {
typeLabel = "themes"
}
fmt.Printf("No %s with version drift found.\n", typeLabel)
return
}
// Output
typeLabel := "plugins"
if flagDriftThemes {
typeLabel = "themes"
}
if flagDriftJSON {
data := map[string]interface{}{
"type": typeLabel,
"environment": env,
"total_sites": len(results),
"total_with_drift": totalWithDrift,
"showing": len(summaries),
"sort": flagDriftSort,
"results": summaries,
}
result, _ := json.MarshalIndent(data, "", " ")
fmt.Println(string(result))
return
}
fmt.Printf("Top %d drifting %s (%s, %s sites):\n\n", len(summaries), typeLabel, env, formatNumber(len(results)))
w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
fmt.Fprintln(w, "PLUGIN\tLATEST\tON LATEST\tDRIFTED\tVERSIONS")
for _, s := range summaries {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%d\n", s.Slug, s.Latest, formatNumber(s.OnLatest), formatNumber(s.Drifted), s.Versions)
}
w.Flush()
fmt.Printf("\n%d of %d %s with version drift\n", len(summaries), totalWithDrift, typeLabel)
}
// extractVersion returns the version string for a given result.
func extractVersion(r models.SiteEnvironmentResult, pluginSlug, themeSlug string, isCore bool) string {
if isCore {
if r.Core == "" {
return "unknown"
}
return r.Core
}
var jsonData string
var slug string
if pluginSlug != "" {
jsonData = r.Plugins
slug = pluginSlug
} else {
jsonData = r.Themes
slug = themeSlug
}
if jsonData == "" {
return ""
}
var items []map[string]interface{}
if err := json.Unmarshal([]byte(jsonData), &items); err != nil {
return ""
}
for _, item := range items {
if name, _ := item["name"].(string); name == slug {
version, _ := item["version"].(string)
return version
}
}
return ""
}
// extractVersionAndHash returns the version and hash for a given result.
func extractVersionAndHash(r models.SiteEnvironmentResult, pluginSlug, themeSlug string) (string, string) {
var jsonData string
var slug string
if pluginSlug != "" {
jsonData = r.Plugins
slug = pluginSlug
} else {
jsonData = r.Themes
slug = themeSlug
}
if jsonData == "" {
return "", ""
}
var items []map[string]interface{}
if err := json.Unmarshal([]byte(jsonData), &items); err != nil {
return "", ""
}
for _, item := range items {
if name, _ := item["name"].(string); name == slug {
version, _ := item["version"].(string)
hash, _ := item["hash"].(string)
return version, hash
}
}
return "", ""
}
// suggestSlugs searches all environments for plugin/theme slugs that partially match the query.
func suggestSlugs(results []models.SiteEnvironmentResult, query string, isPlugin bool) {
// Results may be empty due to the LIKE pre-filter, so do a fresh query
var envs []models.SiteEnvironmentResult
col := "captaincore_environments.plugins"
if !isPlugin {
col = "captaincore_environments.themes"
}
models.DB.Table("captaincore_sites").
Select(col+" AS plugins, "+col+" AS themes").
Joins("INNER JOIN captaincore_environments ON captaincore_sites.site_id = captaincore_environments.site_id").
Where("captaincore_sites.status = ?", "active").
Where("captaincore_environments.environment = ?", "Production").
Find(&envs)
queryLower := strings.ToLower(query)
// Normalize: strip spaces/hyphens for fuzzy comparison
queryNorm := strings.NewReplacer(" ", "", "-", "", "_", "").Replace(queryLower)
type slugMatch struct {
slug string
title string
count int
}
seen := make(map[string]*slugMatch)
for _, r := range envs {
var jsonData string
if isPlugin {
jsonData = r.Plugins
} else {
jsonData = r.Themes
}
if jsonData == "" {
continue
}
var items []map[string]interface{}
if json.Unmarshal([]byte(jsonData), &items) != nil {
continue
}
for _, item := range items {
name, _ := item["name"].(string)
title, _ := item["title"].(string)
if name == "" {
continue
}
nameNorm := strings.NewReplacer(" ", "", "-", "", "_", "").Replace(strings.ToLower(name))
titleLower := strings.ToLower(title)
if strings.Contains(nameNorm, queryNorm) || strings.Contains(titleLower, queryLower) {
if m, ok := seen[name]; ok {
m.count++
} else {
seen[name] = &slugMatch{slug: name, title: title, count: 1}
}
}
}
}
if len(seen) == 0 {
return
}
// Sort by count descending
matches := make([]*slugMatch, 0, len(seen))
for _, m := range seen {
matches = append(matches, m)
}
sort.Slice(matches, func(i, j int) bool {
return matches[i].count > matches[j].count
})
// Show up to 10 suggestions
limit := 10
if len(matches) < limit {
limit = len(matches)
}
fmt.Printf("\nDid you mean?\n")
for _, m := range matches[:limit] {
fmt.Printf(" %s (%s, %s sites)\n", m.slug, m.title, formatNumber(m.count))
}
}
// steerConn holds SSH/SCP connection details for a site.
type steerConn struct {
beforeSSH string
remoteOpts string
user string
host string
port string
commandPrep string
}
// newSteerConn builds SSH/SCP connection details for a site, following the same
// pattern as sshNative in cmd/ssh.go.
func newSteerConn(site *models.Site, env *models.Environment, system *config.SystemConfig) (*steerConn, error) {
siteDetails := site.ParseDetails()
remoteOpts := "-q -oStrictHostKeyChecking=no -oConnectTimeout=30 -oServerAliveInterval=60 -oServerAliveCountMax=10"
beforeSSH := ""
key := siteDetails.Key
if key != "use_password" && key == "" {
cid, _ := strconv.ParseUint(captainID, 10, 64)
configValue, _ := models.GetConfiguration(uint(cid), "configurations")
if configValue != "" {
var configObj map[string]json.RawMessage
if json.Unmarshal([]byte(configValue), &configObj) == nil {
if defaultKeyRaw, ok := configObj["default_key"]; ok {
var defaultKey string
json.Unmarshal(defaultKeyRaw, &defaultKey)
key = defaultKey
}
}
}
}
if key != "use_password" {
remoteOpts = fmt.Sprintf("%s -oPreferredAuthentications=publickey -i %s/%s/%s", remoteOpts, system.PathKeys, captainID, key)
} else {
beforeSSH = fmt.Sprintf("sshpass -p '%s'", env.Password)
}
conn := &steerConn{
beforeSSH: beforeSSH,
remoteOpts: remoteOpts,
}
switch site.Provider {
case "kinsta":
conn.commandPrep = "cd public/ &&"
conn.user = env.Username
conn.host = env.Address
conn.port = env.Port
case "wpengine":
conn.commandPrep = "cd sites/* &&"
conn.user = site.Site
conn.host = site.Site + ".ssh.wpengine.net"
conn.port = ""
case "rocketdotnet":
conn.commandPrep = fmt.Sprintf("cd %s/ &&", env.HomeDirectory)
conn.user = env.Username
conn.host = env.Address
conn.port = env.Port
default:
conn.commandPrep = fmt.Sprintf("cd %s/ &&", env.HomeDirectory)
conn.user = env.Username
conn.host = env.Address
conn.port = env.Port
}
return conn, nil
}
func (c *steerConn) runSSH(command string) (string, error) {
sshCmd := fmt.Sprintf("%s ssh %s %s@%s", c.beforeSSH, c.remoteOpts, c.user, c.host)
if c.port != "" {
sshCmd += " -p " + c.port
}
sshCmd += fmt.Sprintf(" \"%s %s\"", c.commandPrep, command)
sshCmd = strings.TrimSpace(sshCmd)
cmd := exec.Command("bash", "-c", sshCmd)
output, err := cmd.CombinedOutput()
return string(output), err
}
func (c *steerConn) scpFrom(remotePath, localPath string) error {
scpCmd := fmt.Sprintf("%s scp %s", c.beforeSSH, c.remoteOpts)
if c.port != "" {
scpCmd += " -P " + c.port
}
scpCmd += fmt.Sprintf(" %s@%s:%s %s", c.user, c.host, remotePath, localPath)
scpCmd = strings.TrimSpace(scpCmd)
cmd := exec.Command("bash", "-c", scpCmd)
cmd.Stderr = os.Stderr
return cmd.Run()
}
func (c *steerConn) scpTo(localPath, remotePath string) error {
scpCmd := fmt.Sprintf("%s scp %s", c.beforeSSH, c.remoteOpts)
if c.port != "" {
scpCmd += " -P " + c.port
}
scpCmd += fmt.Sprintf(" %s %s@%s:%s", localPath, c.user, c.host, remotePath)
scpCmd = strings.TrimSpace(scpCmd)
cmd := exec.Command("bash", "-c", scpCmd)
cmd.Stderr = os.Stderr
return cmd.Run()
}
type steerResult struct {
Site string `json:"site"`
SiteID uint `json:"site_id"`
Environment string `json:"environment"`
OldVersion string `json:"old_version"`
NewVersion string `json:"new_version"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
}
type driftedSiteWithVersion struct {
driftSiteInfo
version string
}
// driftSteer upgrades all drifted sites to the latest version by grabbing the
// plugin/theme zip from a source site and deploying it to each drifted site.
func driftSteer(sorted []string, versionMap map[string]*driftEntry, slug string, isPlugin bool, env string, rawResults []models.SiteEnvironmentResult) {
// Resolve the version to steer TO. An explicit --target always wins (the
// caller — e.g. CaptainCore Manager — supplies the authoritative version,
// typically wp.org's published latest); it must have a source site on it to
// grab a zip from. Otherwise auto-detect the newest version that MORE THAN
// ONE site runs: a version pinned on a single site is almost always a
// bogus/dev/anti-update stamp (9999, 99999999, or any arbitrary value) —
// steering the whole fleet onto it would push a bad build everywhere and
// clobber sites already on the real latest. Identifying it by share (held
// by one site) is value-agnostic; we never match on the number itself.
// Fall back to the absolute newest only when every version is single-site.
latestVersion := sorted[0]
if flagDriftTarget != "" && flagDriftTarget != "latest" {
if _, ok := versionMap[flagDriftTarget]; !ok {
fmt.Printf("Error: No sites are on target version %s — cannot source a zip to steer from.\n", flagDriftTarget)
return
}
latestVersion = flagDriftTarget
} else {
for _, v := range sorted {
if versionMap[v].Count >= 2 {
latestVersion = v
break
}
}
}
// Partition sites into source candidates and drifted
var drifted []driftedSiteWithVersion
onLatestCount := 0
for _, v := range sorted {
entry := versionMap[v]
if v == latestVersion {
onLatestCount = entry.Count
} else {
for _, s := range entry.Sites {
drifted = append(drifted, driftedSiteWithVersion{
driftSiteInfo: s,
version: v,
})
}
}
}
if len(drifted) == 0 {
fmt.Println("All sites are on the latest version. Nothing to steer.")
return
}
// Hash-aware source site selection: group latest-version sites by content hash
// and pick from the largest hash group to propagate the most common variant.
pluginSlug, themeSlug := "", ""
if isPlugin {
pluginSlug = slug
} else {
themeSlug = slug
}
latestSites := versionMap[latestVersion].Sites
hashBuckets := make(map[string][]driftSiteInfo)
// Build a lookup from site name+env to raw result for hash extraction
rawLookup := make(map[string]models.SiteEnvironmentResult)
for _, r := range rawResults {
rawLookup[r.Site+"|"+r.Environment] = r
}
for _, s := range latestSites {
if r, ok := rawLookup[s.Site+"|"+s.Environment]; ok {
_, hash := extractVersionAndHash(r, pluginSlug, themeSlug)
if hash == "" {
hash = "unknown"
}
hashBuckets[hash] = append(hashBuckets[hash], s)
}
}
// Find the largest hash group
var sourceSite *driftSiteInfo
var sourceHash string
bestCount := 0
for hash, sites := range hashBuckets {
if len(sites) > bestCount {
bestCount = len(sites)
sourceHash = hash
s := sites[0]
sourceSite = &s
}
}
if sourceSite == nil {
fmt.Println("Error: No source site found on the latest version.")
return
}
// Print summary
componentType := "Plugin"
if !isPlugin {
componentType = "Theme"
}
outdatedVersions := len(sorted) - 1
fmt.Printf("%s: %s\n", componentType, slug)
fmt.Printf("Latest: %s (on %s sites)\n", latestVersion, formatNumber(onLatestCount))
fmt.Printf("Drifted: %s sites across %d outdated versions\n\n", formatNumber(len(drifted)), outdatedVersions)
if len(hashBuckets) > 1 {
fmt.Printf("Source hash: %s (%s of %s sites on %s)\n", sourceHash[:8], formatNumber(bestCount), formatNumber(onLatestCount), latestVersion)
}
fmt.Printf("Source: %s (%s) — will grab zip from this site\n\n", sourceSite.Site, sourceSite.Name)
if !flagForce {
fmt.Println("Add --force to proceed with upgrade.")
return
}
// Load config once for SSH key paths
_, system, _, err := loadCaptainConfig()
if err != nil || system == nil {
fmt.Println("Error: Configuration file not found.")
return
}
// Step 1: Extract zip from source site
fmt.Printf("Extracting %s from %s...\n", slug, sourceSite.Site)
sourceSiteModel, err := models.GetSiteByName(sourceSite.Site)
if err != nil || sourceSiteModel == nil {
fmt.Printf("Error: Source site '%s' not found in database.\n", sourceSite.Site)
return
}
sourceEnvs, err := models.FindEnvironmentsBySiteID(sourceSiteModel.SiteID)
if err != nil || len(sourceEnvs) == 0 {
fmt.Printf("Error: No environments found for source site '%s'.\n", sourceSite.Site)
return
}
var sourceEnv *models.Environment
for _, e := range sourceEnvs {
if strings.EqualFold(e.Environment, sourceSite.Environment) {
sourceEnv = &e
break
}
}
if sourceEnv == nil {
fmt.Printf("Error: %s environment not found for source site '%s'.\n", sourceSite.Environment, sourceSite.Site)
return
}
conn, err := newSteerConn(sourceSiteModel, sourceEnv, system)
if err != nil {
fmt.Printf("Error building connection: %s\n", err)
return
}
componentDir := "wp-content/plugins"
wpCmd := "wp plugin install"
if !isPlugin {
componentDir = "wp-content/themes"
wpCmd = "wp theme install"
}
remoteZip := fmt.Sprintf("/tmp/%s.zip", slug)
localZip := fmt.Sprintf("/tmp/captaincore-steer-%s-%s.zip", slug, latestVersion)
// SSH to source: zip the plugin/theme directory
zipCmd := fmt.Sprintf("cd %s && zip -qr %s %s/", componentDir, remoteZip, slug)
output, err := conn.runSSH(zipCmd)
if err != nil {
fmt.Printf("Error zipping on source site: %s\n%s\n", err, output)
return
}
// SCP the zip down to the VPS
err = conn.scpFrom(remoteZip, localZip)
if err != nil {
fmt.Printf("Error downloading zip from source: %s\n", err)
return
}
// Cleanup remote zip on source
conn.runSSH(fmt.Sprintf("rm -f %s", remoteZip))
fmt.Printf("Deploying to %s sites...\n", formatNumber(len(drifted)))
// Step 2: Deploy to all drifted sites in parallel
parallel := flagParallel
if parallel <= 0 {
parallel = 10
}
sem := make(chan struct{}, parallel)
var wg sync.WaitGroup
var mu sync.Mutex
var results []steerResult
successCount := 0
failCount := 0
for _, d := range drifted {
wg.Add(1)
go func(ds driftedSiteWithVersion) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
result := steerResult{
Site: ds.Site,
SiteID: ds.SiteID,
Environment: ds.Environment,
OldVersion: ds.version,
NewVersion: latestVersion,
}
// Look up site
dsite, err := models.GetSiteByName(ds.Site)
if err != nil || dsite == nil {
result.Status = "failed"
result.Error = "site not found"
mu.Lock()
results = append(results, result)
failCount++
mu.Unlock()
return
}
denvs, err := models.FindEnvironmentsBySiteID(dsite.SiteID)
if err != nil || len(denvs) == 0 {
result.Status = "failed"
result.Error = "environment not found"
mu.Lock()
results = append(results, result)
failCount++
mu.Unlock()
return
}
var denv *models.Environment
for _, e := range denvs {
if strings.EqualFold(e.Environment, ds.Environment) {
denv = &e
break
}
}
if denv == nil {
result.Status = "failed"
result.Error = "environment not found"
mu.Lock()
results = append(results, result)
failCount++
mu.Unlock()
return
}
dconn, err := newSteerConn(dsite, denv, system)
if err != nil {
result.Status = "failed"
result.Error = err.Error()
mu.Lock()
results = append(results, result)
failCount++
mu.Unlock()
return
}
// SCP zip up to target site
err = dconn.scpTo(localZip, remoteZip)
if err != nil {
result.Status = "failed"
result.Error = "scp upload failed"
mu.Lock()
results = append(results, result)
failCount++
mu.Unlock()
return
}
// Install the plugin/theme
installCmd := fmt.Sprintf("%s %s --force 2>&1", wpCmd, remoteZip)
installOutput, err := dconn.runSSH(installCmd)
// Cleanup remote zip
dconn.runSSH(fmt.Sprintf("rm -f %s", remoteZip))
if err != nil {
result.Status = "failed"
result.Error = strings.TrimSpace(installOutput)
mu.Lock()
results = append(results, result)
failCount++
mu.Unlock()
return
}
result.Status = "success"
mu.Lock()
results = append(results, result)
successCount++
mu.Unlock()
}(d)
}
wg.Wait()
// Cleanup local zip
os.Remove(localZip)
// Sort results by site name
sort.Slice(results, func(i, j int) bool {
return results[i].Site < results[j].Site
})
// Output results
if flagDriftJSON {
data := map[string]interface{}{
"type": componentType,
"slug": slug,
"target": latestVersion,
"source": sourceSite.Site,
"success": successCount,
"failed": failCount,
"total": len(results),
"results": results,
}
jsonResult, _ := json.MarshalIndent(data, "", " ")
fmt.Println(string(jsonResult))
return
}
fmt.Printf("\nUpgrade complete: %s → %s\n\n", slug, latestVersion)
w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
fmt.Fprintln(w, "SITE\tSITE_ID\tENVIRONMENT\tOLD VERSION\tSTATUS")
for _, r := range results {
status := "✓"
if r.Status == "failed" {
status = "✗ (" + r.Error + ")"
}
fmt.Fprintf(w, "%s\t%d\t%s\t%s\t%s\n", r.Site, r.SiteID, r.Environment, r.OldVersion, status)
}
w.Flush()
fmt.Printf("\n%s of %s sites upgraded successfully.", formatNumber(successCount), formatNumber(len(results)))
if failCount > 0 {
fmt.Printf(" %s failed.", formatNumber(failCount))
}
fmt.Println()
}
// componentLabel returns a display label and slug for the current component.
func componentLabel(hasPlugin, hasTheme, hasCore bool) (string, string) {
if hasPlugin {
return "plugin", flagDriftPlugin
}
if hasTheme {
return "theme", flagDriftTheme
}
return "core", "WordPress"
}
// outputDistribution renders the version distribution table.
func outputDistribution(sorted []string, versionMap map[string]*driftEntry, label, slug, env string) {
totalSites := 0
for _, e := range versionMap {
totalSites += e.Count
}
if flagDriftJSON {
versions := make([]map[string]interface{}, 0, len(sorted))
for _, v := range sorted {
entry := versionMap[v]
sites := make([]map[string]interface{}, 0, len(entry.Sites))
for _, s := range entry.Sites {
sites = append(sites, map[string]interface{}{
"site": s.Site,
"site_id": s.SiteID,
"environment": s.Environment,
"name": s.Name,
"provider": s.Provider,
"home_url": s.HomeURL,
})
}
versions = append(versions, map[string]interface{}{
"version": v,
"count": entry.Count,
"sites": sites,
})
}
data := map[string]interface{}{
"type": label,
"slug": slug,
"environment": env,
"total_sites": totalSites,
"total_versions": len(sorted),
"versions": versions,
}
result, _ := json.MarshalIndent(data, "", " ")
fmt.Println(string(result))
return
}
// Capitalize label for display
displayLabel := strings.ToUpper(label[:1]) + label[1:]
fmt.Printf("%s: %s\n", displayLabel, slug)
fmt.Printf("Environment: %s\n\n", env)
w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
fmt.Fprintln(w, "VERSION\tSITES")
for _, v := range sorted {
fmt.Fprintf(w, "%s\t%s\n", v, formatNumber(versionMap[v].Count))
}
w.Flush()
fmt.Printf("\nTotal: %s sites across %d versions\n", formatNumber(totalSites), len(sorted))
}
// sortedVersionsFromHashMap returns version strings sorted descending and sorts hashes within each version by count descending.
func sortedVersionsFromHashMap(vhMap map[string]*driftVersionHashEntry) []string {
versions := make([]string, 0, len(vhMap))
for v := range vhMap {
versions = append(versions, v)
}
sort.Slice(versions, func(i, j int) bool {
return compareVersions(versions[i], versions[j]) > 0
})
for _, vhEntry := range vhMap {
sort.Slice(vhEntry.Hashes, func(i, j int) bool {
return vhEntry.Hashes[i].Count > vhEntry.Hashes[j].Count
})
}
return versions
}
// outputDistributionWithHashes renders the version distribution with hash sub-grouping.
func outputDistributionWithHashes(sorted []string, vhMap map[string]*driftVersionHashEntry, label, slug, env string) {
totalSites := 0
totalDistinctHashes := 0
for _, vhEntry := range vhMap {
totalSites += vhEntry.Count
totalDistinctHashes += len(vhEntry.Hashes)
}
if flagDriftJSON {
versions := make([]map[string]interface{}, 0, len(sorted))
for _, v := range sorted {
vhEntry := vhMap[v]
hashes := make([]map[string]interface{}, 0, len(vhEntry.Hashes))
for _, he := range vhEntry.Hashes {
sites := make([]map[string]interface{}, 0, len(he.Sites))
for _, s := range he.Sites {
sites = append(sites, map[string]interface{}{
"site": s.Site,
"site_id": s.SiteID,
"environment": s.Environment,
"name": s.Name,
"provider": s.Provider,
"home_url": s.HomeURL,
})
}
hashes = append(hashes, map[string]interface{}{
"hash": he.Hash,
"count": he.Count,
"sites": sites,
})
}
versions = append(versions, map[string]interface{}{
"version": v,
"count": vhEntry.Count,
"distinct_hashes": len(vhEntry.Hashes),
"hashes": hashes,
})
}
data := map[string]interface{}{
"type": label,
"slug": slug,
"environment": env,
"total_sites": totalSites,
"total_versions": len(sorted),
"total_distinct_hashes": totalDistinctHashes,
"versions": versions,
}
result, _ := json.MarshalIndent(data, "", " ")
fmt.Println(string(result))
return
}
// Table mode
displayLabel := strings.ToUpper(label[:1]) + label[1:]
fmt.Printf("%s: %s\n", displayLabel, slug)
fmt.Printf("Environment: %s\n\n", env)
w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
fmt.Fprintln(w, "VERSION\tSITES\tHASHES")
for _, v := range sorted {
vhEntry := vhMap[v]
fmt.Fprintf(w, "%s\t%s\t%d\n", v, formatNumber(vhEntry.Count), len(vhEntry.Hashes))
for _, he := range vhEntry.Hashes {
hashLabel := he.Hash
if hashLabel == "" {
hashLabel = "(no hash)"
} else if len(hashLabel) > 8 {
hashLabel = hashLabel[:8]
}
fmt.Fprintf(w, " %s\t%s\t\n", hashLabel, formatNumber(he.Count))
}
}
w.Flush()
fmt.Printf("\nTotal: %s sites across %d versions, %d distinct hashes\n",
formatNumber(totalSites), len(sorted), totalDistinctHashes)
}
// outputTarget renders the drift detection view with --target.
func outputTarget(sorted []string, versionMap map[string]*driftEntry, label, slug, env string) {
// Resolve "latest" to the highest version found
if flagDriftTarget == "latest" && len(sorted) > 0 {
flagDriftTarget = sorted[0]
}
totalSites := 0
onTarget := 0
var driftedSites []driftSiteInfo
for _, v := range sorted {
entry := versionMap[v]
totalSites += entry.Count
if v == flagDriftTarget {
onTarget = entry.Count
} else {
for i := range entry.Sites {
driftedSites = append(driftedSites, entry.Sites[i])
}
}
}
// Sort drifted sites: by version (ascending) then name
sort.Slice(driftedSites, func(i, j int) bool {
// Find version for each site
vi := findSiteVersion(driftedSites[i], versionMap)
vj := findSiteVersion(driftedSites[j], versionMap)
cmp := compareVersions(vi, vj)
if cmp != 0 {
return cmp < 0
}
return driftedSites[i].Name < driftedSites[j].Name
})
if flagDriftJSON {
// Build drifted list with version info
type driftedJSON struct {
Version string `json:"version"`
Site string `json:"site"`
SiteID uint `json:"site_id"`
Environment string `json:"environment"`
Name string `json:"name"`
Provider string `json:"provider"`
HomeURL string `json:"home_url"`
}
drifted := make([]driftedJSON, 0, len(driftedSites))
for _, s := range driftedSites {
drifted = append(drifted, driftedJSON{
Version: findSiteVersion(s, versionMap),
Site: s.Site,
SiteID: s.SiteID,
Environment: s.Environment,
Name: s.Name,
Provider: s.Provider,
HomeURL: s.HomeURL,
})
}
data := map[string]interface{}{
"type": label,
"slug": slug,
"target": flagDriftTarget,
"environment": env,
"on_target": onTarget,
"drifted_count": len(driftedSites),
"total_sites": totalSites,
"drifted": drifted,
}
result, _ := json.MarshalIndent(data, "", " ")
fmt.Println(string(result))
return
}
displayLabel := strings.ToUpper(label[:1]) + label[1:]
fmt.Printf("%s: %s\n", displayLabel, slug)
fmt.Printf("Target: %s\n", flagDriftTarget)
fmt.Printf("Environment: %s\n\n", env)
fmt.Printf("On target: %s sites\n\n", formatNumber(onTarget))
if len(driftedSites) == 0 {
fmt.Println("All sites are on the target version.")
return
}
fmt.Printf("Drifted sites (%s):\n\n", formatNumber(len(driftedSites)))
w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
fmt.Fprintln(w, "VERSION\tSITE\tSITE_ID\tENVIRONMENT\tDOMAIN\tHOME_URL\tPROVIDER")
for _, s := range driftedSites {
v := findSiteVersion(s, versionMap)
fmt.Fprintf(w, "%s\t%s\t%d\t%s\t%s\t%s\t%s\n", v, s.Site, s.SiteID, s.Environment, s.Name, s.HomeURL, s.Provider)
}
w.Flush()
fmt.Printf("\n%s of %s sites have drifted from target version %s\n",
formatNumber(len(driftedSites)), formatNumber(totalSites), flagDriftTarget)
}
// findSiteVersion looks up which version a site belongs to in the version map.
func findSiteVersion(site driftSiteInfo, versionMap map[string]*driftEntry) string {
for version, entry := range versionMap {
for _, s := range entry.Sites {
if s.SiteID == site.SiteID && s.Site == site.Site {
return version
}
}
}
return "unknown"
}
// sortedVersions returns version strings sorted descending (newest first).
func sortedVersions(versionMap map[string]*driftEntry) []string {
versions := make([]string, 0, len(versionMap))
for v := range versionMap {
versions = append(versions, v)
}
sort.Slice(versions, func(i, j int) bool {
return compareVersions(versions[i], versions[j]) > 0
})
return versions
}
// compareVersions compares two version strings.
// Returns >0 if a > b, <0 if a < b, 0 if equal.
func compareVersions(a, b string) int {
partsA := strings.Split(a, ".")
partsB := strings.Split(b, ".")
maxLen := len(partsA)
if len(partsB) > maxLen {
maxLen = len(partsB)
}
for i := 0; i < maxLen; i++ {
var segA, segB string
if i < len(partsA) {
segA = partsA[i]
}
if i < len(partsB) {
segB = partsB[i]
}
numA, errA := strconv.Atoi(segA)
numB, errB := strconv.Atoi(segB)
if errA == nil && errB == nil {
if numA != numB {
return numA - numB
}
} else {
if segA != segB {
if segA < segB {
return -1
}
return 1
}
}
}
return 0
}
// --- drift diff subcommand ---
var driftDiffCmd = &cobra.Command{
Use: "diff",
Short: "Show file-level differences between hash variants of a plugin or theme",
Long: `Compare actual file contents across different hash variants of a plugin
or theme found in the fleet. Uses local quicksave directories to produce
real diffs between variants.
Requires --plugin or --theme. Uses --target to specify the reference
variant (defaults to "latest" = highest version, most common hash).`,
Run: func(cmd *cobra.Command, args []string) {
resolveNativeOrWP(cmd, args, driftDiffNative)
},
}
type driftDiffTarget struct {
Version string `json:"version"`
Hash string `json:"hash"`
Site driftSiteInfo `json:"site"`
ComponentPath string `json:"-"`
}
type driftDiffVariant struct {
Hash string `json:"hash"`
Version string `json:"version"`
Count int `json:"count"`
Sites []driftSiteInfo `json:"sites"`
SourceSite *driftSiteInfo `json:"source_site,omitempty"`
ChangedFiles []string `json:"changed_files,omitempty"`
DiffOutput string `json:"diff,omitempty"`
HasQuicksave bool `json:"has_quicksave"`
}
type driftHashGroup struct {
Hash string
Version string
Count int
Sites []driftSiteInfo
}
func driftDiffNative(cmd *cobra.Command, args []string) {
hasPlugin := flagDriftPlugin != ""
hasTheme := flagDriftTheme != ""
if !hasPlugin && !hasTheme {
fmt.Println("Error: --plugin or --theme is required")
return
}
if hasPlugin && hasTheme {
fmt.Println("Error: Specify only one of --plugin or --theme")
return
}
_, system, _, err := loadCaptainConfig()
if err != nil || system == nil {
fmt.Println("Error: Configuration file not found.")
return
}
env := normalizeEnv(flagDriftEnvironment)
// Query DB for all sites with this component
var results []models.SiteEnvironmentResult
query := models.DB.Table("captaincore_sites").
Select("captaincore_sites.site, captaincore_sites.site_id, captaincore_sites.name, captaincore_sites.provider, captaincore_environments.environment, captaincore_environments.home_url, captaincore_environments.core, captaincore_environments.plugins, captaincore_environments.themes").
Joins("INNER JOIN captaincore_environments ON captaincore_sites.site_id = captaincore_environments.site_id").
Where("captaincore_sites.status = ?", "active")
if env != "all" {
query = query.Where("captaincore_environments.environment = ?", env)
}
if flagDriftProvider != "" {
query = query.Where("captaincore_sites.provider = ?", flagDriftProvider)
}
if hasPlugin {
query = query.Where("captaincore_environments.plugins LIKE ?", `%"name":"`+flagDriftPlugin+`"%`)
}
if hasTheme {
query = query.Where("captaincore_environments.themes LIKE ?", `%"name":"`+flagDriftTheme+`"%`)
}
query.Order("captaincore_sites.name ASC").Find(&results)
pluginSlug, themeSlug := "", ""
if hasPlugin {
pluginSlug = flagDriftPlugin
} else {
themeSlug = flagDriftTheme
}
// Build hash map: hash -> {version, count, sites}
hashMap := make(map[string]*driftHashGroup)
for _, r := range results {
version, hash := extractVersionAndHash(r, pluginSlug, themeSlug)
if version == "" {
continue
}
if hash == "" {
hash = "(no hash)"
}
hg, ok := hashMap[hash]
if !ok {
hg = &driftHashGroup{Hash: hash, Version: version}
hashMap[hash] = hg
}
hg.Count++
hg.Sites = append(hg.Sites, driftSiteInfo{
Site: r.Site,
SiteID: r.SiteID,
Name: r.Name,
Provider: r.Provider,
HomeURL: r.HomeURL,
Environment: r.Environment,
})
}
if len(hashMap) == 0 {
label, slug := componentLabel(hasPlugin, hasTheme, false)
fmt.Printf("No sites found with %s \"%s\" installed.\n", label, slug)
suggestSlugs(results, slug, hasPlugin)
return
}
if len(hashMap) == 1 {
for hash, hg := range hashMap {
hashLabel := hash
if len(hashLabel) > 8 {
hashLabel = hashLabel[:8]
}
fmt.Printf("All %s sites have identical content (hash %s, version %s). No drift detected.\n",
formatNumber(hg.Count), hashLabel, hg.Version)
}
return
}
// Resolve target
targetHash, targetVersion, err := driftDiffResolveTarget(hashMap)
if err != nil {
fmt.Printf("Error: %s\n", err)
return
}
slug := pluginSlug
if slug == "" {
slug = themeSlug
}
componentDir := "plugins"
if hasTheme {
componentDir = "themes"
}
// Find quicksave for target
targetGroup := hashMap[targetHash]
targetPath, targetSite, found := findQuicksaveForHash(targetGroup.Sites, slug, componentDir, system)
if !found {
fmt.Printf("Error: No local quicksave found for target hash %s.\n", targetHash[:8])
fmt.Printf("Run 'captaincore quicksave generate %s' to create one.\n", targetGroup.Sites[0].Site)
return
}
target := driftDiffTarget{
Version: targetVersion,
Hash: targetHash,
Site: targetSite,
ComponentPath: targetPath,
}
// Filter to a specific hash if --hash is set
filterHash := flagDriftDiffHash
// When --target is a version string (not "latest"), filter to only that version
filterVersion := ""
if flagDriftTarget != "" && flagDriftTarget != "latest" {
// Check if target was a version (not a hash prefix)
for _, hg := range hashMap {
if hg.Version == flagDriftTarget {
filterVersion = flagDriftTarget
break
}
}
}
// Build variant list
var variants []driftDiffVariant
for hash, hg := range hashMap {
if hash == targetHash {
continue
}
if filterHash != "" && !strings.HasPrefix(hash, filterHash) {
continue
}
if filterVersion != "" && hg.Version != filterVersion {
continue
}
variant := driftDiffVariant{
Hash: hash,
Version: hg.Version,
Count: hg.Count,
Sites: hg.Sites,
}
variantPath, variantSite, found := findQuicksaveForHash(hg.Sites, slug, componentDir, system)
if found {
variant.HasQuicksave = true
variant.SourceSite = &variantSite
diffOutput, changedFiles := diffDirectories(targetPath, variantPath, target.Hash, hash, target.Site.Site, variantSite.Site)
variant.DiffOutput = diffOutput
variant.ChangedFiles = changedFiles
}
variants = append(variants, variant)
}
// Sort variants: by version descending, then by count descending
sort.Slice(variants, func(i, j int) bool {
cmp := compareVersions(variants[i].Version, variants[j].Version)
if cmp != 0 {
return cmp > 0
}
return variants[i].Count > variants[j].Count
})
if filterHash != "" && len(variants) == 0 {
fmt.Printf("No hash variant found matching prefix \"%s\".\n", filterHash)
return
}
// Render
if flagDriftJSON {
renderDriftDiffJSON(target, variants, slug, hasPlugin, env)
} else if flagDriftDiffSummary || (filterHash == "" && len(variants) > 5) {
renderDriftDiffSummary(target, variants, slug, hasPlugin, env)
if !flagDriftDiffSummary && len(variants) > 5 {
fmt.Printf("\n%d variants found. Showing summary. Use --hash=<prefix> to see diff for a specific variant.\n", len(variants))
}
} else {
renderDriftDiffFull(target, variants, slug, hasPlugin, env)
}
}
// driftDiffResolveTarget resolves the --target flag to a specific (hash, version) pair.
func driftDiffResolveTarget(hashMap map[string]*driftHashGroup) (hash string, version string, err error) {
targetFlag := flagDriftTarget
if targetFlag == "" {
targetFlag = "latest"
}
if targetFlag == "latest" {
// Find the highest version, then the most common hash within that version
highestVersion := ""
for _, hg := range hashMap {
if highestVersion == "" || compareVersions(hg.Version, highestVersion) > 0 {
highestVersion = hg.Version
}
}
bestHash := ""
bestCount := 0
for h, hg := range hashMap {
if hg.Version == highestVersion && hg.Count > bestCount {
bestCount = hg.Count
bestHash = h
}
}
return bestHash, highestVersion, nil
}
// Check if target matches a version string
for h, hg := range hashMap {
if hg.Version == targetFlag {
// Find most common hash for this version
bestHash := h
bestCount := hg.Count
for h2, hg2 := range hashMap {
if hg2.Version == targetFlag && hg2.Count > bestCount {
bestCount = hg2.Count
bestHash = h2
}
}
return bestHash, targetFlag, nil
}
}
// Check if target matches a hash prefix
if len(targetFlag) >= 8 {
for h, hg := range hashMap {
if strings.HasPrefix(h, targetFlag) {
return h, hg.Version, nil
}
}
}
return "", "", fmt.Errorf("target \"%s\" does not match any version or hash in the fleet", targetFlag)
}
// findQuicksaveForHash finds a local quicksave directory containing the component for one of the given sites.
func findQuicksaveForHash(sites []driftSiteInfo, slug, componentDir string, system *config.SystemConfig) (componentPath string, site driftSiteInfo, found bool) {
for _, s := range sites {
siteDir := fmt.Sprintf("%s_%d", s.Site, s.SiteID)
envName := strings.ToLower(s.Environment)
cp := filepath.Join(system.Path, siteDir, envName, "quicksave", componentDir, slug)
if info, err := os.Stat(cp); err == nil && info.IsDir() {
return cp, s, true
}
}
return "", driftSiteInfo{}, false
}
// diffDirectories runs diff between two component directories, returning the full diff output and a list of changed files.
func diffDirectories(targetDir, variantDir, targetHash, variantHash, targetSiteName, variantSiteName string) (output string, changedFiles []string) {
// Get changed file list via diff -rq (ignore line-ending differences)
qCmd := exec.Command("diff", "-rq", "--strip-trailing-cr", targetDir, variantDir)
qOut, _ := qCmd.CombinedOutput()
lines := strings.Split(strings.TrimSpace(string(qOut)), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
// diff -rq output: "Files X and Y differ" or "Only in X: file"
if strings.HasPrefix(line, "Files ") {
parts := strings.SplitN(line, " and ", 2)
if len(parts) == 2 {
file := strings.TrimPrefix(parts[0], "Files ")
// Make relative to targetDir
rel, err := filepath.Rel(targetDir, file)
if err == nil {
changedFiles = append(changedFiles, rel)
}
}
} else if strings.HasPrefix(line, "Only in ") {
changedFiles = append(changedFiles, line)
}
}
// Build labels with hash prefix and site name
hashLabel := targetHash
if hashLabel != "(no hash)" && len(hashLabel) > 8 {
hashLabel = hashLabel[:8]
}
targetLabel := fmt.Sprintf("target/%s (%s)", hashLabel, targetSiteName)
hashLabel = variantHash
if hashLabel != "(no hash)" && len(hashLabel) > 8 {
hashLabel = hashLabel[:8]
}
variantLabel := fmt.Sprintf("variant/%s (%s)", hashLabel, variantSiteName)
dCmd := exec.Command("diff", "-ruN", "--strip-trailing-cr",
"--label", targetLabel,
"--label", variantLabel,
targetDir, variantDir)
dOut, _ := dCmd.CombinedOutput()
// Clean raw diff headers to remove full filesystem paths
outputLines := strings.Split(string(dOut), "\n")
var cleanedLines []string
for _, line := range outputLines {
if strings.HasPrefix(line, "diff ") {
// Extract the relative file path from the target directory path
parts := strings.SplitN(line, targetDir, 2)
if len(parts) == 2 {
relPath := strings.SplitN(parts[1], " ", 2)
if len(relPath) > 0 {
line = "diff " + strings.TrimPrefix(relPath[0], "/")
}
}
}
cleanedLines = append(cleanedLines, line)
}
output = strings.Join(cleanedLines, "\n")
return output, changedFiles
}
// renderDriftDiffSummary prints a summary table of variants with changed file counts.
func renderDriftDiffSummary(target driftDiffTarget, variants []driftDiffVariant, slug string, isPlugin bool, env string) {
label := "Plugin"
if !isPlugin {
label = "Theme"
}
targetHashLabel := target.Hash
if len(targetHashLabel) > 8 {
targetHashLabel = targetHashLabel[:8]
}
fmt.Printf("%s: %s\n", label, slug)
fmt.Printf("Target: %s (hash %s, from site %s)\n", target.Version, targetHashLabel, target.Site.Site)
fmt.Printf("Environment: %s\n\n", env)
w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
fmt.Fprintln(w, "HASH\tVERSION\tSITES\tCHANGED FILES")
fmt.Fprintf(w, "%s\t%s\t\t(target)\n", targetHashLabel, target.Version)
for _, v := range variants {
hashLabel := v.Hash
if hashLabel != "(no hash)" && len(hashLabel) > 8 {
hashLabel = hashLabel[:8]
}
fileInfo := "(no local quicksave)"
if v.HasQuicksave {
fileInfo = fmt.Sprintf("%d files changed", len(v.ChangedFiles))
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", hashLabel, v.Version, formatNumber(v.Count), fileInfo)
}
w.Flush()
}
// renderDriftDiffFull prints full colored diff output for each variant.
func renderDriftDiffFull(target driftDiffTarget, variants []driftDiffVariant, slug string, isPlugin bool, env string) {
label := "Plugin"
if !isPlugin {
label = "Theme"
}
targetHashLabel := target.Hash
if len(targetHashLabel) > 8 {
targetHashLabel = targetHashLabel[:8]
}
fmt.Printf("%s: %s\n", label, slug)
fmt.Printf("Target: %s (hash %s, from site %s)\n", target.Version, targetHashLabel, target.Site.Site)
fmt.Printf("Environment: %s\n\n", env)
for _, v := range variants {
hashLabel := v.Hash
if hashLabel != "(no hash)" && len(hashLabel) > 8 {
hashLabel = hashLabel[:8]
}
fmt.Printf("--- Variant: %s (version %s, %s sites) ", hashLabel, v.Version, formatNumber(v.Count))
if v.SourceSite != nil {
fmt.Printf("from site %s", v.SourceSite.Site)
}
fmt.Println(" ---")
if !v.HasQuicksave {
fmt.Println(" (no local quicksave available)")
if len(v.Sites) > 0 {
fmt.Printf(" Run: captaincore quicksave generate %s\n", v.Sites[0].Site)
}
fmt.Println()
continue
}
if len(v.ChangedFiles) == 0 {
fmt.Println(" No file differences found.")
fmt.Println()
continue
}
// Colorize diff output
for _, line := range strings.Split(v.DiffOutput, "\n") {
if strings.HasPrefix(line, "+") && !strings.HasPrefix(line, "+++") {
fmt.Printf("\x1b[32m%s\x1b[0m\n", line)
} else if strings.HasPrefix(line, "-") && !strings.HasPrefix(line, "---") {
fmt.Printf("\x1b[31m%s\x1b[0m\n", line)
} else if strings.HasPrefix(line, "@@") {
fmt.Printf("\x1b[36m%s\x1b[0m\n", line)
} else if strings.HasPrefix(line, "diff ") {
fmt.Printf("\x1b[1m%s\x1b[0m\n", line)
} else {
fmt.Println(line)
}
}
fmt.Println()
}
}
// renderDriftDiffJSON outputs drift diff results as JSON.
func renderDriftDiffJSON(target driftDiffTarget, variants []driftDiffVariant, slug string, isPlugin bool, env string) {
label := "plugin"
if !isPlugin {
label = "theme"
}
variantsJSON := make([]map[string]interface{}, 0, len(variants))
for _, v := range variants {
vj := map[string]interface{}{
"hash": v.Hash,
"version": v.Version,
"count": v.Count,
"has_quicksave": v.HasQuicksave,
"changed_files": v.ChangedFiles,
}
if v.SourceSite != nil {
vj["source_site"] = map[string]interface{}{
"site": v.SourceSite.Site,
"site_id": v.SourceSite.SiteID,
"environment": v.SourceSite.Environment,
}
}
if v.DiffOutput != "" {
vj["diff"] = v.DiffOutput
}
sites := make([]map[string]interface{}, 0, len(v.Sites))
for _, s := range v.Sites {
sites = append(sites, map[string]interface{}{
"site": s.Site,
"site_id": s.SiteID,
"environment": s.Environment,
"name": s.Name,
"provider": s.Provider,
"home_url": s.HomeURL,
})
}
vj["sites"] = sites
variantsJSON = append(variantsJSON, vj)
}
data := map[string]interface{}{
"type": label,
"slug": slug,
"environment": env,
"target": map[string]interface{}{
"version": target.Version,
"hash": target.Hash,
"site": target.Site.Site,
"site_id": target.Site.SiteID,
},
"variants": variantsJSON,
}
result, _ := json.MarshalIndent(data, "", " ")
fmt.Println(string(result))
}
func init() {
rootCmd.AddCommand(driftCmd)
driftCmd.Flags().BoolVar(&flagDriftJSON, "json", false, "Output as JSON")
driftCmd.Flags().StringVar(&flagDriftPlugin, "plugin", "", "Plugin slug to check")
driftCmd.Flags().StringVar(&flagDriftTheme, "theme", "", "Theme slug to check")
driftCmd.Flags().BoolVar(&flagDriftCore, "core", false, "Check WordPress core versions")
driftCmd.Flags().BoolVar(&flagDriftThemes, "themes", false, "Overview mode: rank themes instead of plugins")
driftCmd.Flags().StringVar(&flagDriftTarget, "target", "", "Target version — use 'latest' to auto-detect (shows sites NOT on this version)")
driftCmd.Flags().StringVar(&flagDriftProvider, "provider", "", "Filter by hosting provider")
driftCmd.Flags().StringVar(&flagDriftEnvironment, "environment", "Production", "Filter by environment (Production, Staging, all)")
driftCmd.Flags().IntVar(&flagDriftTop, "top", 20, "Overview mode: number of results to show")
driftCmd.Flags().StringVar(&flagDriftSort, "sort", "volume", "Overview mode: sort by volume (drifted count) or spread (version count)")
driftCmd.Flags().BoolVar(&flagDriftHashes, "hashes", false, "Show hash breakdown within each version (use with --plugin or --theme)")
driftCmd.Flags().BoolVar(&flagDriftSteer, "steer", false, "Upgrade all drifted sites to the latest version")
driftCmd.Flags().BoolVarP(&flagForce, "force", "", false, "Execute the steer upgrade (required with --steer)")
driftCmd.Flags().IntVarP(&flagParallel, "parallel", "p", 10, "Number of parallel deployments")
// drift diff subcommand
driftCmd.AddCommand(driftDiffCmd)
driftDiffCmd.Flags().StringVar(&flagDriftPlugin, "plugin", "", "Plugin slug to diff")
driftDiffCmd.Flags().StringVar(&flagDriftTheme, "theme", "", "Theme slug to diff")
driftDiffCmd.Flags().StringVar(&flagDriftTarget, "target", "", "Target version or hash (default: latest)")
driftDiffCmd.Flags().BoolVar(&flagDriftDiffSummary, "summary", false, "Show only changed file counts per variant")
driftDiffCmd.Flags().StringVar(&flagDriftDiffHash, "hash", "", "Show diff for a specific hash variant (prefix match)")
driftDiffCmd.Flags().BoolVar(&flagDriftJSON, "json", false, "Output as JSON")
driftDiffCmd.Flags().StringVar(&flagDriftProvider, "provider", "", "Filter by hosting provider")
driftDiffCmd.Flags().StringVar(&flagDriftEnvironment, "environment", "Production", "Filter by environment (Production, Staging, all)")
}