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