From de6fc197cd051ff4bb5e5cd71db22be462b5ace7 Mon Sep 17 00:00:00 2001 From: Pascal Martineau Date: Thu, 7 May 2026 16:10:37 -0400 Subject: [PATCH] feat: add deploy command options for component inclusion and enhance deployment logic --- src/cli.ts | 6 +- src/commands/deploy.ts | 333 ++++++++++++++++++++++++++++++++++------- src/lib/env.ts | 2 +- 3 files changed, 289 insertions(+), 52 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index afced76..d58aed1 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -19,9 +19,13 @@ program program .command("deploy") .description("Deploy a WordPress project") + .option( + "--include ", + "comma-separated components to deploy: core,vendor,plugins,themes,mu-plugins or all", + ) .option("--skip-composer", "skip Composer dependency installation") .option("--skip-node", "skip theme asset builds") - .action(async (options: { skipComposer?: boolean; skipNode?: boolean }) => { + .action(async (options: { include?: string; skipComposer?: boolean; skipNode?: boolean }) => { await deploy(createContext(program.opts()), options); }); diff --git a/src/commands/deploy.ts b/src/commands/deploy.ts index 0f8c430..c542c60 100644 --- a/src/commands/deploy.ts +++ b/src/commands/deploy.ts @@ -1,19 +1,36 @@ -import { existsSync, mkdirSync, readdirSync, rmSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; import { join } from "node:path"; import { consola } from "consola"; +import { execa } from "execa"; import type { WPopContext } from "../lib/context"; import { readDeployEnv } from "../lib/env"; import { run } from "../lib/run"; -import { createSshConfig, prepareSsh, rsyncSshShell, sshArgs, sshTarget } from "../lib/ssh"; +import { + createSshConfig, + prepareSsh, + rsyncSshShell, + sshArgs, + sshTarget, + type PreparedSsh, +} from "../lib/ssh"; + +const DEFAULT_COMPONENTS = ["vendor", "plugins", "themes", "mu-plugins"] as const; +const ALL_COMPONENTS = ["core", ...DEFAULT_COMPONENTS] as const; +const CONTENT_COMPONENTS = ["plugins", "themes", "mu-plugins"] as const; + +export type DeployComponent = (typeof ALL_COMPONENTS)[number]; export type DeployOptions = { + include?: string; skipComposer?: boolean; skipNode?: boolean; }; export async function deploy(context: WPopContext, options: DeployOptions): Promise { const env = readDeployEnv(); - const commandEnv = createCommandEnv(env.CACHE_DIR); + const components = parseComponents(options.include); + const commandEnv = createCommandEnv(env.WPOP_CACHE_DIR); if (context.json) { console.log( @@ -21,13 +38,14 @@ export async function deploy(context: WPopContext, options: DeployOptions): Prom { command: "deploy", cwd: context.cwd, + include: [...components], remote: { host: env.REMOTE_HOST, port: env.REMOTE_PORT, user: env.REMOTE_USER, path: env.REMOTE_PATH, }, - cacheDir: env.CACHE_DIR, + cacheDir: env.WPOP_CACHE_DIR, dryRun: context.dryRun, }, null, @@ -36,15 +54,57 @@ export async function deploy(context: WPopContext, options: DeployOptions): Prom ); } - ensureCacheDirs(env.CACHE_DIR); + ensureCacheDirs(env.WPOP_CACHE_DIR); const ssh = await prepareSsh(context, createSshConfig(env)); - await installWordPressCore(context, env, commandEnv); + await installComposerDependencies(context, options, commandEnv); await buildThemes(context, options, commandEnv); - await syncFiles(context, env, ssh); + await checkRemoteContentDrift(context, env, ssh, components); + + if (components.has("core")) { + await syncCore(context, env, ssh, commandEnv); + } + + if (components.has("vendor")) { + const syncedVendor = await syncVendor(context, env, ssh); + if (syncedVendor) { + await checkRemoteComposerAutoload(context, env, ssh); + } + } + + for (const component of CONTENT_COMPONENTS) { + if (components.has(component)) { + await syncContentComponent(context, env, ssh, component); + } + } + await updateRemoteDatabase(context, env, ssh); } +function parseComponents(include?: string): Set { + if (!include) { + return new Set(DEFAULT_COMPONENTS); + } + + if (include === "all") { + return new Set(ALL_COMPONENTS); + } + + const components = include + .split(",") + .map((component) => component.trim()) + .filter(Boolean); + const invalid = components.filter( + (component): component is string => !ALL_COMPONENTS.includes(component as DeployComponent), + ); + + if (invalid.length > 0) { + throw new Error(`Invalid deploy component(s): ${invalid.join(", ")}`); + } + + return new Set(components as DeployComponent[]); +} + function createCommandEnv(cacheDir: string): NodeJS.ProcessEnv { return { ...process.env, @@ -62,33 +122,6 @@ function ensureCacheDirs(cacheDir: string): void { } } -async function installWordPressCore( - context: WPopContext, - env: ReturnType, - commandEnv: NodeJS.ProcessEnv, -): Promise { - consola.info("Installing WordPress core"); - await run( - context, - "wp", - [ - "core", - "download", - "--skip-content", - `--version=${env.WP_VERSION}`, - `--locale=${env.WP_LOCALE}`, - ], - { env: commandEnv }, - ); - - for (const file of ["license.txt", "phpcs.xml", "readme.html", "wp-config-sample.php"]) { - const path = join(context.cwd, file); - if (existsSync(path) && !context.dryRun) { - rmSync(path); - } - } -} - async function installComposerDependencies( context: WPopContext, options: DeployOptions, @@ -176,31 +209,194 @@ async function buildThemes( } } -async function syncFiles( +async function checkRemoteContentDrift( context: WPopContext, env: ReturnType, - ssh: Awaited>, + ssh: PreparedSsh, + components: Set, ): Promise { - consola.info("Synchronizing files"); - const args = [ - "-avz", - "--delete", - "--exclude=.git/", - "--exclude=node_modules/", - "--exclude=.cache/", - "-e", - rsyncSshShell(ssh), - `${context.cwd}/`, - `${sshTarget(ssh)}:${env.REMOTE_PATH}/`, - ]; + for (const component of CONTENT_COMPONENTS) { + if (!components.has(component)) { + continue; + } - await run(context, "rsync", args); + const local = listLocalTopLevelDirs(join(context.cwd, remoteContentPath(component))); + const remote = await listRemoteTopLevelDirs(context, env, ssh, remoteContentPath(component)); + const unmanaged = remote.filter((name) => !local.includes(name)); + + if (unmanaged.length > 0) { + throw new Error( + [ + `Remote ${component} contains entries that are not present in the local build:`, + ...unmanaged.map((name) => `- ${remoteContentPath(component)}/${name}`), + "Refusing to deploy because rsync --delete would remove them.", + ].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: ReturnType, + ssh: PreparedSsh, + path: string, +): Promise { + const script = `set -e +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 +`; + + const stdout = await sshOutput(context, ssh, script); + return stdout + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); +} + +async function syncCore( + context: WPopContext, + env: ReturnType, + ssh: PreparedSsh, + commandEnv: NodeJS.ProcessEnv, +): Promise { + const coreDir = context.dryRun + ? join(tmpdir(), "wpop-core") + : mkdtempSync(join(tmpdir(), "wpop-core-")); + + consola.info("Preparing WordPress core deploy payload"); + await run( + context, + "wp", + [ + "core", + "download", + "--skip-content", + `--path=${coreDir}`, + `--version=${env.WP_VERSION}`, + `--locale=${env.WP_LOCALE}`, + ], + { env: commandEnv }, + ); + + consola.info("Synchronizing WordPress core"); + await rsync(context, ssh, [ + "--delete", + "--exclude=wp-config.php", + "--exclude=wp-content/", + "--exclude=.htaccess", + "--exclude=.user.ini", + "--exclude=php.ini", + "--exclude=robots.txt", + "--exclude=.well-known/", + `${coreDir}/`, + `${sshTarget(ssh)}:${env.REMOTE_PATH}/`, + ]); + + if (!context.dryRun) { + rmSync(coreDir, { force: true, recursive: true }); + } +} + +async function syncVendor( + context: WPopContext, + env: ReturnType, + ssh: PreparedSsh, +): Promise { + let syncedVendor = false; + + if (existsSync(join(context.cwd, "vendor"))) { + await ensureRemoteDirectory(context, env, ssh, "vendor"); + consola.info("Synchronizing vendor"); + await rsync(context, ssh, [ + "--delete", + `${join(context.cwd, "vendor")}/`, + `${sshTarget(ssh)}:${env.REMOTE_PATH}/vendor/`, + ]); + syncedVendor = true; + } else { + consola.warn("vendor directory not found, skipping vendor sync"); + } + + for (const file of ["composer.json", "composer.lock"]) { + const localPath = join(context.cwd, file); + if (existsSync(localPath) && statSync(localPath).isFile()) { + consola.info(`Synchronizing ${file}`); + await rsync(context, ssh, [localPath, `${sshTarget(ssh)}:${env.REMOTE_PATH}/${file}`]); + } + } + + return syncedVendor; +} + +async function checkRemoteComposerAutoload( + context: WPopContext, + env: ReturnType, + ssh: PreparedSsh, +): Promise { + consola.info("Checking remote Composer autoload inclusion"); + const script = `set -e +cd ${JSON.stringify(env.REMOTE_PATH)} +test -f wp-config.php +grep -q 'vendor/autoload.php' wp-config.php +`; + + await sshRun(context, ssh, script); +} + +async function syncContentComponent( + context: WPopContext, + env: ReturnType, + ssh: PreparedSsh, + component: (typeof CONTENT_COMPONENTS)[number], +): Promise { + const path = remoteContentPath(component); + const localPath = join(context.cwd, path); + + if (!existsSync(localPath)) { + consola.warn(`${path} not found, skipping ${component} sync`); + return; + } + + await ensureRemoteDirectory(context, env, ssh, path); + consola.info(`Synchronizing ${component}`); + await rsync(context, ssh, [ + "--delete", + `${localPath}/`, + `${sshTarget(ssh)}:${env.REMOTE_PATH}/${path}/`, + ]); +} + +function remoteContentPath(component: (typeof CONTENT_COMPONENTS)[number]): string { + return `wp-content/${component}`; +} + +async function ensureRemoteDirectory( + context: WPopContext, + env: ReturnType, + ssh: PreparedSsh, + path: string, +): Promise { + await sshRun(context, ssh, `mkdir -p ${JSON.stringify(join(env.REMOTE_PATH, path))}`); } async function updateRemoteDatabase( context: WPopContext, env: ReturnType, - ssh: Awaited>, + ssh: PreparedSsh, ): Promise { consola.info("Updating remote database"); const script = `set -e @@ -211,10 +407,47 @@ if [ -f wp-config.php ]; then echo "Updating WooCommerce database..." wp wc update fi + + ACF_VERSION="" + if wp plugin is-installed advanced-custom-fields-pro >/dev/null 2>&1; then + ACF_VERSION="$(wp plugin get advanced-custom-fields-pro --field=version)" + elif wp plugin is-installed advanced-custom-fields >/dev/null 2>&1; then + ACF_VERSION="$(wp plugin get advanced-custom-fields --field=version)" + fi + + if [ -n "$ACF_VERSION" ] && php -r 'exit(version_compare($argv[1], "6.8", ">=") ? 0 : 1);' "$ACF_VERSION"; then + if wp acf json status >/dev/null 2>&1; then + echo "Synchronizing ACF JSON..." + wp acf json sync + else + echo "ACF $ACF_VERSION detected, but ACF JSON CLI is unavailable. Skipping." + fi + fi else echo "wp-config.php not found. Skipping database update." fi `; + await sshRun(context, ssh, script); +} + +async function rsync(context: WPopContext, ssh: PreparedSsh, args: string[]): Promise { + await run(context, "rsync", ["-az", "-e", rsyncSshShell(ssh), ...args]); +} + +async function sshRun(context: WPopContext, ssh: PreparedSsh, script: string): Promise { await run(context, "ssh", [...sshArgs(ssh), sshTarget(ssh)], { stdin: script }); } + +async function sshOutput(context: WPopContext, ssh: PreparedSsh, script: string): Promise { + if (context.dryRun) { + await sshRun(context, ssh, script); + return ""; + } + + const { stdout } = await execa("ssh", [...sshArgs(ssh), sshTarget(ssh)], { + input: script, + }); + + return stdout; +} diff --git a/src/lib/env.ts b/src/lib/env.ts index 07ee459..18f926d 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -6,7 +6,7 @@ const deployEnvSchema = z.object({ REMOTE_PATH: z.string().min(1), REMOTE_PORT: z.coerce.number().int().positive().default(22), SSH_PRIVATE_KEY: z.string().optional(), - CACHE_DIR: z.string().default("/cache/wpop"), + WPOP_CACHE_DIR: z.string().default("/tmp/wpop"), WP_VERSION: z.string().default("latest"), WP_LOCALE: z.string().default("fr_CA"), });