feat: add deploy command options for component inclusion and enhance deployment logic
This commit is contained in:
@@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"),
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user