From fedb05fee5bc0b05f8b518b19a62eebaf71697c4 Mon Sep 17 00:00:00 2001 From: Pascal Martineau Date: Wed, 27 May 2026 08:37:11 -0400 Subject: [PATCH] feat: wpop sync --- src/cli.ts | 20 ++ src/commands/deploy.ts | 131 +------------ src/commands/sync.ts | 426 +++++++++++++++++++++++++++++++++++++++++ src/lib/remote.ts | 129 +++++++++++++ src/lib/ssh.ts | 2 + 5 files changed, 579 insertions(+), 129 deletions(-) create mode 100644 src/commands/sync.ts create mode 100644 src/lib/remote.ts diff --git a/src/cli.ts b/src/cli.ts index e3b1d82..19ed4a7 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,6 +1,7 @@ #!/usr/bin/env node import { Command } from "commander"; import { deploy } from "./commands/deploy"; +import { sync } from "./commands/sync"; import { createContext } from "./lib/context"; import { configureLogger, emitJson } from "./lib/output"; import pkg from "../package.json" with { type: "json" }; @@ -37,6 +38,25 @@ program } }); +program + .command("sync") + .description("Sync a WordPress project from the remote into the local working copy") + .option( + "--include ", + "comma-separated components to sync: database,uploads,plugins,themes,mu-plugins or all", + ) + .option("--skip-search-replace", "skip URL search-replace after database import") + .action(async (options: { include?: string; skipSearchReplace?: boolean }) => { + const context = createContext(program.opts()); + configureLogger(context); + try { + await sync(context, options); + } catch (error) { + handleError(context, error); + process.exitCode = 1; + } + }); + function handleError(context: { json: boolean }, error: unknown): void { const message = error instanceof Error ? error.message : String(error); if (context.json) { diff --git a/src/commands/deploy.ts b/src/commands/deploy.ts index d1ca946..0d187b3 100644 --- a/src/commands/deploy.ts +++ b/src/commands/deploy.ts @@ -5,15 +5,9 @@ import prompts from "prompts"; import type { WPopContext } from "../lib/context"; import { readDeployEnv, type DeployEnv } from "../lib/env"; import { emitJson } from "../lib/output"; +import { parseMarkedOutput, rsync, sshOutput, sshRun } from "../lib/remote"; import { run } from "../lib/run"; -import { - createSshConfig, - prepareSsh, - rsyncSshShell, - sshArgs, - sshTarget, - type PreparedSsh, -} from "../lib/ssh"; +import { createSshConfig, prepareSsh, sshTarget, type PreparedSsh } from "../lib/ssh"; import { createTempDir } from "../lib/tempdir"; const DEFAULT_COMPONENTS = ["vendor", "plugins", "themes", "mu-plugins"] as const; @@ -41,8 +35,6 @@ const REMOTE_LIST_BEGIN = "__WPOP_REMOTE_LIST_BEGIN__"; const REMOTE_LIST_END = "__WPOP_REMOTE_LIST_END__"; const COMPOSER_AUTOLOAD_BEGIN = "__WPOP_COMPOSER_AUTOLOAD_BEGIN__"; const COMPOSER_AUTOLOAD_END = "__WPOP_COMPOSER_AUTOLOAD_END__"; -const SSH_RUN_BEGIN = "__WPOP_SSH_RUN_BEGIN__"; -const SSH_RUN_END = "__WPOP_SSH_RUN_END__"; type PackageManager = { lockfile: string; @@ -671,122 +663,3 @@ fi await sshRun(context, ssh, script); } - -async function rsync(context: WPopContext, ssh: PreparedSsh, args: string[]): Promise { - const result = await run(context, "rsync", ["-az", "-e", rsyncSshShell(ssh), ...args], { - capture: true, - reject: false, - }); - - if (context.dryRun) { - return; - } - - const output = filterRemoteBanner([result.stdout, result.stderr].filter(Boolean).join("\n")); - if (context.verbose && output) { - process.stdout.write(`${output}\n`); - } - - if (result.exitCode !== 0) { - if (!context.verbose && output) { - process.stderr.write(`${output}\n`); - } - throw new Error(`rsync failed with exit code ${result.exitCode}`); - } -} - -async function sshRun(context: WPopContext, ssh: PreparedSsh, script: string): Promise { - const result = await run(context, "ssh", [...sshArgs(ssh), sshTarget(ssh)], { - stdin: wrapRemoteScriptOutput(script), - capture: true, - reject: false, - }); - - if (context.dryRun) { - return; - } - - const output = parseMarkedOutput( - result.stdout, - SSH_RUN_BEGIN, - SSH_RUN_END, - "remote command output", - ); - if (context.verbose && output.length > 0) { - process.stdout.write(`${output.join("\n")}\n`); - } - - if (result.exitCode !== 0) { - if (!context.verbose && output.length > 0) { - process.stderr.write(`${output.join("\n")}\n`); - } - throw new Error(`Remote SSH command failed with exit code ${result.exitCode}`); - } -} - -async function sshOutput( - context: WPopContext, - ssh: PreparedSsh, - script: string, -): Promise<{ stdout: string }> { - return run(context, "ssh", [...sshArgs(ssh), sshTarget(ssh)], { - stdin: script, - capture: true, - }); -} - -function parseMarkedOutput( - stdout: string, - beginMarker: string, - endMarker: string, - label: string, -): string[] { - const output = stdout.split("\n").map((line) => line.trim()); - const begin = output.indexOf(beginMarker); - const end = output.indexOf(endMarker); - - if (begin === -1 || end === -1 || end < begin) { - throw new Error(`Could not parse ${label} from SSH output`); - } - - return output.slice(begin + 1, end).filter(Boolean); -} - -function wrapRemoteScriptOutput(script: string): string { - return `printf '%s\\n' ${JSON.stringify(SSH_RUN_BEGIN)} -( -${script} -) 2>&1 -status=$? -printf '%s\\n' ${JSON.stringify(SSH_RUN_END)} -exit "$status" -`; -} - -function filterRemoteBanner(output: string): string { - const lines = output.split("\n"); - const filtered: string[] = []; - let skippingBanner = false; - - for (const line of lines) { - if (line.includes("This server is managed by Ansible and Cloud-init.")) { - skippingBanner = true; - continue; - } - - if (skippingBanner) { - if (line.trim().startsWith("Last deployment:")) { - skippingBanner = false; - } - continue; - } - - filtered.push(line); - } - - return filtered - .map((line) => line.trimEnd()) - .filter((line, index, all) => line.trim() || (index > 0 && index < all.length - 1)) - .join("\n") - .trim(); -} diff --git a/src/commands/sync.ts b/src/commands/sync.ts new file mode 100644 index 0000000..200296a --- /dev/null +++ b/src/commands/sync.ts @@ -0,0 +1,426 @@ +import { existsSync, mkdirSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { consola } from "consola"; +import prompts from "prompts"; +import type { WPopContext } from "../lib/context"; +import { readDeployEnv, type DeployEnv } from "../lib/env"; +import { emitJson } from "../lib/output"; +import { parseMarkedOutput, rsync, sshOutput } from "../lib/remote"; +import { run } from "../lib/run"; +import { createSshConfig, prepareSsh, sshArgs, sshTarget, type PreparedSsh } from "../lib/ssh"; + +const SYNC_COMPONENTS = ["database", "uploads", "plugins", "themes", "mu-plugins"] as const; +const DEFAULT_SYNC_COMPONENTS = ["database", "uploads"] as const; +const CONTENT_SYNC_COMPONENTS = ["uploads", "plugins", "themes", "mu-plugins"] as const; +const CODE_SYNC_COMPONENTS = ["plugins", "themes", "mu-plugins"] as const; + +const REMOTE_LIST_BEGIN = "__WPOP_REMOTE_LIST_BEGIN__"; +const REMOTE_LIST_END = "__WPOP_REMOTE_LIST_END__"; +const REMOTE_OPTION_BEGIN = "__WPOP_REMOTE_OPTION_BEGIN__"; +const REMOTE_OPTION_END = "__WPOP_REMOTE_OPTION_END__"; +const REMOTE_DB_BEGIN = "__WPOP_REMOTE_DB_BEGIN__"; + +export type SyncComponent = (typeof SYNC_COMPONENTS)[number]; + +export type SyncOptions = { + include?: string; + skipSearchReplace?: boolean; +}; + +type SyncReport = { + command: "sync"; + cwd: string; + include: SyncComponent[]; + remote: { host: string; port: number; user: string; path: string }; + cacheDir: string; + dryRun: boolean; + steps: string[]; +}; + +export async function sync(context: WPopContext, options: SyncOptions): Promise { + const env = await readDeployEnv(process.env, { cwd: context.cwd }); + const promptedForComponents = shouldPromptForComponents(context, options.include); + const components = await resolveComponents(context, options.include); + if (!components) { + consola.warn("Aborted."); + return; + } + + const report = buildReport(context, env, components); + + if (context.json) { + if (!context.yes && !context.dryRun) { + throw new Error("--json requires --yes (cannot prompt for confirmation in JSON mode)"); + } + } else { + consola.info( + `Planning sync of ${[...components].join(", ")} from ${sshTargetSummary(env)}:${env.REMOTE_PATH}`, + ); + if (context.dryRun) { + consola.info("Dry-run: no local changes will be made"); + } + if (components.has("database")) { + consola.warn("This will OVERWRITE the local database."); + } + if (!promptedForComponents && !(await confirm(context, components, env))) { + consola.warn("Aborted."); + return; + } + } + + const ssh = await prepareSsh(context, createSshConfig(env), env.WPOP_CACHE_DIR); + + await warnContentDrift(context, env, ssh, components); + + let localSiteurl: string | undefined; + if (components.has("database") && !options.skipSearchReplace) { + localSiteurl = await readLocalOption(context, "siteurl"); + } + + for (const component of CONTENT_SYNC_COMPONENTS) { + if (components.has(component)) { + await syncContentComponent(context, env, ssh, component); + report.steps.push(`sync:${component}`); + } + } + + if (components.has("database")) { + await syncDatabase(context, env, ssh); + report.steps.push("db:import"); + + if (!options.skipSearchReplace) { + await runSearchReplace(context, ssh, env, localSiteurl); + report.steps.push("db:search-replace"); + } + } + + if (context.json) { + emitJson(report); + } else { + consola.success("Sync complete."); + } +} + +function buildReport( + context: WPopContext, + env: DeployEnv, + components: Set, +): SyncReport { + return { + command: "sync", + cwd: context.cwd, + include: [...components], + remote: { + host: env.REMOTE_HOST, + port: env.REMOTE_PORT, + user: env.REMOTE_USER, + path: env.REMOTE_PATH, + }, + cacheDir: env.WPOP_CACHE_DIR, + dryRun: context.dryRun, + steps: [], + }; +} + +function sshTargetSummary(env: DeployEnv): string { + return `${env.REMOTE_USER}@${env.REMOTE_HOST}:${env.REMOTE_PORT}`; +} + +function shouldPromptForComponents(context: WPopContext, include?: string): boolean { + return !include && !context.yes && !context.dryRun && !context.json; +} + +async function resolveComponents( + context: WPopContext, + include?: string, +): Promise | undefined> { + if (!shouldPromptForComponents(context, include)) { + return parseComponents(include); + } + + const response = await prompts({ + type: "multiselect", + name: "components", + message: "Select sync components", + choices: SYNC_COMPONENTS.map((component) => ({ + title: component, + value: component, + selected: DEFAULT_SYNC_COMPONENTS.includes( + component as (typeof DEFAULT_SYNC_COMPONENTS)[number], + ), + })), + instructions: false, + }); + + if (!Array.isArray(response.components)) { + return undefined; + } + + if (response.components.length === 0) { + throw new Error("Select at least one sync component"); + } + + return new Set(response.components as SyncComponent[]); +} + +async function confirm( + context: WPopContext, + components: Set, + env: DeployEnv, +): Promise { + if (context.yes || context.dryRun) { + return true; + } + + const response = await prompts({ + type: "confirm", + name: "confirmed", + message: `Sync ${[...components].join(",")} from ${sshTargetSummary(env)}:${env.REMOTE_PATH} into ${context.cwd}?`, + initial: false, + }); + + return Boolean(response.confirmed); +} + +function parseComponents(include?: string): Set { + if (!include) { + return new Set(DEFAULT_SYNC_COMPONENTS); + } + + if (include === "all") { + return new Set(SYNC_COMPONENTS); + } + + const components = include + .split(",") + .map((component) => component.trim()) + .filter(Boolean); + const invalid = components.filter( + (component) => !SYNC_COMPONENTS.includes(component as SyncComponent), + ); + + if (invalid.length > 0) { + throw new Error(`Invalid sync component(s): ${invalid.join(", ")}`); + } + + return new Set(components as SyncComponent[]); +} + +function contentPath(component: (typeof CONTENT_SYNC_COMPONENTS)[number]): string { + return component === "uploads" ? "wp-content/uploads" : `wp-content/${component}`; +} + +async function syncContentComponent( + context: WPopContext, + env: DeployEnv, + ssh: PreparedSsh, + component: (typeof CONTENT_SYNC_COMPONENTS)[number], +): Promise { + const path = contentPath(component); + const localPath = join(context.cwd, path); + + if (!context.dryRun) { + mkdirSync(localPath, { recursive: true }); + } + + consola.info(`Synchronizing ${component} from remote`); + + const args: string[] = []; + if (component !== "uploads") { + args.push("--delete"); + } + args.push(`${sshTarget(ssh)}:${env.REMOTE_PATH}/${path}/`, `${localPath}/`); + + await rsync(context, ssh, args); +} + +async function warnContentDrift( + context: WPopContext, + env: DeployEnv, + ssh: PreparedSsh, + components: Set, +): Promise { + for (const component of CODE_SYNC_COMPONENTS) { + if (!components.has(component)) { + continue; + } + + const localPath = join(context.cwd, contentPath(component)); + const remote = await listRemoteTopLevelDirs(context, env, ssh, contentPath(component)); + const local = listLocalTopLevelDirs(localPath); + const onlyLocal = local.filter((name) => !remote.includes(name)); + + if (onlyLocal.length > 0) { + consola.warn( + [ + `Local ${component} contains entries that are not present on the remote:`, + ...onlyLocal.map((name) => `- ${contentPath(component)}/${name}`), + "rsync --delete will remove them on sync.", + ].join("\n"), + ); + } + } +} + +function listLocalTopLevelDirs(path: string): string[] { + if (!existsSync(path)) { + return []; + } + + return readdirSync(path, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(); +} + +async function listRemoteTopLevelDirs( + context: WPopContext, + env: DeployEnv, + ssh: PreparedSsh, + path: string, +): Promise { + const script = `set -e +printf '%s\\n' ${JSON.stringify(REMOTE_LIST_BEGIN)} +cd ${JSON.stringify(env.REMOTE_PATH)} +if [ -d ${JSON.stringify(path)} ]; then + find ${JSON.stringify(path)} -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | sort +fi +printf '%s\\n' ${JSON.stringify(REMOTE_LIST_END)} +`; + + const { stdout } = await sshOutput(context, ssh, script); + if (context.dryRun) { + return []; + } + + return parseMarkedOutput(stdout, REMOTE_LIST_BEGIN, REMOTE_LIST_END, "remote directory listing"); +} + +async function syncDatabase(context: WPopContext, env: DeployEnv, ssh: PreparedSsh): Promise { + consola.info("Importing remote database into local"); + + const remoteDump = [ + `cd ${JSON.stringify(env.REMOTE_PATH)}`, + `printf '%s\\n' ${JSON.stringify(REMOTE_DB_BEGIN)}`, + "wp db export --skip-plugins --skip-themes --single-transaction --quick --default-character-set=utf8mb4 -", + ].join(" && "); + + const pipeline = [ + `ssh ${sshArgs(ssh).join(" ")} ${sshTarget(ssh)} ${JSON.stringify(remoteDump)}`, + `sed -n '/^${REMOTE_DB_BEGIN}$/,$p' | sed '1d'`, + `wp --path=${JSON.stringify(context.cwd)} db import --skip-plugins --skip-themes -`, + ].join(" | "); + + await run(context, "bash", ["-o", "pipefail", "-c", pipeline]); +} + +async function runSearchReplace( + context: WPopContext, + ssh: PreparedSsh, + env: DeployEnv, + localSiteurl: string | undefined, +): Promise { + const remoteSiteurl = await readRemoteOption(context, ssh, env, "siteurl"); + const remoteHome = await readRemoteOption(context, ssh, env, "home"); + + if (context.dryRun) { + consola.info("[dry-run] Would run wp search-replace for siteurl and home"); + await run(context, "wp", ["search-replace", "", "", "--all-tables"], { + cwd: context.cwd, + }); + return; + } + + if (!localSiteurl) { + consola.warn("Could not determine local siteurl; skipping search-replace"); + return; + } + + if (!remoteSiteurl) { + consola.warn("Could not determine remote siteurl; skipping search-replace"); + return; + } + + if (remoteSiteurl === localSiteurl) { + consola.info(`Local and remote siteurl match (${localSiteurl}); skipping search-replace`); + return; + } + + consola.info(`Rewriting URLs: ${remoteSiteurl} -> ${localSiteurl}`); + await run( + context, + "wp", + [ + "search-replace", + remoteSiteurl, + localSiteurl, + "--all-tables", + "--report-changed-only", + "--skip-columns=guid", + ], + { cwd: context.cwd }, + ); + + if (remoteHome && remoteHome !== remoteSiteurl) { + const localHome = localSiteurl; + consola.info(`Rewriting home URLs: ${remoteHome} -> ${localHome}`); + await run( + context, + "wp", + [ + "search-replace", + remoteHome, + localHome, + "--all-tables", + "--report-changed-only", + "--skip-columns=guid", + ], + { cwd: context.cwd }, + ); + } + + await run(context, "wp", ["cache", "flush"], { cwd: context.cwd }); +} + +async function readLocalOption(context: WPopContext, option: string): Promise { + const result = await run(context, "wp", ["option", "get", option], { + capture: true, + cwd: context.cwd, + reject: false, + }); + + if (context.dryRun) { + return undefined; + } + + if (result.exitCode !== 0) { + return undefined; + } + + return result.stdout.trim() || undefined; +} + +async function readRemoteOption( + context: WPopContext, + ssh: PreparedSsh, + env: DeployEnv, + option: string, +): Promise { + const script = `printf '%s\\n' ${JSON.stringify(REMOTE_OPTION_BEGIN)} +cd ${JSON.stringify(env.REMOTE_PATH)} && wp option get ${JSON.stringify(option)} || true +printf '%s\\n' ${JSON.stringify(REMOTE_OPTION_END)} +`; + const { stdout } = await sshOutput(context, ssh, script); + + if (context.dryRun) { + return undefined; + } + + const lines = parseMarkedOutput( + stdout, + REMOTE_OPTION_BEGIN, + REMOTE_OPTION_END, + `remote option ${option}`, + ); + return lines.join("\n").trim() || undefined; +} diff --git a/src/lib/remote.ts b/src/lib/remote.ts new file mode 100644 index 0000000..caf89ed --- /dev/null +++ b/src/lib/remote.ts @@ -0,0 +1,129 @@ +import type { WPopContext } from "./context"; +import { run } from "./run"; +import { type PreparedSsh, rsyncSshShell, sshArgs, sshTarget } from "./ssh"; + +const SSH_RUN_BEGIN = "__WPOP_SSH_RUN_BEGIN__"; +const SSH_RUN_END = "__WPOP_SSH_RUN_END__"; + +export async function rsync(context: WPopContext, ssh: PreparedSsh, args: string[]): Promise { + const result = await run(context, "rsync", ["-az", "-e", rsyncSshShell(ssh), ...args], { + capture: true, + reject: false, + }); + + if (context.dryRun) { + return; + } + + const output = filterRemoteBanner([result.stdout, result.stderr].filter(Boolean).join("\n")); + if (context.verbose && output) { + process.stdout.write(`${output}\n`); + } + + if (result.exitCode !== 0) { + if (!context.verbose && output) { + process.stderr.write(`${output}\n`); + } + throw new Error(`rsync failed with exit code ${result.exitCode}`); + } +} + +export async function sshRun( + context: WPopContext, + ssh: PreparedSsh, + script: string, +): Promise { + const result = await run(context, "ssh", [...sshArgs(ssh), sshTarget(ssh)], { + stdin: wrapRemoteScriptOutput(script), + capture: true, + reject: false, + }); + + if (context.dryRun) { + return; + } + + const output = parseMarkedOutput( + result.stdout, + SSH_RUN_BEGIN, + SSH_RUN_END, + "remote command output", + ); + if (context.verbose && output.length > 0) { + process.stdout.write(`${output.join("\n")}\n`); + } + + if (result.exitCode !== 0) { + if (!context.verbose && output.length > 0) { + process.stderr.write(`${output.join("\n")}\n`); + } + throw new Error(`Remote SSH command failed with exit code ${result.exitCode}`); + } +} + +export async function sshOutput( + context: WPopContext, + ssh: PreparedSsh, + script: string, +): Promise<{ stdout: string }> { + return run(context, "ssh", [...sshArgs(ssh), sshTarget(ssh)], { + stdin: script, + capture: true, + }); +} + +export function parseMarkedOutput( + stdout: string, + beginMarker: string, + endMarker: string, + label: string, +): string[] { + const output = stdout.split("\n").map((line) => line.trim()); + const begin = output.indexOf(beginMarker); + const end = output.indexOf(endMarker); + + if (begin === -1 || end === -1 || end < begin) { + throw new Error(`Could not parse ${label} from SSH output`); + } + + return output.slice(begin + 1, end).filter(Boolean); +} + +function wrapRemoteScriptOutput(script: string): string { + return `printf '%s\\n' ${JSON.stringify(SSH_RUN_BEGIN)} +( +${script} +) 2>&1 +status=$? +printf '%s\\n' ${JSON.stringify(SSH_RUN_END)} +exit "$status" +`; +} + +function filterRemoteBanner(output: string): string { + const lines = output.split("\n"); + const filtered: string[] = []; + let skippingBanner = false; + + for (const line of lines) { + if (line.includes("This server is managed by Ansible and Cloud-init.")) { + skippingBanner = true; + continue; + } + + if (skippingBanner) { + if (line.trim().startsWith("Last deployment:")) { + skippingBanner = false; + } + continue; + } + + filtered.push(line); + } + + return filtered + .map((line) => line.trimEnd()) + .filter((line, index, all) => line.trim() || (index > 0 && index < all.length - 1)) + .join("\n") + .trim(); +} diff --git a/src/lib/ssh.ts b/src/lib/ssh.ts index a71bdf9..f2d5f81 100644 --- a/src/lib/ssh.ts +++ b/src/lib/ssh.ts @@ -68,6 +68,8 @@ export function sshArgs(config: PreparedSsh): string[] { `UserKnownHostsFile=${config.knownHostsFile}`, "-o", "GlobalKnownHostsFile=/dev/null", + "-o", + "LogLevel=ERROR", ]; if (config.identityFile) {