From 9b7cb13e658444e18f6b794cdc92454332ed94cd Mon Sep 17 00:00:00 2001 From: Pascal Martineau Date: Thu, 11 Jun 2026 14:13:56 -0400 Subject: [PATCH] feat: wpop provisino --- CLAUDE.md | 6 +- src/cli.ts | 17 ++ src/commands/provision.ts | 332 ++++++++++++++++++++++++++++++++++++++ src/lib/env.ts | 51 +++++- src/lib/gitea.ts | 79 +++++++++ 5 files changed, 475 insertions(+), 10 deletions(-) create mode 100644 src/commands/provision.ts create mode 100644 src/lib/gitea.ts diff --git a/CLAUDE.md b/CLAUDE.md index f29d8c6..418fd7d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,12 +22,13 @@ Package manager is pnpm@10 (declared via `packageManager`); husky + lint-staged ## Architecture -Single command today (`deploy`), but the layout assumes more will be added. +Commands today: `deploy` (local → remote), `sync` (remote → local), and `provision` (bootstrap 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. - `src/lib/run.ts` — `run(context, cmd, args, opts)` is the single chokepoint for spawning processes via `execa`. **In dry-run mode it logs and returns without executing.** Any new shell-out must go through `run` (or use `execa` directly only when capturing stdout, and in that case branch on `context.dryRun` like `sshOutput` in `deploy.ts`). - `src/lib/env.ts` — Zod schema and resolution for deploy-time env vars (`REMOTE_HOST`, `REMOTE_USER`, `REMOTE_PATH`, `REMOTE_PORT`, `SSH_PRIVATE_KEY`, `WPOP_CACHE_DIR`, `WP_VERSION`, `WP_LOCALE`). Schema is parsed lazily inside the command, not at import. If any `REMOTE_*` value is missing, it can read Gitea Actions variables from the inferred repo/org using `WEBSIMPLE_GITEA_API_TOKEN`, `WPOP_GITEA_TOKEN`, or `GITEA_TOKEN`; repo inference comes from `git remote get-url origin` and can be overridden with `WPOP_GITEA_REPO` or `WPOP_GITEA_OWNER` + `WPOP_GITEA_REPO_NAME`. +- `src/lib/gitea.ts` — shared Gitea API primitives: `getGiteaToken`/`getGiteaBaseUrl` (env resolution, used by both `env.ts` and `provision`), plus `giteaRepoExists` (GET repo) and `generateGiteaRepo` (POST `/generate` from a template repo). - `src/lib/ssh.ts` — builds `PreparedSsh` from env: optionally writes `SSH_PRIVATE_KEY` to a 0600 tempfile, runs `ssh-keyscan` to populate `~/.ssh/known_hosts`, then verifies access with `ssh -o BatchMode=yes`. `sshArgs`/`sshTarget`/`rsyncSshShell` are used to construct ssh and rsync invocations consistently. - `src/commands/deploy.ts` — orchestrates the deploy. Order matters: 1. Parse `--include` (default `vendor,plugins,themes,mu-plugins`; `all` adds `core`). @@ -35,9 +36,10 @@ Single command today (`deploy`), but the layout assumes more will be added. 3. **Drift check** — for every content component being deployed, list remote top-level dirs under `wp-content/` and abort if any are absent locally. This guards against `rsync --delete` wiping plugins/themes installed out-of-band on the server. 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`. - 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` (currently only `deploy` emits a one-shot JSON header). +Logging uses `consola`; structured output is opt-in via `--json` (`deploy` and `provision` emit a one-shot JSON report). ## Conventions diff --git a/src/cli.ts b/src/cli.ts index 19ed4a7..d0f914e 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 { provision } from "./commands/provision"; import { sync } from "./commands/sync"; import { createContext } from "./lib/context"; import { configureLogger, emitJson } from "./lib/output"; @@ -57,6 +58,22 @@ program } }); +program + .command("provision ") + .description( + "Provision a minimal local WordPress site (Gitea repo, clone, database, core, wp-config)", + ) + .action(async (slug: string, options: Record) => { + const context = createContext(program.opts()); + configureLogger(context); + try { + await provision(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/provision.ts b/src/commands/provision.ts new file mode 100644 index 0000000..3f29055 --- /dev/null +++ b/src/commands/provision.ts @@ -0,0 +1,332 @@ +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { consola } from "consola"; +import prompts from "prompts"; +import type { WPopContext } from "../lib/context"; +import { readProvisionEnv, type ProvisionEnv } from "../lib/env"; +import { generateGiteaRepo, giteaRepoExists } from "../lib/gitea"; +import { emitJson } from "../lib/output"; +import { run } from "../lib/run"; + +const AUTOLOAD_MARKER = "vendor/autoload.php"; +const WP_SETTINGS_PATTERN = /^([ \t]*)require_once ABSPATH \. ['"]wp-settings\.php['"];$/m; +const COMPOSER_AUTOLOAD_GUARD = [ + "if ( file_exists( __DIR__ . '/vendor/autoload.php' ) ) {", + "\trequire_once __DIR__ . '/vendor/autoload.php';", + "}", + "", + "", +].join("\n"); + +export type ProvisionOptions = Record; + +type Derived = { + sitePath: string; + dbName: string; + siteUrl: string; + owner: string; + templateOwner: string; + templateRepo: string; + cloneUrl: string; +}; + +type ProvisionReport = { + command: "provision"; + slug: string; + sitePath: string; + dbName: string; + siteUrl: string; + repo: string; + dryRun: boolean; + steps: string[]; +}; + +export async function provision( + context: WPopContext, + slug: string, + _options: ProvisionOptions, +): Promise { + if (!/^[a-zA-Z0-9._-]+$/.test(slug)) { + throw new Error(`Invalid site slug "${slug}": use letters, numbers, ".", "_" or "-" only`); + } + + const env = readProvisionEnv(process.env); + const derived = resolveDerived(env, slug); + const report = buildReport(context, slug, derived); + + 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; + } + } + + if (!context.dryRun && !existsSync(env.WP_LOCAL_ROOT_PATH)) { + throw new Error(`Local root path does not exist: ${env.WP_LOCAL_ROOT_PATH}`); + } + + await ensureGiteaRepo(context, env, slug, derived, report); + await ensureSiteDirectory(context, env, slug, derived, report); + await checkGitFreshness(context, derived.sitePath); + await ensureDatabase(context, derived.dbName, report); + await ensureCore(context, env, derived.sitePath, report); + await ensureWpConfig(context, derived, report); + + if (context.json) { + emitJson(report); + } else { + consola.success(`Provisioned ${slug} at ${derived.siteUrl}`); + } +} + +function resolveDerived(env: ProvisionEnv, slug: string): Derived { + const [templateOwner, templateRepo] = env.WPOP_GITEA_TEMPLATE.split("/"); + if (!templateOwner || !templateRepo) { + throw new Error( + `Invalid WPOP_GITEA_TEMPLATE "${env.WPOP_GITEA_TEMPLATE}": expected "owner/repo"`, + ); + } + + const host = new URL(env.giteaBaseUrl).hostname; + + return { + sitePath: join(env.WP_LOCAL_ROOT_PATH, slug), + dbName: `wp_${slug}`, + siteUrl: `${env.WEBSIMPLE_STACK_PROTOCOL}://${slug}.${env.WEBSIMPLE_STACK_DOMAIN}`, + owner: env.WPOP_GITEA_OWNER, + templateOwner, + templateRepo, + cloneUrl: `ssh://${env.WPOP_GITEA_SSH_USER}@${host}:${env.WPOP_GITEA_SSH_PORT}/${env.WPOP_GITEA_OWNER}/${slug}.git`, + }; +} + +function buildReport(context: WPopContext, slug: string, derived: Derived): ProvisionReport { + return { + command: "provision", + slug, + sitePath: derived.sitePath, + dbName: derived.dbName, + siteUrl: derived.siteUrl, + repo: `${derived.owner}/${slug}`, + dryRun: context.dryRun, + steps: [], + }; +} + +function logPlan(context: WPopContext, slug: string, derived: Derived): void { + consola.info(`Planning provision of ${slug}`); + consola.info(` repo: ${derived.owner}/${slug}`); + consola.info(` path: ${derived.sitePath}`); + consola.info(` database: ${derived.dbName}`); + consola.info(` url: ${derived.siteUrl}`); + 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: `Provision ${slug} at ${derived.sitePath}?`, + initial: false, + }); + + return Boolean(response.confirmed); +} + +async function ensureGiteaRepo( + context: WPopContext, + env: ProvisionEnv, + slug: string, + derived: Derived, + report: ProvisionReport, +): Promise { + if (context.dryRun) { + consola.info( + `[dry-run] Would ensure Gitea repo ${derived.owner}/${slug} exists (create from ${env.WPOP_GITEA_TEMPLATE} if missing)`, + ); + return; + } + + const exists = await giteaRepoExists(env.giteaBaseUrl, env.giteaToken, derived.owner, slug); + if (exists) { + consola.info(`Gitea repo ${derived.owner}/${slug} already exists`); + return; + } + + if (!context.yes) { + const response = await prompts({ + type: "confirm", + name: "confirmed", + message: `Repo ${derived.owner}/${slug} does not exist. Create it from ${env.WPOP_GITEA_TEMPLATE}?`, + initial: true, + }); + if (!response.confirmed) { + throw new Error(`Gitea repo ${derived.owner}/${slug} is required but was not created`); + } + } + + consola.info(`Creating Gitea repo ${derived.owner}/${slug} from ${env.WPOP_GITEA_TEMPLATE}`); + await generateGiteaRepo(env.giteaBaseUrl, env.giteaToken, { + templateOwner: derived.templateOwner, + templateRepo: derived.templateRepo, + owner: derived.owner, + name: slug, + }); + report.steps.push("gitea:create"); +} + +async function ensureSiteDirectory( + context: WPopContext, + env: ProvisionEnv, + slug: string, + derived: Derived, + report: ProvisionReport, +): Promise { + if (!context.dryRun && existsSync(derived.sitePath)) { + if (!existsSync(join(derived.sitePath, ".git"))) { + consola.warn(`${derived.sitePath} exists but is not a git repository`); + } else { + consola.info(`Site directory already present at ${derived.sitePath}`); + } + return; + } + + consola.info(`Cloning ${derived.cloneUrl} into ${derived.sitePath}`); + await run(context, "git", ["clone", derived.cloneUrl, derived.sitePath], { + cwd: env.WP_LOCAL_ROOT_PATH, + }); + report.steps.push("git:clone"); +} + +async function checkGitFreshness(context: WPopContext, sitePath: string): Promise { + if (context.dryRun) { + return; + } + + await run(context, "git", ["fetch", "--quiet"], { cwd: sitePath, capture: true, reject: false }); + + const status = await run(context, "git", ["status", "--porcelain"], { + cwd: sitePath, + capture: true, + reject: false, + }); + if (status.exitCode === 0 && status.stdout.trim()) { + consola.warn("Working tree has uncommitted changes"); + } + + const counts = await run(context, "git", ["rev-list", "--left-right", "--count", "@{u}...HEAD"], { + cwd: sitePath, + capture: true, + reject: false, + }); + if (counts.exitCode !== 0) { + return; + } + + const [behind, ahead] = counts.stdout.trim().split(/\s+/).map(Number); + if (behind > 0 || ahead > 0) { + consola.warn(`Branch diverged from upstream (behind ${behind}, ahead ${ahead})`); + } else { + consola.success("Git is up to date with upstream"); + } +} + +async function ensureDatabase( + context: WPopContext, + dbName: string, + report: ProvisionReport, +): Promise { + consola.info(`Ensuring database ${dbName} exists`); + await run(context, "mysql", [ + "-e", + `CREATE DATABASE IF NOT EXISTS \`${dbName}\` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;`, + ]); + report.steps.push("db:ensure"); +} + +async function ensureCore( + context: WPopContext, + env: ProvisionEnv, + sitePath: string, + report: ProvisionReport, +): Promise { + if (!context.dryRun && existsSync(join(sitePath, "wp-includes", "version.php"))) { + consola.info("WordPress core already present"); + return; + } + + consola.info("Downloading WordPress core (--skip-content)"); + await run(context, "wp", [ + "core", + "download", + "--skip-content", + `--version=${env.WP_VERSION}`, + `--locale=${env.WP_LOCALE}`, + `--path=${sitePath}`, + ]); + report.steps.push("core:download"); +} + +async function ensureWpConfig( + context: WPopContext, + derived: Derived, + report: ProvisionReport, +): Promise { + const configPath = join(derived.sitePath, "wp-config.php"); + + if (context.dryRun || !existsSync(configPath)) { + consola.info("Creating wp-config.php"); + await run(context, "wp", [ + "config", + "create", + `--dbname=${derived.dbName}`, + "--dbprefix=wp_", + "--skip-check", + `--path=${derived.sitePath}`, + ]); + report.steps.push("config:create"); + } else { + consola.info("wp-config.php already present"); + } + + ensureComposerAutoload(context, configPath, report); +} + +function ensureComposerAutoload( + context: WPopContext, + configPath: string, + report: ProvisionReport, +): void { + if (context.dryRun) { + consola.info("[dry-run] Would ensure Composer autoload guard in wp-config.php"); + return; + } + + const contents = readFileSync(configPath, "utf8"); + if (contents.includes(AUTOLOAD_MARKER)) { + consola.info("Composer autoload guard already present in wp-config.php"); + return; + } + + let updated: string; + if (WP_SETTINGS_PATTERN.test(contents)) { + updated = contents.replace(WP_SETTINGS_PATTERN, `${COMPOSER_AUTOLOAD_GUARD}$&`); + } else { + updated = contents.replace(/<\?php\n/, `$&\n${COMPOSER_AUTOLOAD_GUARD}`); + } + + writeFileSync(configPath, updated); + consola.info("Added Composer autoload guard to wp-config.php"); + report.steps.push("config:autoload"); +} diff --git a/src/lib/env.ts b/src/lib/env.ts index 9cca0fc..0c08f48 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { consola } from "consola"; import { execa } from "execa"; +import { getGiteaBaseUrl, getGiteaToken } from "./gitea"; const deployEnvSchema = z.object({ REMOTE_HOST: z.string().min(1), @@ -25,6 +26,48 @@ const giteaVariableKeys = [ export type DeployEnv = z.infer; +const provisionEnvSchema = z.object({ + WP_LOCAL_ROOT_PATH: z.string().min(1), + WEBSIMPLE_STACK_DOMAIN: z.string().min(1), + WEBSIMPLE_STACK_PROTOCOL: z.string().default("https"), + WP_VERSION: z.string().default("latest"), + WP_LOCALE: z.string().default("fr_CA"), + WPOP_GITEA_OWNER: z.string().default("wp-sites"), + WPOP_GITEA_TEMPLATE: z.string().default("templates/wp-boilerplate"), + WPOP_GITEA_SSH_USER: z.string().default("git"), + WPOP_GITEA_SSH_PORT: z.coerce.number().int().positive().default(222), +}); + +export type ProvisionEnv = z.infer & { + giteaToken: string; + giteaBaseUrl: string; +}; + +export function readProvisionEnv(env: NodeJS.ProcessEnv = process.env): ProvisionEnv { + const missing = (["WP_LOCAL_ROOT_PATH", "WEBSIMPLE_STACK_DOMAIN"] as const).filter( + (key) => !hasValue(env[key]), + ); + if (missing.length > 0) { + throw new Error(`Missing provision environment variable(s): ${missing.join(", ")}`); + } + + const giteaToken = getGiteaToken(env); + if (!giteaToken) { + throw new Error( + [ + "Missing Gitea API token for provisioning.", + "Set WPOP_GITEA_TOKEN, WEBSIMPLE_GITEA_API_TOKEN, or GITEA_TOKEN (write access required to create the site repository).", + ].join("\n"), + ); + } + + return { + ...provisionEnvSchema.parse(env), + giteaToken, + giteaBaseUrl: getGiteaBaseUrl(env), + }; +} + type GiteaVariableKey = (typeof giteaVariableKeys)[number]; type GiteaRepo = { @@ -118,14 +161,6 @@ async function readGiteaDeployEnv( }; } -function getGiteaToken(env: NodeJS.ProcessEnv): string | undefined { - return firstValue(env.WPOP_GITEA_TOKEN, env.WEBSIMPLE_GITEA_API_TOKEN, env.GITEA_TOKEN); -} - -function getGiteaBaseUrl(env: NodeJS.ProcessEnv): string { - return firstValue(env.WPOP_GITEA_BASE_URL, env.GITEA_BASE_URL) ?? "https://gitea.websimple.com"; -} - async function resolveGiteaRepo( env: NodeJS.ProcessEnv, cwd: string, diff --git a/src/lib/gitea.ts b/src/lib/gitea.ts new file mode 100644 index 0000000..69c05d8 --- /dev/null +++ b/src/lib/gitea.ts @@ -0,0 +1,79 @@ +export function getGiteaToken(env: NodeJS.ProcessEnv): string | undefined { + return firstValue(env.WPOP_GITEA_TOKEN, env.WEBSIMPLE_GITEA_API_TOKEN, env.GITEA_TOKEN); +} + +export function getGiteaBaseUrl(env: NodeJS.ProcessEnv): string { + return firstValue(env.WPOP_GITEA_BASE_URL, env.GITEA_BASE_URL) ?? "https://gitea.websimple.com"; +} + +export async function giteaRepoExists( + baseUrl: string, + token: string, + owner: string, + repo: string, +): Promise { + const url = new URL( + `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`, + baseUrl, + ); + + const response = await fetch(url, { + headers: { + Accept: "application/json", + Authorization: `token ${token}`, + }, + }); + + if (response.status === 200) { + return true; + } + + if (response.status === 404) { + return false; + } + + throw new Error(`Gitea API request failed (${response.status}) while checking ${owner}/${repo}`); +} + +export async function generateGiteaRepo( + baseUrl: string, + token: string, + options: { templateOwner: string; templateRepo: string; owner: string; name: string }, +): Promise { + const url = new URL( + `/api/v1/repos/${encodeURIComponent(options.templateOwner)}/${encodeURIComponent(options.templateRepo)}/generate`, + baseUrl, + ); + + const response = await fetch(url, { + method: "POST", + headers: { + Accept: "application/json", + Authorization: `token ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + owner: options.owner, + name: options.name, + git_content: true, + private: true, + }), + }); + + if (!response.ok) { + throw new Error( + `Gitea API request failed (${response.status}) while generating ${options.owner}/${options.name} from ${options.templateOwner}/${options.templateRepo}`, + ); + } +} + +function firstValue(...values: (string | undefined)[]): string | undefined { + for (const value of values) { + const trimmed = value?.trim(); + if (trimmed) { + return trimmed; + } + } + + return undefined; +}