feat: add deploy command options for component inclusion and enhance deployment logic
This commit is contained in:
@@ -19,9 +19,13 @@ program
|
||||
program
|
||||
.command("deploy")
|
||||
.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-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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
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<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 {
|
||||
return {
|
||||
...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(
|
||||
context: WPopContext,
|
||||
options: DeployOptions,
|
||||
@@ -176,31 +209,194 @@ async function buildThemes(
|
||||
}
|
||||
}
|
||||
|
||||
async function syncFiles(
|
||||
async function checkRemoteContentDrift(
|
||||
context: WPopContext,
|
||||
env: ReturnType<typeof readDeployEnv>,
|
||||
ssh: Awaited<ReturnType<typeof prepareSsh>>,
|
||||
ssh: PreparedSsh,
|
||||
components: Set<DeployComponent>,
|
||||
): Promise<void> {
|
||||
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<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(
|
||||
context: WPopContext,
|
||||
env: ReturnType<typeof readDeployEnv>,
|
||||
ssh: Awaited<ReturnType<typeof prepareSsh>>,
|
||||
ssh: PreparedSsh,
|
||||
): Promise<void> {
|
||||
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<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 });
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user