feat: enhance deployment process with improved error handling and logging

This commit is contained in:
2026-05-07 16:34:21 -04:00
parent ab0da30e78
commit e3ac72906d
6 changed files with 332 additions and 132 deletions

View File

@@ -2,6 +2,7 @@
import { Command } from "commander";
import { deploy } from "./commands/deploy";
import { createContext } from "./lib/context";
import { configureLogger, emitJson } from "./lib/output";
import pkg from "../package.json" with { type: "json" };
const program = new Command();
@@ -26,7 +27,23 @@ program
.option("--skip-composer", "skip Composer dependency installation")
.option("--skip-node", "skip theme asset builds")
.action(async (options: { include?: string; skipComposer?: boolean; skipNode?: boolean }) => {
await deploy(createContext(program.opts()), options);
const context = createContext(program.opts());
configureLogger(context);
try {
await deploy(context, options);
} catch (error) {
handleError(context, error);
process.exitCode = 1;
}
});
function handleError(context: { json: boolean }, error: unknown): void {
const message = error instanceof Error ? error.message : String(error);
if (context.json) {
emitJson({ ok: false, error: message });
return;
}
process.stderr.write(`Error: ${message}\n`);
}
program.parse();

View File

@@ -1,10 +1,10 @@
import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, statSync } from "node:fs";
import { tmpdir } from "node:os";
import { existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
import { join } from "node:path";
import { consola } from "consola";
import { execa } from "execa";
import prompts from "prompts";
import type { WPopContext } from "../lib/context";
import { readDeployEnv } from "../lib/env";
import { readDeployEnv, type DeployEnv } from "../lib/env";
import { emitJson } from "../lib/output";
import { run } from "../lib/run";
import {
createSshConfig,
@@ -14,11 +14,53 @@ import {
sshTarget,
type PreparedSsh,
} from "../lib/ssh";
import { createTempDir } from "../lib/tempdir";
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;
const CACHE_BUCKETS = {
composer: "COMPOSER_CACHE_DIR",
npm: "npm_config_cache",
pnpm: "PNPM_STORE_DIR",
yarn: "YARN_CACHE_FOLDER",
} as const;
const CORE_RSYNC_EXCLUDES = [
"wp-config.php",
"wp-content/",
".htaccess",
".user.ini",
"php.ini",
"robots.txt",
".well-known/",
] as const;
type PackageManager = {
lockfile: string;
install: readonly string[];
build: readonly string[];
};
const PACKAGE_MANAGERS: readonly PackageManager[] = [
{
lockfile: "pnpm-lock.yaml",
install: ["pnpm", "install", "--frozen-lockfile", "--silent"],
build: ["pnpm", "build"],
},
{
lockfile: "yarn.lock",
install: ["yarn", "install", "--frozen-lockfile", "--silent"],
build: ["yarn", "build"],
},
{
lockfile: "package-lock.json",
install: ["npm", "ci", "--no-audit", "--loglevel=error"],
build: ["npm", "run", "build"],
},
];
export type DeployComponent = (typeof ALL_COMPONENTS)[number];
export type DeployOptions = {
@@ -27,15 +69,82 @@ export type DeployOptions = {
skipNode?: boolean;
};
type DeployReport = {
command: "deploy";
cwd: string;
include: DeployComponent[];
remote: { host: string; port: number; user: string; path: string };
cacheDir: string;
dryRun: boolean;
steps: string[];
};
export async function deploy(context: WPopContext, options: DeployOptions): Promise<void> {
const env = readDeployEnv();
const components = parseComponents(options.include);
const commandEnv = createCommandEnv(env.WPOP_CACHE_DIR);
const report = buildReport(context, env, components);
if (context.json) {
console.log(
JSON.stringify(
{
if (!context.yes && !context.dryRun) {
throw new Error("--json requires --yes (cannot prompt for confirmation in JSON mode)");
}
} else {
consola.info(
`Planning deploy of ${[...components].join(", ")} to ${sshTargetSummary(env)}:${env.REMOTE_PATH}`,
);
if (context.dryRun) {
consola.info("Dry-run: no remote changes will be made");
}
if (!(await confirm(context, components, env))) {
consola.warn("Aborted.");
return;
}
}
ensureCacheDirs(env.WPOP_CACHE_DIR);
const ssh = await prepareSsh(context, createSshConfig(env), env.WPOP_CACHE_DIR);
await installComposerDependencies(context, options, commandEnv, report);
await buildThemes(context, options, commandEnv, report);
await checkRemoteContentDrift(context, env, ssh, components);
if (components.has("core")) {
await syncCore(context, env, ssh, commandEnv);
report.steps.push("sync:core");
}
if (components.has("vendor")) {
await syncVendor(context, env, ssh);
report.steps.push("sync:vendor");
if (existsSync(join(context.cwd, "vendor"))) {
await checkRemoteComposerAutoload(context, env, ssh);
}
}
for (const component of CONTENT_COMPONENTS) {
if (components.has(component)) {
await syncContentComponent(context, env, ssh, component);
report.steps.push(`sync:${component}`);
}
}
await updateRemoteDatabase(context, env, ssh);
report.steps.push("db:update");
if (context.json) {
emitJson(report);
} else {
consola.success("Deploy complete.");
}
}
function buildReport(
context: WPopContext,
env: DeployEnv,
components: Set<DeployComponent>,
): DeployReport {
return {
command: "deploy",
cwd: context.cwd,
include: [...components],
@@ -47,38 +156,31 @@ export async function deploy(context: WPopContext, options: DeployOptions): Prom
},
cacheDir: env.WPOP_CACHE_DIR,
dryRun: context.dryRun,
},
null,
2,
),
);
steps: [],
};
}
ensureCacheDirs(env.WPOP_CACHE_DIR);
const ssh = await prepareSsh(context, createSshConfig(env));
await installComposerDependencies(context, options, commandEnv);
await buildThemes(context, options, commandEnv);
await checkRemoteContentDrift(context, env, ssh, components);
if (components.has("core")) {
await syncCore(context, env, ssh, commandEnv);
function sshTargetSummary(env: DeployEnv): string {
return `${env.REMOTE_USER}@${env.REMOTE_HOST}:${env.REMOTE_PORT}`;
}
if (components.has("vendor")) {
const syncedVendor = await syncVendor(context, env, ssh);
if (syncedVendor) {
await checkRemoteComposerAutoload(context, env, ssh);
}
async function confirm(
context: WPopContext,
components: Set<DeployComponent>,
env: DeployEnv,
): Promise<boolean> {
if (context.yes || context.dryRun) {
return true;
}
for (const component of CONTENT_COMPONENTS) {
if (components.has(component)) {
await syncContentComponent(context, env, ssh, component);
}
}
const response = await prompts({
type: "confirm",
name: "confirmed",
message: `Deploy ${[...components].join(",")} to ${sshTargetSummary(env)}:${env.REMOTE_PATH}?`,
initial: false,
});
await updateRemoteDatabase(context, env, ssh);
return Boolean(response.confirmed);
}
function parseComponents(include?: string): Set<DeployComponent> {
@@ -95,7 +197,7 @@ function parseComponents(include?: string): Set<DeployComponent> {
.map((component) => component.trim())
.filter(Boolean);
const invalid = components.filter(
(component): component is string => !ALL_COMPONENTS.includes(component as DeployComponent),
(component) => !ALL_COMPONENTS.includes(component as DeployComponent),
);
if (invalid.length > 0) {
@@ -106,19 +208,16 @@ function parseComponents(include?: string): Set<DeployComponent> {
}
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",
};
const overrides: NodeJS.ProcessEnv = { WP_CLI_ALLOW_ROOT: "1" };
for (const [bucket, envVar] of Object.entries(CACHE_BUCKETS)) {
overrides[envVar] = join(cacheDir, bucket);
}
return { ...process.env, ...overrides };
}
function ensureCacheDirs(cacheDir: string): void {
for (const child of ["composer", "npm", "pnpm", "yarn"]) {
mkdirSync(join(cacheDir, child), { recursive: true });
for (const bucket of Object.keys(CACHE_BUCKETS)) {
mkdirSync(join(cacheDir, bucket), { recursive: true });
}
}
@@ -126,6 +225,7 @@ async function installComposerDependencies(
context: WPopContext,
options: DeployOptions,
commandEnv: NodeJS.ProcessEnv,
report: DeployReport,
): Promise<void> {
if (options.skipComposer) {
consola.info("Skipping Composer installation");
@@ -144,12 +244,14 @@ async function installComposerDependencies(
["install", "--no-dev", "--no-interaction", "--optimize-autoloader", "--prefer-dist"],
{ env: commandEnv },
);
report.steps.push("composer:install");
}
async function buildThemes(
context: WPopContext,
options: DeployOptions,
commandEnv: NodeJS.ProcessEnv,
report: DeployReport,
): Promise<void> {
if (options.skipNode) {
consola.info("Skipping theme builds");
@@ -171,38 +273,21 @@ async function buildThemes(
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 });
const manager = PACKAGE_MANAGERS.find((pm) => existsSync(join(themeDir, pm.lockfile)));
const install = manager?.install ?? PACKAGE_MANAGERS[2].install;
const build = manager?.build ?? PACKAGE_MANAGERS[2].build;
consola.info(`Building theme ${entry.name} (${install[0]})`);
const installArgs = install.slice(1);
if (install[0] === "pnpm" && commandEnv.PNPM_STORE_DIR) {
installArgs.push("--store-dir", commandEnv.PNPM_STORE_DIR);
}
await run(context, install[0], installArgs, { cwd: themeDir, env: commandEnv });
await run(context, build[0], build.slice(1), { cwd: themeDir, env: commandEnv });
report.steps.push(`theme:${entry.name}`);
if (!context.dryRun) {
rmSync(join(themeDir, "node_modules"), { force: true, recursive: true });
}
@@ -211,7 +296,7 @@ async function buildThemes(
async function checkRemoteContentDrift(
context: WPopContext,
env: ReturnType<typeof readDeployEnv>,
env: DeployEnv,
ssh: PreparedSsh,
components: Set<DeployComponent>,
): Promise<void> {
@@ -249,10 +334,12 @@ function listLocalTopLevelDirs(path: string): string[] {
async function listRemoteTopLevelDirs(
context: WPopContext,
env: ReturnType<typeof readDeployEnv>,
env: DeployEnv,
ssh: PreparedSsh,
path: string,
): Promise<string[]> {
// Paths flow through env vars and are quoted with JSON.stringify, which is
// close enough to POSIX double-quote escaping for the values we handle here.
const script = `set -e
cd ${JSON.stringify(env.REMOTE_PATH)}
if [ -d ${JSON.stringify(path)} ]; then
@@ -260,7 +347,7 @@ if [ -d ${JSON.stringify(path)} ]; then
fi
`;
const stdout = await sshOutput(context, ssh, script);
const { stdout } = await sshOutput(context, ssh, script);
return stdout
.split("\n")
.map((line) => line.trim())
@@ -269,13 +356,11 @@ fi
async function syncCore(
context: WPopContext,
env: ReturnType<typeof readDeployEnv>,
env: DeployEnv,
ssh: PreparedSsh,
commandEnv: NodeJS.ProcessEnv,
): Promise<void> {
const coreDir = context.dryRun
? join(tmpdir(), "wpop-core")
: mkdtempSync(join(tmpdir(), "wpop-core-"));
const { path: coreDir, cleanup } = createTempDir(context, "wpop-core");
consola.info("Preparing WordPress core deploy payload");
await run(
@@ -295,29 +380,15 @@ async function syncCore(
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/",
...CORE_RSYNC_EXCLUDES.map((entry) => `--exclude=${entry}`),
`${coreDir}/`,
`${sshTarget(ssh)}:${env.REMOTE_PATH}/`,
]);
if (!context.dryRun) {
rmSync(coreDir, { force: true, recursive: true });
}
cleanup();
}
async function syncVendor(
context: WPopContext,
env: ReturnType<typeof readDeployEnv>,
ssh: PreparedSsh,
): Promise<boolean> {
let syncedVendor = false;
async function syncVendor(context: WPopContext, env: DeployEnv, ssh: PreparedSsh): Promise<void> {
if (existsSync(join(context.cwd, "vendor"))) {
await ensureRemoteDirectory(context, env, ssh, "vendor");
consola.info("Synchronizing vendor");
@@ -326,25 +397,22 @@ async function syncVendor(
`${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()) {
if (existsSync(localPath)) {
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>,
env: DeployEnv,
ssh: PreparedSsh,
): Promise<void> {
consola.info("Checking remote Composer autoload inclusion");
@@ -359,7 +427,7 @@ grep -q 'vendor/autoload.php' wp-config.php
async function syncContentComponent(
context: WPopContext,
env: ReturnType<typeof readDeployEnv>,
env: DeployEnv,
ssh: PreparedSsh,
component: (typeof CONTENT_COMPONENTS)[number],
): Promise<void> {
@@ -386,7 +454,7 @@ function remoteContentPath(component: (typeof CONTENT_COMPONENTS)[number]): stri
async function ensureRemoteDirectory(
context: WPopContext,
env: ReturnType<typeof readDeployEnv>,
env: DeployEnv,
ssh: PreparedSsh,
path: string,
): Promise<void> {
@@ -395,7 +463,7 @@ async function ensureRemoteDirectory(
async function updateRemoteDatabase(
context: WPopContext,
env: ReturnType<typeof readDeployEnv>,
env: DeployEnv,
ssh: PreparedSsh,
): Promise<void> {
consola.info("Updating remote database");
@@ -439,15 +507,13 @@ async function sshRun(context: WPopContext, ssh: PreparedSsh, script: string): P
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,
async function sshOutput(
context: WPopContext,
ssh: PreparedSsh,
script: string,
): Promise<{ stdout: string }> {
return run(context, "ssh", [...sshArgs(ssh), sshTarget(ssh)], {
stdin: script,
capture: true,
});
return stdout;
}

14
src/lib/output.ts Normal file
View File

@@ -0,0 +1,14 @@
import { consola } from "consola";
import type { WPopContext } from "./context";
export function configureLogger(context: WPopContext): void {
if (context.json) {
consola.level = -999;
return;
}
consola.level = context.verbose ? 4 : 3;
}
export function emitJson(payload: unknown): void {
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
}

View File

@@ -1,3 +1,4 @@
import { consola } from "consola";
import { execa } from "execa";
import type { WPopContext } from "./context";
@@ -7,17 +8,45 @@ export type RunOptions = {
stdin?: string;
};
export type CaptureOptions = RunOptions & { capture: true };
export type CaptureResult = { stdout: string };
export async function run(
context: WPopContext,
command: string,
args: string[],
options: RunOptions = {},
): Promise<void> {
options?: RunOptions,
): Promise<void>;
export async function run(
context: WPopContext,
command: string,
args: string[],
options: CaptureOptions,
): Promise<CaptureResult>;
export async function run(
context: WPopContext,
command: string,
args: string[],
options: RunOptions | CaptureOptions = {},
): Promise<void | CaptureResult> {
const printable = [command, ...args].join(" ");
const capture = "capture" in options && options.capture === true;
if (context.dryRun) {
console.log(`[dry-run] ${printable}`);
return;
consola.info(`[dry-run] ${printable}`);
return capture ? { stdout: "" } : undefined;
}
consola.debug(printable);
if (capture) {
const { stdout } = await execa(command, args, {
cwd: options.cwd ?? context.cwd,
env: options.env,
input: options.stdin,
});
return { stdout };
}
await execa(command, args, {

View File

@@ -1,11 +1,11 @@
import { appendFileSync, mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { appendFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { consola } from "consola";
import { execa } from "execa";
import type { WPopContext } from "./context";
import type { DeployEnv } from "./env";
import { run } from "./run";
import { createTempDir } from "./tempdir";
export type SshConfig = {
host: string;
@@ -14,8 +14,12 @@ export type SshConfig = {
privateKey?: string;
};
export type PreparedSsh = SshConfig & {
export type PreparedSsh = {
host: string;
port: number;
user: string;
identityFile?: string;
knownHostsFile: string;
};
export function createSshConfig(env: DeployEnv): SshConfig {
@@ -27,29 +31,43 @@ export function createSshConfig(env: DeployEnv): SshConfig {
};
}
export async function prepareSsh(context: WPopContext, config: SshConfig): Promise<PreparedSsh> {
export async function prepareSsh(
context: WPopContext,
config: SshConfig,
cacheDir: string,
): Promise<PreparedSsh> {
const knownHostsFile = ensureKnownHostsFile(context, cacheDir);
const prepared: PreparedSsh = {
host: config.host,
port: config.port,
user: config.user,
knownHostsFile,
};
if (config.privateKey) {
prepared.identityFile = writePrivateKey(context, config.privateKey);
}
await addKnownHost(context, prepared);
await ensureKnownHost(context, prepared);
await verifySshAuth(context, prepared);
return prepared;
}
export function sshTarget(config: SshConfig): string {
export function sshTarget(config: PreparedSsh): string {
return `${config.user}@${config.host}`;
}
export function sshArgs(config: PreparedSsh): string[] {
const args = ["-p", String(config.port)];
const args = [
"-p",
String(config.port),
"-o",
`UserKnownHostsFile=${config.knownHostsFile}`,
"-o",
"GlobalKnownHostsFile=/dev/null",
];
if (config.identityFile) {
args.push("-i", config.identityFile);
@@ -62,34 +80,66 @@ 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");
function ensureKnownHostsFile(context: WPopContext, cacheDir: string): string {
const path = join(cacheDir, "known_hosts");
if (context.dryRun) {
return "/tmp/wpop-ssh-key";
return path;
}
mkdirSync(cacheDir, { recursive: true });
if (!existsSync(path)) {
writeFileSync(path, "", { mode: 0o600 });
}
return path;
}
function writePrivateKey(context: WPopContext, privateKey: string): string {
consola.info("Using SSH private key from SSH_PRIVATE_KEY");
const { path: keyDir, cleanup } = createTempDir(context, "wpop-ssh");
if (context.dryRun) {
return join(keyDir, "id_ed25519");
}
const keyDir = mkdtempSync(join(tmpdir(), "wpop-ssh-"));
const keyPath = join(keyDir, "id_ed25519");
writeFileSync(keyPath, privateKey.endsWith("\n") ? privateKey : `${privateKey}\n`, {
mode: 0o600,
});
process.once("exit", cleanup);
return keyPath;
}
async function addKnownHost(context: WPopContext, config: PreparedSsh): Promise<void> {
consola.info(`Scanning SSH host key for ${config.host}`);
async function ensureKnownHost(context: WPopContext, config: PreparedSsh): Promise<void> {
if (context.dryRun) {
consola.info(`Ensuring SSH host key for ${config.host} is known`);
await run(context, "ssh-keyscan", ["-p", String(config.port), "-H", config.host]);
return;
}
if (await hostInKnownHosts(config)) {
consola.debug(`Host ${config.host}:${config.port} already in known_hosts`);
return;
}
consola.info(`Scanning SSH host key for ${config.host}`);
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`);
appendFileSync(config.knownHostsFile, `${stdout}\n`);
}
async function hostInKnownHosts(config: PreparedSsh): Promise<boolean> {
if (!existsSync(config.knownHostsFile)) {
return false;
}
const lookup = config.port === 22 ? config.host : `[${config.host}]:${config.port}`;
const result = await execa("ssh-keygen", ["-F", lookup, "-f", config.knownHostsFile], {
reject: false,
});
return result.exitCode === 0;
}
async function verifySshAuth(context: WPopContext, config: PreparedSsh): Promise<void> {

24
src/lib/tempdir.ts Normal file
View File

@@ -0,0 +1,24 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { WPopContext } from "./context";
export type TempDir = {
path: string;
cleanup: () => void;
};
export function createTempDir(context: WPopContext, prefix: string): TempDir {
if (context.dryRun) {
return {
path: join(tmpdir(), prefix),
cleanup: () => {},
};
}
const path = mkdtempSync(join(tmpdir(), `${prefix}-`));
return {
path,
cleanup: () => rmSync(path, { force: true, recursive: true }),
};
}