feat: implement deploy command with SSH support and context management
This commit is contained in:
@@ -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
220
src/commands/deploy.ts
Normal 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
19
src/lib/context.ts
Normal 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
18
src/lib/env.ts
Normal 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
29
src/lib/run.ts
Normal 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
99
src/lib/ssh.ts
Normal 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"]);
|
||||
}
|
||||
Reference in New Issue
Block a user