feat: add deploy command options for component inclusion and enhance deployment logic

This commit is contained in:
2026-05-07 16:10:37 -04:00
parent 9d2d5d27fa
commit de6fc197cd
3 changed files with 289 additions and 52 deletions

View File

@@ -19,9 +19,13 @@ program
program program
.command("deploy") .command("deploy")
.description("Deploy a WordPress project") .description("Deploy a WordPress project")
.option(
"--include <components>",
"comma-separated components to deploy: core,vendor,plugins,themes,mu-plugins or all",
)
.option("--skip-composer", "skip Composer dependency installation") .option("--skip-composer", "skip Composer dependency installation")
.option("--skip-node", "skip theme asset builds") .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); await deploy(createContext(program.opts()), options);
}); });

View File

@@ -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 { join } from "node:path";
import { consola } from "consola"; import { consola } from "consola";
import { execa } from "execa";
import type { WPopContext } from "../lib/context"; import type { WPopContext } from "../lib/context";
import { readDeployEnv } from "../lib/env"; import { readDeployEnv } from "../lib/env";
import { run } from "../lib/run"; 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 = { export type DeployOptions = {
include?: string;
skipComposer?: boolean; skipComposer?: boolean;
skipNode?: boolean; skipNode?: boolean;
}; };
export async function deploy(context: WPopContext, options: DeployOptions): Promise<void> { export async function deploy(context: WPopContext, options: DeployOptions): Promise<void> {
const env = readDeployEnv(); const env = readDeployEnv();
const commandEnv = createCommandEnv(env.CACHE_DIR); const components = parseComponents(options.include);
const commandEnv = createCommandEnv(env.WPOP_CACHE_DIR);
if (context.json) { if (context.json) {
console.log( console.log(
@@ -21,13 +38,14 @@ export async function deploy(context: WPopContext, options: DeployOptions): Prom
{ {
command: "deploy", command: "deploy",
cwd: context.cwd, cwd: context.cwd,
include: [...components],
remote: { remote: {
host: env.REMOTE_HOST, host: env.REMOTE_HOST,
port: env.REMOTE_PORT, port: env.REMOTE_PORT,
user: env.REMOTE_USER, user: env.REMOTE_USER,
path: env.REMOTE_PATH, path: env.REMOTE_PATH,
}, },
cacheDir: env.CACHE_DIR, cacheDir: env.WPOP_CACHE_DIR,
dryRun: context.dryRun, dryRun: context.dryRun,
}, },
null, 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)); const ssh = await prepareSsh(context, createSshConfig(env));
await installWordPressCore(context, env, commandEnv);
await installComposerDependencies(context, options, commandEnv); await installComposerDependencies(context, options, commandEnv);
await buildThemes(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); await updateRemoteDatabase(context, env, ssh);
} }
function parseComponents(include?: string): Set<DeployComponent> {
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 { function createCommandEnv(cacheDir: string): NodeJS.ProcessEnv {
return { return {
...process.env, ...process.env,
@@ -62,33 +122,6 @@ function ensureCacheDirs(cacheDir: string): void {
} }
} }
async function installWordPressCore(
context: WPopContext,
env: ReturnType<typeof readDeployEnv>,
commandEnv: NodeJS.ProcessEnv,
): Promise<void> {
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( async function installComposerDependencies(
context: WPopContext, context: WPopContext,
options: DeployOptions, options: DeployOptions,
@@ -176,31 +209,194 @@ async function buildThemes(
} }
} }
async function syncFiles( async function checkRemoteContentDrift(
context: WPopContext, context: WPopContext,
env: ReturnType<typeof readDeployEnv>, env: ReturnType<typeof readDeployEnv>,
ssh: Awaited<ReturnType<typeof prepareSsh>>, ssh: PreparedSsh,
components: Set<DeployComponent>,
): Promise<void> { ): Promise<void> {
consola.info("Synchronizing files"); for (const component of CONTENT_COMPONENTS) {
const args = [ if (!components.has(component)) {
"-avz", continue;
"--delete", }
"--exclude=.git/",
"--exclude=node_modules/",
"--exclude=.cache/",
"-e",
rsyncSshShell(ssh),
`${context.cwd}/`,
`${sshTarget(ssh)}:${env.REMOTE_PATH}/`,
];
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<typeof readDeployEnv>,
ssh: PreparedSsh,
path: string,
): Promise<string[]> {
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<typeof readDeployEnv>,
ssh: PreparedSsh,
commandEnv: NodeJS.ProcessEnv,
): Promise<void> {
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<typeof readDeployEnv>,
ssh: PreparedSsh,
): Promise<boolean> {
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<typeof readDeployEnv>,
ssh: PreparedSsh,
): Promise<void> {
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<typeof readDeployEnv>,
ssh: PreparedSsh,
component: (typeof CONTENT_COMPONENTS)[number],
): Promise<void> {
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<typeof readDeployEnv>,
ssh: PreparedSsh,
path: string,
): Promise<void> {
await sshRun(context, ssh, `mkdir -p ${JSON.stringify(join(env.REMOTE_PATH, path))}`);
} }
async function updateRemoteDatabase( async function updateRemoteDatabase(
context: WPopContext, context: WPopContext,
env: ReturnType<typeof readDeployEnv>, env: ReturnType<typeof readDeployEnv>,
ssh: Awaited<ReturnType<typeof prepareSsh>>, ssh: PreparedSsh,
): Promise<void> { ): Promise<void> {
consola.info("Updating remote database"); consola.info("Updating remote database");
const script = `set -e const script = `set -e
@@ -211,10 +407,47 @@ if [ -f wp-config.php ]; then
echo "Updating WooCommerce database..." echo "Updating WooCommerce database..."
wp wc update wp wc update
fi 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 else
echo "wp-config.php not found. Skipping database update." echo "wp-config.php not found. Skipping database update."
fi fi
`; `;
await sshRun(context, ssh, script);
}
async function rsync(context: WPopContext, ssh: PreparedSsh, args: string[]): Promise<void> {
await run(context, "rsync", ["-az", "-e", rsyncSshShell(ssh), ...args]);
}
async function sshRun(context: WPopContext, ssh: PreparedSsh, script: string): Promise<void> {
await run(context, "ssh", [...sshArgs(ssh), sshTarget(ssh)], { stdin: script }); await run(context, "ssh", [...sshArgs(ssh), sshTarget(ssh)], { stdin: script });
} }
async function sshOutput(context: WPopContext, ssh: PreparedSsh, script: string): Promise<string> {
if (context.dryRun) {
await sshRun(context, ssh, script);
return "";
}
const { stdout } = await execa("ssh", [...sshArgs(ssh), sshTarget(ssh)], {
input: script,
});
return stdout;
}

View File

@@ -6,7 +6,7 @@ const deployEnvSchema = z.object({
REMOTE_PATH: z.string().min(1), REMOTE_PATH: z.string().min(1),
REMOTE_PORT: z.coerce.number().int().positive().default(22), REMOTE_PORT: z.coerce.number().int().positive().default(22),
SSH_PRIVATE_KEY: z.string().optional(), 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_VERSION: z.string().default("latest"),
WP_LOCALE: z.string().default("fr_CA"), WP_LOCALE: z.string().default("fr_CA"),
}); });