From ebdaf110d0b88d858de7dae886ca0b637f07c64f Mon Sep 17 00:00:00 2001 From: Pascal Martineau Date: Thu, 11 Jun 2026 14:28:25 -0400 Subject: [PATCH] feat: wpop destroy --- CLAUDE.md | 5 +- src/cli.ts | 15 ++++ src/commands/destroy.ts | 163 ++++++++++++++++++++++++++++++++++++++++ src/lib/env.ts | 14 ++++ 4 files changed, 195 insertions(+), 2 deletions(-) create mode 100644 src/commands/destroy.ts diff --git a/CLAUDE.md b/CLAUDE.md index 418fd7d..533c1f8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,7 +22,7 @@ Package manager is pnpm@10 (declared via `packageManager`); husky + lint-staged ## Architecture -Commands today: `deploy` (local → remote), `sync` (remote → local), and `provision` (bootstrap a local site). The layout assumes more will be added. +Commands today: `deploy` (local → remote), `sync` (remote → local), `provision` (bootstrap a local site), and `destroy` (tear down a local site). The layout assumes more will be added. - `src/cli.ts` — commander entry. Defines global flags (`--cwd`, `--dry-run`, `--json`, `--yes`, `--verbose`) on the root program and registers subcommands. Each subcommand action calls `createContext(program.opts())` and passes the context as the first argument to its handler. New commands should follow this pattern: register in `cli.ts`, implement in `src/commands/.ts`, take `(context, options)`. - `src/lib/context.ts` — `WPopContext` carries the resolved cwd and the global flags. Every side-effecting helper takes a context; nothing reads `process.cwd()` or the flags directly. @@ -37,9 +37,10 @@ Commands today: `deploy` (local → remote), `sync` (remote → local), and `pro 4. Rsync, in this order: core (excludes `wp-config.php`, `wp-content/`, `.htaccess`, `.user.ini`, `php.ini`, `robots.txt`, `.well-known/`), `vendor/` + `composer.json/lock`, then each content component. After vendor sync, asserts `vendor/autoload.php` is referenced from remote `wp-config.php`. 5. Remote DB updates: `wp core update-db`, `wp wc update` if WooCommerce is present, `wp acf json sync` if ACF ≥ 6.8 is installed. - `src/commands/provision.ts` — bootstraps a minimal local site from a positional ``. Resolves env via `readProvisionEnv` (`WP_LOCAL_ROOT_PATH`, `WEBSIMPLE_STACK_DOMAIN`, `WEBSIMPLE_STACK_PROTOCOL`, `WP_VERSION`, `WP_LOCALE`, `WPOP_GITEA_OWNER`, `WPOP_GITEA_TEMPLATE`, plus a Gitea token) and derives path (`${WP_LOCAL_ROOT_PATH}/`), DB (`wp_`), and URL. Each step is idempotent ("check, then act"): ensure the Gitea `wp-sites/` repo (create from `templates/wp-boilerplate` if missing), clone it, report git freshness, `CREATE DATABASE IF NOT EXISTS`, `wp core download --skip-content`, `wp config create`, and inject a `vendor/autoload.php` guard into `wp-config.php`. It does **not** run `wp core install` or `composer install`. +- `src/commands/destroy.ts` — the local inverse of `provision`. Resolves env via `readDestroyEnv` (only `WP_LOCAL_ROOT_PATH`), derives path (`${WP_LOCAL_ROOT_PATH}/`) and DB (`wp_`), confirms (yes/no, default No; `--yes` skips), guards that the path is a direct child of the root, then `rm -rf` the folder and `DROP DATABASE IF EXISTS`. Idempotent and **local only** — it never touches the Gitea repo or the remote. - Caches (`composer/`, `npm/`, `pnpm/`, `yarn/`) live under `WPOP_CACHE_DIR` (default `/tmp/wpop`) and are wired into the child env so repeated runs reuse downloads. -Logging uses `consola`; structured output is opt-in via `--json` (`deploy` and `provision` emit a one-shot JSON report). +Logging uses `consola`; structured output is opt-in via `--json` (`deploy`, `provision`, and `destroy` emit a one-shot JSON report). ## Conventions diff --git a/src/cli.ts b/src/cli.ts index d0f914e..d21ac27 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,6 +1,7 @@ #!/usr/bin/env node import { Command } from "commander"; import { deploy } from "./commands/deploy"; +import { destroy } from "./commands/destroy"; import { provision } from "./commands/provision"; import { sync } from "./commands/sync"; import { createContext } from "./lib/context"; @@ -74,6 +75,20 @@ program } }); +program + .command("destroy ") + .description("Destroy a local WordPress site (remove folder, drop database)") + .action(async (slug: string, options: Record) => { + const context = createContext(program.opts()); + configureLogger(context); + try { + await destroy(context, slug, 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) { diff --git a/src/commands/destroy.ts b/src/commands/destroy.ts new file mode 100644 index 0000000..ddab91c --- /dev/null +++ b/src/commands/destroy.ts @@ -0,0 +1,163 @@ +import { existsSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { consola } from "consola"; +import prompts from "prompts"; +import type { WPopContext } from "../lib/context"; +import { readDestroyEnv, type DestroyEnv } from "../lib/env"; +import { emitJson } from "../lib/output"; +import { run } from "../lib/run"; + +export type DestroyOptions = Record; + +type Derived = { + sitePath: string; + dbName: string; +}; + +type DestroyReport = { + command: "destroy"; + slug: string; + sitePath: string; + dbName: string; + dryRun: boolean; + steps: string[]; +}; + +export async function destroy( + context: WPopContext, + slug: string, + _options: DestroyOptions, +): Promise { + if (!/^[a-zA-Z0-9._-]+$/.test(slug)) { + throw new Error(`Invalid site slug "${slug}": use letters, numbers, ".", "_" or "-" only`); + } + + const env = readDestroyEnv(process.env); + const derived = resolveDerived(env, slug); + const report = buildReport(context, slug, derived); + + if (!context.dryRun) { + const dirExists = existsSync(derived.sitePath); + const dbExists = await databaseExists(context, derived.dbName); + if (!dirExists && !dbExists) { + if (context.json) { + emitJson(report); + } else { + consola.warn( + `Nothing to destroy for ${slug}: no directory at ${derived.sitePath} and no database ${derived.dbName}`, + ); + } + return; + } + } + + if (context.json) { + if (!context.yes && !context.dryRun) { + throw new Error("--json requires --yes (cannot prompt for confirmation in JSON mode)"); + } + } else { + logPlan(context, slug, derived); + if (!(await confirm(context, slug, derived))) { + consola.warn("Aborted."); + return; + } + } + + assertWithinRoot(env, derived.sitePath); + + await removeSiteDirectory(context, derived.sitePath, report); + await dropDatabase(context, derived.dbName, report); + + if (context.json) { + emitJson(report); + } else { + consola.success(`Destroyed ${slug}`); + } +} + +function resolveDerived(env: DestroyEnv, slug: string): Derived { + return { + sitePath: join(env.WP_LOCAL_ROOT_PATH, slug), + dbName: `wp_${slug}`, + }; +} + +function buildReport(context: WPopContext, slug: string, derived: Derived): DestroyReport { + return { + command: "destroy", + slug, + sitePath: derived.sitePath, + dbName: derived.dbName, + dryRun: context.dryRun, + steps: [], + }; +} + +function logPlan(context: WPopContext, slug: string, derived: Derived): void { + consola.info(`Planning destroy of ${slug}`); + consola.info(` path: ${derived.sitePath}`); + consola.info(` database: ${derived.dbName}`); + if (context.dryRun) { + consola.info("Dry-run: no changes will be made"); + } +} + +async function confirm(context: WPopContext, slug: string, derived: Derived): Promise { + if (context.yes || context.dryRun) { + return true; + } + + const response = await prompts({ + type: "confirm", + name: "confirmed", + message: `Destroy ${slug}? This deletes ${derived.sitePath} and drops ${derived.dbName}.`, + initial: false, + }); + + return Boolean(response.confirmed); +} + +async function databaseExists(context: WPopContext, dbName: string): Promise { + const result = await run(context, "mysql", ["-N", "-e", `SHOW DATABASES LIKE '${dbName}'`], { + capture: true, + reject: false, + }); + if (result.exitCode !== 0) { + // Could not query (e.g. mysql unreachable); assume it may exist so we don't wrongly skip teardown. + return true; + } + return result.stdout.trim().length > 0; +} + +function assertWithinRoot(env: DestroyEnv, sitePath: string): void { + if (dirname(resolve(sitePath)) !== resolve(env.WP_LOCAL_ROOT_PATH)) { + throw new Error( + `Refusing to delete ${sitePath}: not a direct child of ${env.WP_LOCAL_ROOT_PATH}`, + ); + } +} + +async function removeSiteDirectory( + context: WPopContext, + sitePath: string, + report: DestroyReport, +): Promise { + if (!context.dryRun && !existsSync(sitePath)) { + consola.info(`Site directory already absent at ${sitePath}`); + return; + } + + consola.info(`Removing site directory ${sitePath}`); + await run(context, "rm", ["-rf", sitePath]); + report.steps.push("fs:remove"); +} + +async function dropDatabase( + context: WPopContext, + dbName: string, + report: DestroyReport, +): Promise { + consola.info(`Dropping database ${dbName}`); + await run(context, "mysql", ["-e", `DROP DATABASE IF EXISTS \`${dbName}\`;`]); + report.steps.push("db:drop"); +} diff --git a/src/lib/env.ts b/src/lib/env.ts index 0c08f48..2b002cd 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -68,6 +68,20 @@ export function readProvisionEnv(env: NodeJS.ProcessEnv = process.env): Provisio }; } +const destroyEnvSchema = z.object({ + WP_LOCAL_ROOT_PATH: z.string().min(1), +}); + +export type DestroyEnv = z.infer; + +export function readDestroyEnv(env: NodeJS.ProcessEnv = process.env): DestroyEnv { + if (!hasValue(env.WP_LOCAL_ROOT_PATH)) { + throw new Error("Missing destroy environment variable(s): WP_LOCAL_ROOT_PATH"); + } + + return destroyEnvSchema.parse(env); +} + type GiteaVariableKey = (typeof giteaVariableKeys)[number]; type GiteaRepo = {