feat: implement deploy command with SSH support and context management

This commit is contained in:
2026-05-07 12:02:26 -04:00
parent f5f3678e30
commit 52ce8bc622
7 changed files with 393 additions and 3 deletions

View File

@@ -1,3 +1,3 @@
# @lewebsimple/wpop
# WPop
WordPress operations CLI for Websimple projects.

View File

@@ -1,5 +1,7 @@
#!/usr/bin/env node
import { Command } from "commander";
import { deploy } from "./commands/deploy.js";
import { createContext } from "./lib/context.js";
import pkg from "../package.json" with { type: "json" };
const program = new Command();
@@ -8,6 +10,7 @@ program
.name("wpop")
.description("WordPress operations CLI for Websimple projects.")
.version(pkg.version)
.option("--cwd <path>", "project working directory")
.option("--dry-run", "show what would happen without making changes")
.option("--json", "output machine-readable JSON")
.option("--yes", "skip confirmation prompts")
@@ -16,8 +19,10 @@ program
program
.command("deploy")
.description("Deploy a WordPress project")
.action(() => {
throw new Error("Not implemented yet.");
.option("--skip-composer", "skip Composer dependency installation")
.option("--skip-node", "skip theme asset builds")
.action(async (options: { skipComposer?: boolean; skipNode?: boolean }) => {
await deploy(createContext(program.opts()), options);
});
program.parse();

220
src/commands/deploy.ts Normal file
View File

@@ -0,0 +1,220 @@
import { existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
import { join } from "node:path";
import { consola } from "consola";
import type { WPopContext } from "../lib/context.js";
import { readDeployEnv } from "../lib/env.js";
import { run } from "../lib/run.js";
import { createSshConfig, prepareSsh, rsyncSshShell, sshArgs, sshTarget } from "../lib/ssh.js";
export type DeployOptions = {
skipComposer?: boolean;
skipNode?: boolean;
};
export async function deploy(context: WPopContext, options: DeployOptions): Promise<void> {
const env = readDeployEnv();
const commandEnv = createCommandEnv(env.CACHE_DIR);
if (context.json) {
console.log(
JSON.stringify(
{
command: "deploy",
cwd: context.cwd,
remote: {
host: env.REMOTE_HOST,
port: env.REMOTE_PORT,
user: env.REMOTE_USER,
path: env.REMOTE_PATH,
},
cacheDir: env.CACHE_DIR,
dryRun: context.dryRun,
},
null,
2,
),
);
}
ensureCacheDirs(env.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 updateRemoteDatabase(context, env, ssh);
}
function createCommandEnv(cacheDir: string): NodeJS.ProcessEnv {
return {
...process.env,
COMPOSER_CACHE_DIR: join(cacheDir, "composer"),
npm_config_cache: join(cacheDir, "npm"),
PNPM_STORE_DIR: join(cacheDir, "pnpm"),
YARN_CACHE_FOLDER: join(cacheDir, "yarn"),
WP_CLI_ALLOW_ROOT: "1",
};
}
function ensureCacheDirs(cacheDir: string): void {
for (const child of ["composer", "npm", "pnpm", "yarn"]) {
mkdirSync(join(cacheDir, child), { recursive: true });
}
}
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,
commandEnv: NodeJS.ProcessEnv,
): Promise<void> {
if (options.skipComposer) {
consola.info("Skipping Composer installation");
return;
}
if (!existsSync(join(context.cwd, "composer.json"))) {
consola.warn("composer.json not found, skipping Composer installation");
return;
}
consola.info("Installing Composer dependencies");
await run(
context,
"composer",
["install", "--no-dev", "--no-interaction", "--optimize-autoloader", "--prefer-dist"],
{ env: commandEnv },
);
}
async function buildThemes(
context: WPopContext,
options: DeployOptions,
commandEnv: NodeJS.ProcessEnv,
): Promise<void> {
if (options.skipNode) {
consola.info("Skipping theme builds");
return;
}
const themesDir = join(context.cwd, "wp-content", "themes");
if (!existsSync(themesDir)) {
return;
}
for (const entry of readdirSync(themesDir, { withFileTypes: true })) {
if (!entry.isDirectory()) {
continue;
}
const themeDir = join(themesDir, entry.name);
if (!existsSync(join(themeDir, "package.json"))) {
continue;
}
consola.info(`Building theme ${entry.name}`);
if (existsSync(join(themeDir, "pnpm-lock.yaml"))) {
await run(
context,
"pnpm",
[
"install",
"--frozen-lockfile",
"--silent",
"--store-dir",
commandEnv.PNPM_STORE_DIR ?? "",
],
{
cwd: themeDir,
env: commandEnv,
},
);
await run(context, "pnpm", ["build"], { cwd: themeDir, env: commandEnv });
} else if (existsSync(join(themeDir, "yarn.lock"))) {
await run(context, "yarn", ["install", "--frozen-lockfile", "--silent"], {
cwd: themeDir,
env: commandEnv,
});
await run(context, "yarn", ["build"], { cwd: themeDir, env: commandEnv });
} else {
await run(context, "npm", ["ci", "--no-audit", "--loglevel=error"], {
cwd: themeDir,
env: commandEnv,
});
await run(context, "npm", ["run", "build"], { cwd: themeDir, env: commandEnv });
}
if (!context.dryRun) {
rmSync(join(themeDir, "node_modules"), { force: true, recursive: true });
}
}
}
async function syncFiles(
context: WPopContext,
env: ReturnType<typeof readDeployEnv>,
ssh: Awaited<ReturnType<typeof prepareSsh>>,
): 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}/`,
];
await run(context, "rsync", args);
}
async function updateRemoteDatabase(
context: WPopContext,
env: ReturnType<typeof readDeployEnv>,
ssh: Awaited<ReturnType<typeof prepareSsh>>,
): Promise<void> {
consola.info("Updating remote database");
const script = `set -e
cd ${JSON.stringify(env.REMOTE_PATH)}
if [ -f wp-config.php ]; then
wp core update-db
if [ -d wp-content/plugins/woocommerce ]; then
echo "Updating WooCommerce database..."
wp wc update
fi
else
echo "wp-config.php not found. Skipping database update."
fi
`;
await run(context, "ssh", [...sshArgs(ssh), sshTarget(ssh)], { stdin: script });
}

19
src/lib/context.ts Normal file
View File

@@ -0,0 +1,19 @@
import { resolve } from "node:path";
export type WPopContext = {
cwd: string;
dryRun: boolean;
json: boolean;
yes: boolean;
verbose: boolean;
};
export function createContext(options: Partial<WPopContext>): WPopContext {
return {
cwd: resolve(options.cwd ?? process.cwd()),
dryRun: Boolean(options.dryRun),
json: Boolean(options.json),
yes: Boolean(options.yes),
verbose: Boolean(options.verbose),
};
}

18
src/lib/env.ts Normal file
View File

@@ -0,0 +1,18 @@
import { z } from "zod";
const deployEnvSchema = z.object({
REMOTE_HOST: z.string().min(1),
REMOTE_USER: z.string().min(1),
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"),
WP_VERSION: z.string().default("latest"),
WP_LOCALE: z.string().default("fr_CA"),
});
export type DeployEnv = z.infer<typeof deployEnvSchema>;
export function readDeployEnv(env: NodeJS.ProcessEnv = process.env): DeployEnv {
return deployEnvSchema.parse(env);
}

29
src/lib/run.ts Normal file
View File

@@ -0,0 +1,29 @@
import { execa } from "execa";
import type { WPopContext } from "./context.js";
export type RunOptions = {
cwd?: string;
env?: NodeJS.ProcessEnv;
stdin?: string;
};
export async function run(
context: WPopContext,
command: string,
args: string[],
options: RunOptions = {},
): Promise<void> {
const printable = [command, ...args].join(" ");
if (context.dryRun) {
console.log(`[dry-run] ${printable}`);
return;
}
await execa(command, args, {
cwd: options.cwd ?? context.cwd,
env: options.env,
input: options.stdin,
stdio: options.stdin ? ["pipe", "inherit", "inherit"] : "inherit",
});
}

99
src/lib/ssh.ts Normal file
View File

@@ -0,0 +1,99 @@
import { appendFileSync, mkdtempSync, mkdirSync, writeFileSync } 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 "./context.js";
import type { DeployEnv } from "./env.js";
import { run } from "./run.js";
export type SshConfig = {
host: string;
port: number;
user: string;
privateKey?: string;
};
export type PreparedSsh = SshConfig & {
identityFile?: string;
};
export function createSshConfig(env: DeployEnv): SshConfig {
return {
host: env.REMOTE_HOST,
port: env.REMOTE_PORT,
user: env.REMOTE_USER,
privateKey: env.SSH_PRIVATE_KEY,
};
}
export async function prepareSsh(context: WPopContext, config: SshConfig): Promise<PreparedSsh> {
const prepared: PreparedSsh = {
host: config.host,
port: config.port,
user: config.user,
};
if (config.privateKey) {
prepared.identityFile = writePrivateKey(context, config.privateKey);
}
await addKnownHost(context, prepared);
await verifySshAuth(context, prepared);
return prepared;
}
export function sshTarget(config: SshConfig): string {
return `${config.user}@${config.host}`;
}
export function sshArgs(config: PreparedSsh): string[] {
const args = ["-p", String(config.port)];
if (config.identityFile) {
args.push("-i", config.identityFile);
}
return args;
}
export function rsyncSshShell(config: PreparedSsh): string {
return ["ssh", ...sshArgs(config)].join(" ");
}
function writePrivateKey(context: WPopContext, privateKey: string): string | undefined {
consola.info("Using SSH private key from SSH_PRIVATE_KEY");
if (context.dryRun) {
return "/tmp/wpop-ssh-key";
}
const keyDir = mkdtempSync(join(tmpdir(), "wpop-ssh-"));
const keyPath = join(keyDir, "id_ed25519");
writeFileSync(keyPath, privateKey.endsWith("\n") ? privateKey : `${privateKey}\n`, {
mode: 0o600,
});
return keyPath;
}
async function addKnownHost(context: WPopContext, config: PreparedSsh): Promise<void> {
consola.info(`Scanning SSH host key for ${config.host}`);
if (context.dryRun) {
await run(context, "ssh-keyscan", ["-p", String(config.port), "-H", config.host]);
return;
}
const { stdout } = await execa("ssh-keyscan", ["-p", String(config.port), "-H", config.host]);
const sshDir = join(process.env.HOME ?? context.cwd, ".ssh");
mkdirSync(sshDir, { mode: 0o700, recursive: true });
appendFileSync(join(sshDir, "known_hosts"), `${stdout}\n`);
}
async function verifySshAuth(context: WPopContext, config: PreparedSsh): Promise<void> {
consola.info(`Verifying SSH access to ${sshTarget(config)}`);
await run(context, "ssh", [...sshArgs(config), "-o", "BatchMode=yes", sshTarget(config), "true"]);
}