diff --git a/src/cli.ts b/src/cli.ts index d58aed1..e3b1d82 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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(); diff --git a/src/commands/deploy.ts b/src/commands/deploy.ts index c542c60..c2241d4 100644 --- a/src/commands/deploy.ts +++ b/src/commands/deploy.ts @@ -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,47 +69,55 @@ 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 { 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( - { - command: "deploy", - cwd: context.cwd, - include: [...components], - remote: { - host: env.REMOTE_HOST, - port: env.REMOTE_PORT, - user: env.REMOTE_USER, - path: env.REMOTE_PATH, - }, - cacheDir: env.WPOP_CACHE_DIR, - dryRun: context.dryRun, - }, - null, - 2, - ), + 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)); + const ssh = await prepareSsh(context, createSshConfig(env), env.WPOP_CACHE_DIR); - await installComposerDependencies(context, options, commandEnv); - await buildThemes(context, options, commandEnv); + 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")) { - const syncedVendor = await syncVendor(context, env, ssh); - if (syncedVendor) { + await syncVendor(context, env, ssh); + report.steps.push("sync:vendor"); + if (existsSync(join(context.cwd, "vendor"))) { await checkRemoteComposerAutoload(context, env, ssh); } } @@ -75,10 +125,62 @@ export async function deploy(context: WPopContext, options: DeployOptions): Prom 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, +): DeployReport { + return { + command: "deploy", + cwd: context.cwd, + include: [...components], + remote: { + host: env.REMOTE_HOST, + port: env.REMOTE_PORT, + user: env.REMOTE_USER, + path: env.REMOTE_PATH, + }, + cacheDir: env.WPOP_CACHE_DIR, + dryRun: context.dryRun, + steps: [], + }; +} + +function sshTargetSummary(env: DeployEnv): string { + return `${env.REMOTE_USER}@${env.REMOTE_HOST}:${env.REMOTE_PORT}`; +} + +async function confirm( + context: WPopContext, + components: Set, + env: DeployEnv, +): Promise { + if (context.yes || context.dryRun) { + return true; + } + + const response = await prompts({ + type: "confirm", + name: "confirmed", + message: `Deploy ${[...components].join(",")} to ${sshTargetSummary(env)}:${env.REMOTE_PATH}?`, + initial: false, + }); + + return Boolean(response.confirmed); } function parseComponents(include?: string): Set { @@ -95,7 +197,7 @@ function parseComponents(include?: string): Set { .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 { } 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 { 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 { 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, + env: DeployEnv, ssh: PreparedSsh, components: Set, ): Promise { @@ -249,10 +334,12 @@ function listLocalTopLevelDirs(path: string): string[] { async function listRemoteTopLevelDirs( context: WPopContext, - env: ReturnType, + env: DeployEnv, ssh: PreparedSsh, path: string, ): Promise { + // 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, + env: DeployEnv, ssh: PreparedSsh, commandEnv: NodeJS.ProcessEnv, ): Promise { - 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, - ssh: PreparedSsh, -): Promise { - let syncedVendor = false; - +async function syncVendor(context: WPopContext, env: DeployEnv, ssh: PreparedSsh): Promise { 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, + env: DeployEnv, ssh: PreparedSsh, ): Promise { 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, + env: DeployEnv, ssh: PreparedSsh, component: (typeof CONTENT_COMPONENTS)[number], ): Promise { @@ -386,7 +454,7 @@ function remoteContentPath(component: (typeof CONTENT_COMPONENTS)[number]): stri async function ensureRemoteDirectory( context: WPopContext, - env: ReturnType, + env: DeployEnv, ssh: PreparedSsh, path: string, ): Promise { @@ -395,7 +463,7 @@ async function ensureRemoteDirectory( async function updateRemoteDatabase( context: WPopContext, - env: ReturnType, + env: DeployEnv, ssh: PreparedSsh, ): Promise { 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 { - 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; } diff --git a/src/lib/output.ts b/src/lib/output.ts new file mode 100644 index 0000000..74dd4b3 --- /dev/null +++ b/src/lib/output.ts @@ -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`); +} diff --git a/src/lib/run.ts b/src/lib/run.ts index 3b5ce70..db84864 100644 --- a/src/lib/run.ts +++ b/src/lib/run.ts @@ -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 { + options?: RunOptions, +): Promise; +export async function run( + context: WPopContext, + command: string, + args: string[], + options: CaptureOptions, +): Promise; +export async function run( + context: WPopContext, + command: string, + args: string[], + options: RunOptions | CaptureOptions = {}, +): Promise { 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, { diff --git a/src/lib/ssh.ts b/src/lib/ssh.ts index 7ec720a..dd4354d 100644 --- a/src/lib/ssh.ts +++ b/src/lib/ssh.ts @@ -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 { +export async function prepareSsh( + context: WPopContext, + config: SshConfig, + cacheDir: string, +): Promise { + 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 { - consola.info(`Scanning SSH host key for ${config.host}`); - +async function ensureKnownHost(context: WPopContext, config: PreparedSsh): Promise { 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 { + 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 { diff --git a/src/lib/tempdir.ts b/src/lib/tempdir.ts new file mode 100644 index 0000000..a08a872 --- /dev/null +++ b/src/lib/tempdir.ts @@ -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 }), + }; +}