From 52ce8bc6228ad2b9616f855a5ede30555b67fffe Mon Sep 17 00:00:00 2001 From: Pascal Martineau Date: Thu, 7 May 2026 12:02:26 -0400 Subject: [PATCH] feat: implement deploy command with SSH support and context management --- README.md | 2 +- src/cli.ts | 9 +- src/commands/deploy.ts | 220 +++++++++++++++++++++++++++++++++++++++++ src/lib/context.ts | 19 ++++ src/lib/env.ts | 18 ++++ src/lib/run.ts | 29 ++++++ src/lib/ssh.ts | 99 +++++++++++++++++++ 7 files changed, 393 insertions(+), 3 deletions(-) create mode 100644 src/commands/deploy.ts create mode 100644 src/lib/context.ts create mode 100644 src/lib/env.ts create mode 100644 src/lib/run.ts create mode 100644 src/lib/ssh.ts diff --git a/README.md b/README.md index 38c7d15..eb721ad 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,3 @@ -# @lewebsimple/wpop +# WPop WordPress operations CLI for Websimple projects. diff --git a/src/cli.ts b/src/cli.ts index 50a1db0..e9bbf90 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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 ", "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(); diff --git a/src/commands/deploy.ts b/src/commands/deploy.ts new file mode 100644 index 0000000..fb65e58 --- /dev/null +++ b/src/commands/deploy.ts @@ -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 { + 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, + 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, + commandEnv: NodeJS.ProcessEnv, +): Promise { + 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 { + 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, + ssh: Awaited>, +): 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}/`, + ]; + + await run(context, "rsync", args); +} + +async function updateRemoteDatabase( + context: WPopContext, + env: ReturnType, + ssh: Awaited>, +): Promise { + 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 }); +} diff --git a/src/lib/context.ts b/src/lib/context.ts new file mode 100644 index 0000000..3eceee9 --- /dev/null +++ b/src/lib/context.ts @@ -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 { + return { + cwd: resolve(options.cwd ?? process.cwd()), + dryRun: Boolean(options.dryRun), + json: Boolean(options.json), + yes: Boolean(options.yes), + verbose: Boolean(options.verbose), + }; +} diff --git a/src/lib/env.ts b/src/lib/env.ts new file mode 100644 index 0000000..07ee459 --- /dev/null +++ b/src/lib/env.ts @@ -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; + +export function readDeployEnv(env: NodeJS.ProcessEnv = process.env): DeployEnv { + return deployEnvSchema.parse(env); +} diff --git a/src/lib/run.ts b/src/lib/run.ts new file mode 100644 index 0000000..26f76d7 --- /dev/null +++ b/src/lib/run.ts @@ -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 { + 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", + }); +} diff --git a/src/lib/ssh.ts b/src/lib/ssh.ts new file mode 100644 index 0000000..365211c --- /dev/null +++ b/src/lib/ssh.ts @@ -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 { + 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 { + 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 { + consola.info(`Verifying SSH access to ${sshTarget(config)}`); + + await run(context, "ssh", [...sshArgs(config), "-o", "BatchMode=yes", sshTarget(config), "true"]); +}