6 Commits

11 changed files with 1113 additions and 56 deletions

View File

@@ -1,5 +1,29 @@
# Changelog # Changelog
## v0.0.5
[compare changes](https://gitea.websimple.com/pascalmartineau/wpop/compare/v0.0.4...v0.0.5)
### 🚀 Enhancements
- Wpop sync (fedb05f)
## v0.0.4
[compare changes](https://gitea.websimple.com/pascalmartineau/wpop/compare/v0.0.3...v0.0.4)
### 🚀 Enhancements
- Enhance deployment process with Gitea Actions integration and improved environment variable handling (39067c8)
## v0.0.3
[compare changes](https://gitea.websimple.com/pascalmartineau/wpop/compare/v0.0.2...v0.0.3)
### 🏡 Chore
- Check and publish on release (87c87d6)
## v0.0.2 ## v0.0.2
[compare changes](https://gitea.websimple.com/pascalmartineau/wpop/compare/v0.0.1...v0.0.2) [compare changes](https://gitea.websimple.com/pascalmartineau/wpop/compare/v0.0.1...v0.0.2)

View File

@@ -27,7 +27,7 @@ Single command today (`deploy`), but 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/<name>.ts`, take `(context, options)`. - `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/<name>.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/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/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 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. - `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/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/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: - `src/commands/deploy.ts` — orchestrates the deploy. Order matters:
1. Parse `--include` (default `vendor,plugins,themes,mu-plugins`; `all` adds `core`). 1. Parse `--include` (default `vendor,plugins,themes,mu-plugins`; `all` adds `core`).

View File

@@ -1,3 +1,25 @@
# WPop # WPop
WordPress operations CLI for Websimple projects. WordPress operations CLI for Websimple projects.
## Deploy environment
`wpop deploy` needs:
- `REMOTE_HOST`
- `REMOTE_PORT`
- `REMOTE_USER`
- `REMOTE_PATH`
If any `REMOTE_*` value is missing, `wpop` tries to read Websimple Gitea Actions variables from the
current repository and its organization. Provide a read-only Gitea token with one of:
- `WEBSIMPLE_GITEA_API_TOKEN`
- `WPOP_GITEA_TOKEN`
- `GITEA_TOKEN`
By default, the repository is inferred from `git remote get-url origin`. You can override this with:
- `WPOP_GITEA_REPO=wp-sites/example`
- `WPOP_GITEA_OWNER=wp-sites` and `WPOP_GITEA_REPO_NAME=example`
- `WPOP_GITEA_BASE_URL=https://gitea.websimple.com`

View File

@@ -1,6 +1,6 @@
{ {
"name": "@lewebsimple/wpop", "name": "@lewebsimple/wpop",
"version": "0.0.2", "version": "0.0.5",
"description": "WordPress operations CLI for Websimple projects.", "description": "WordPress operations CLI for Websimple projects.",
"license": "MIT", "license": "MIT",
"author": "Pascal Martineau <pascal@lewebsimple.ca>", "author": "Pascal Martineau <pascal@lewebsimple.ca>",
@@ -17,14 +17,14 @@
"scripts": { "scripts": {
"build": "tsdown", "build": "tsdown",
"changelog": "changelogen", "changelog": "changelogen",
"check": "npm run format:check && npm run lint && npm run typecheck", "check": "pnpm run format:check && pnpm run lint && pnpm run typecheck",
"dev": "tsx src/cli.ts", "dev": "tsx src/cli.ts",
"format:check": "oxfmt . --check", "format:check": "oxfmt . --check",
"format": "oxfmt . --write", "format": "oxfmt . --write",
"lint:fix": "oxlint . --fix", "lint:fix": "oxlint . --fix",
"lint": "oxlint .", "lint": "oxlint .",
"prepare": "husky", "prepare": "husky",
"release": "changelogen --release --push --noAuthors", "release": "pnpm check && pnpm build && changelogen --release --push --noAuthors && pnpm publish",
"typecheck": "tsc -p tsconfig.json --noEmit" "typecheck": "tsc -p tsconfig.json --noEmit"
}, },
"dependencies": { "dependencies": {

View File

@@ -1,6 +1,7 @@
#!/usr/bin/env node #!/usr/bin/env node
import { Command } from "commander"; import { Command } from "commander";
import { deploy } from "./commands/deploy"; import { deploy } from "./commands/deploy";
import { sync } from "./commands/sync";
import { createContext } from "./lib/context"; import { createContext } from "./lib/context";
import { configureLogger, emitJson } from "./lib/output"; import { configureLogger, emitJson } from "./lib/output";
import pkg from "../package.json" with { type: "json" }; import pkg from "../package.json" with { type: "json" };
@@ -37,6 +38,25 @@ program
} }
}); });
program
.command("sync")
.description("Sync a WordPress project from the remote into the local working copy")
.option(
"--include <components>",
"comma-separated components to sync: database,uploads,plugins,themes,mu-plugins or all",
)
.option("--skip-search-replace", "skip URL search-replace after database import")
.action(async (options: { include?: string; skipSearchReplace?: boolean }) => {
const context = createContext(program.opts());
configureLogger(context);
try {
await sync(context, options);
} catch (error) {
handleError(context, error);
process.exitCode = 1;
}
});
function handleError(context: { json: boolean }, error: unknown): void { function handleError(context: { json: boolean }, error: unknown): void {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
if (context.json) { if (context.json) {

View File

@@ -5,15 +5,9 @@ import prompts from "prompts";
import type { WPopContext } from "../lib/context"; import type { WPopContext } from "../lib/context";
import { readDeployEnv, type DeployEnv } from "../lib/env"; import { readDeployEnv, type DeployEnv } from "../lib/env";
import { emitJson } from "../lib/output"; import { emitJson } from "../lib/output";
import { parseMarkedOutput, rsync, sshOutput, sshRun } from "../lib/remote";
import { run } from "../lib/run"; import { run } from "../lib/run";
import { import { createSshConfig, prepareSsh, sshTarget, type PreparedSsh } from "../lib/ssh";
createSshConfig,
prepareSsh,
rsyncSshShell,
sshArgs,
sshTarget,
type PreparedSsh,
} from "../lib/ssh";
import { createTempDir } from "../lib/tempdir"; import { createTempDir } from "../lib/tempdir";
const DEFAULT_COMPONENTS = ["vendor", "plugins", "themes", "mu-plugins"] as const; const DEFAULT_COMPONENTS = ["vendor", "plugins", "themes", "mu-plugins"] as const;
@@ -37,6 +31,11 @@ const CORE_RSYNC_EXCLUDES = [
".well-known/", ".well-known/",
] as const; ] as const;
const REMOTE_LIST_BEGIN = "__WPOP_REMOTE_LIST_BEGIN__";
const REMOTE_LIST_END = "__WPOP_REMOTE_LIST_END__";
const COMPOSER_AUTOLOAD_BEGIN = "__WPOP_COMPOSER_AUTOLOAD_BEGIN__";
const COMPOSER_AUTOLOAD_END = "__WPOP_COMPOSER_AUTOLOAD_END__";
type PackageManager = { type PackageManager = {
lockfile: string; lockfile: string;
install: readonly string[]; install: readonly string[];
@@ -80,8 +79,14 @@ type DeployReport = {
}; };
export async function deploy(context: WPopContext, options: DeployOptions): Promise<void> { export async function deploy(context: WPopContext, options: DeployOptions): Promise<void> {
const env = readDeployEnv(); const env = await readDeployEnv(process.env, { cwd: context.cwd });
const components = parseComponents(options.include); const promptedForComponents = shouldPromptForComponents(context, options.include);
const components = await resolveComponents(context, options.include);
if (!components) {
consola.warn("Aborted.");
return;
}
const commandEnv = createCommandEnv(env.WPOP_CACHE_DIR); const commandEnv = createCommandEnv(env.WPOP_CACHE_DIR);
const report = buildReport(context, env, components); const report = buildReport(context, env, components);
@@ -96,7 +101,7 @@ export async function deploy(context: WPopContext, options: DeployOptions): Prom
if (context.dryRun) { if (context.dryRun) {
consola.info("Dry-run: no remote changes will be made"); consola.info("Dry-run: no remote changes will be made");
} }
if (!(await confirm(context, components, env))) { if (!promptedForComponents && !(await confirm(context, components, env))) {
consola.warn("Aborted."); consola.warn("Aborted.");
return; return;
} }
@@ -118,7 +123,7 @@ export async function deploy(context: WPopContext, options: DeployOptions): Prom
await syncVendor(context, env, ssh); await syncVendor(context, env, ssh);
report.steps.push("sync:vendor"); report.steps.push("sync:vendor");
if (existsSync(join(context.cwd, "vendor"))) { if (existsSync(join(context.cwd, "vendor"))) {
await checkRemoteComposerAutoload(context, env, ssh); await ensureRemoteComposerAutoload(context, env, ssh);
} }
} }
@@ -164,6 +169,41 @@ function sshTargetSummary(env: DeployEnv): string {
return `${env.REMOTE_USER}@${env.REMOTE_HOST}:${env.REMOTE_PORT}`; return `${env.REMOTE_USER}@${env.REMOTE_HOST}:${env.REMOTE_PORT}`;
} }
function shouldPromptForComponents(context: WPopContext, include?: string): boolean {
return !include && !context.yes && !context.dryRun && !context.json;
}
async function resolveComponents(
context: WPopContext,
include?: string,
): Promise<Set<DeployComponent> | undefined> {
if (!shouldPromptForComponents(context, include)) {
return parseComponents(include);
}
const response = await prompts({
type: "multiselect",
name: "components",
message: "Select deploy components",
choices: ALL_COMPONENTS.map((component) => ({
title: component,
value: component,
selected: DEFAULT_COMPONENTS.includes(component as (typeof DEFAULT_COMPONENTS)[number]),
})),
instructions: false,
});
if (!Array.isArray(response.components)) {
return undefined;
}
if (response.components.length === 0) {
throw new Error("Select at least one deploy component");
}
return new Set(response.components as DeployComponent[]);
}
async function confirm( async function confirm(
context: WPopContext, context: WPopContext,
components: Set<DeployComponent>, components: Set<DeployComponent>,
@@ -341,17 +381,20 @@ async function listRemoteTopLevelDirs(
// Paths flow through env vars and are quoted with JSON.stringify, which is // 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. // close enough to POSIX double-quote escaping for the values we handle here.
const script = `set -e const script = `set -e
printf '%s\\n' ${JSON.stringify(REMOTE_LIST_BEGIN)}
cd ${JSON.stringify(env.REMOTE_PATH)} cd ${JSON.stringify(env.REMOTE_PATH)}
if [ -d ${JSON.stringify(path)} ]; then if [ -d ${JSON.stringify(path)} ]; then
find ${JSON.stringify(path)} -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | sort find ${JSON.stringify(path)} -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | sort
fi fi
printf '%s\\n' ${JSON.stringify(REMOTE_LIST_END)}
`; `;
const { stdout } = await sshOutput(context, ssh, script); const { stdout } = await sshOutput(context, ssh, script);
return stdout if (context.dryRun) {
.split("\n") return [];
.map((line) => line.trim()) }
.filter(Boolean);
return parseMarkedOutput(stdout, REMOTE_LIST_BEGIN, REMOTE_LIST_END, "remote directory listing");
} }
async function syncCore( async function syncCore(
@@ -410,19 +453,141 @@ async function syncVendor(context: WPopContext, env: DeployEnv, ssh: PreparedSsh
} }
} }
async function checkRemoteComposerAutoload( async function ensureRemoteComposerAutoload(
context: WPopContext, context: WPopContext,
env: DeployEnv, env: DeployEnv,
ssh: PreparedSsh, ssh: PreparedSsh,
): Promise<void> { ): Promise<void> {
consola.info("Checking remote Composer autoload inclusion"); consola.info("Checking remote Composer autoload inclusion");
const script = `printf '%s\\n' ${JSON.stringify(COMPOSER_AUTOLOAD_BEGIN)}
if ! cd ${JSON.stringify(env.REMOTE_PATH)}; then
echo missing_remote_path
elif [ ! -f wp-config.php ]; then
echo missing_wp_config
elif grep -q 'vendor/autoload.php' wp-config.php; then
echo ok
else
echo missing_autoload
fi
printf '%s\\n' ${JSON.stringify(COMPOSER_AUTOLOAD_END)}
`;
const { stdout } = await sshOutput(context, ssh, script);
if (context.dryRun) {
return;
}
const [status] = parseMarkedOutput(
stdout,
COMPOSER_AUTOLOAD_BEGIN,
COMPOSER_AUTOLOAD_END,
"remote Composer autoload check",
);
if (status === "ok") {
return;
}
if (status === "missing_wp_config") {
throw new Error(`Remote wp-config.php not found in ${env.REMOTE_PATH}`);
}
if (status === "missing_autoload") {
await repairRemoteComposerAutoload(context, env, ssh);
return;
}
if (status === "missing_remote_path") {
throw new Error(`Remote path does not exist or is not accessible: ${env.REMOTE_PATH}`);
}
throw new Error(`Unexpected remote Composer autoload check result: ${status ?? "empty output"}`);
}
async function repairRemoteComposerAutoload(
context: WPopContext,
env: DeployEnv,
ssh: PreparedSsh,
): Promise<void> {
if (context.dryRun) {
consola.info("[dry-run] Would add Composer autoload include to remote wp-config.php");
return;
}
if (context.json && !context.yes) {
throw new Error(
[
"Remote wp-config.php does not include vendor/autoload.php.",
"Re-run with --yes to allow wpop to add the Composer autoload include automatically.",
].join("\n"),
);
}
if (!context.yes && !(await confirmRemoteComposerAutoloadRepair(env))) {
throw new Error(
[
"Remote wp-config.php does not include vendor/autoload.php.",
"Deploy cannot continue safely without the Composer autoload include.",
].join("\n"),
);
}
consola.info("Adding Composer autoload include to remote wp-config.php");
const script = `set -e const script = `set -e
cd ${JSON.stringify(env.REMOTE_PATH)} cd ${JSON.stringify(env.REMOTE_PATH)}
test -f wp-config.php php <<'PHP'
grep -q 'vendor/autoload.php' wp-config.php <?php
$path = 'wp-config.php';
$contents = file_get_contents($path);
if ($contents === false) {
fwrite(STDERR, "Could not read wp-config.php\\n");
exit(1);
}
if (strpos($contents, 'vendor/autoload.php') !== false) {
exit(0);
}
if (!preg_match('/^<\\?php\\s*/', $contents)) {
fwrite(STDERR, "wp-config.php does not start with <?php\\n");
exit(1);
}
$backup = $path . '.wpop-' . gmdate('YmdHis') . '.bak';
if (!copy($path, $backup)) {
fwrite(STDERR, "Could not create backup $backup\\n");
exit(1);
}
$autoload = "if ( file_exists( __DIR__ . '/vendor/autoload.php' ) ) {\\n\\trequire_once __DIR__ . '/vendor/autoload.php';\\n}\\n\\n";
$updated = preg_replace('/^<\\?php\\s*/', "<?php\\n" . $autoload, $contents, 1);
if ($updated === null || file_put_contents($path, $updated) === false) {
fwrite(STDERR, "Could not update wp-config.php\\n");
exit(1);
}
echo "Created backup $backup\\n";
PHP
`; `;
await sshRun(context, ssh, script); await sshRun(context, ssh, script);
await ensureRemoteComposerAutoload(context, env, ssh);
}
async function confirmRemoteComposerAutoloadRepair(env: DeployEnv): Promise<boolean> {
const response = await prompts({
type: "confirm",
name: "confirmed",
message: [
"Remote wp-config.php does not include vendor/autoload.php.",
`Add the Composer autoload include on ${env.REMOTE_HOST}:${env.REMOTE_PATH}/wp-config.php?`,
].join(" "),
initial: false,
});
return Boolean(response.confirmed);
} }
async function syncContentComponent( async function syncContentComponent(
@@ -498,22 +663,3 @@ fi
await sshRun(context, ssh, script); await sshRun(context, ssh, script);
} }
async function rsync(context: WPopContext, ssh: PreparedSsh, args: string[]): Promise<void> {
await run(context, "rsync", ["-az", "-e", rsyncSshShell(ssh), ...args]);
}
async function sshRun(context: WPopContext, ssh: PreparedSsh, script: string): Promise<void> {
await run(context, "ssh", [...sshArgs(ssh), sshTarget(ssh)], { stdin: 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,
});
}

426
src/commands/sync.ts Normal file
View File

@@ -0,0 +1,426 @@
import { existsSync, mkdirSync, readdirSync } from "node:fs";
import { join } from "node:path";
import { consola } from "consola";
import prompts from "prompts";
import type { WPopContext } from "../lib/context";
import { readDeployEnv, type DeployEnv } from "../lib/env";
import { emitJson } from "../lib/output";
import { parseMarkedOutput, rsync, sshOutput } from "../lib/remote";
import { run } from "../lib/run";
import { createSshConfig, prepareSsh, sshArgs, sshTarget, type PreparedSsh } from "../lib/ssh";
const SYNC_COMPONENTS = ["database", "uploads", "plugins", "themes", "mu-plugins"] as const;
const DEFAULT_SYNC_COMPONENTS = ["database", "uploads"] as const;
const CONTENT_SYNC_COMPONENTS = ["uploads", "plugins", "themes", "mu-plugins"] as const;
const CODE_SYNC_COMPONENTS = ["plugins", "themes", "mu-plugins"] as const;
const REMOTE_LIST_BEGIN = "__WPOP_REMOTE_LIST_BEGIN__";
const REMOTE_LIST_END = "__WPOP_REMOTE_LIST_END__";
const REMOTE_OPTION_BEGIN = "__WPOP_REMOTE_OPTION_BEGIN__";
const REMOTE_OPTION_END = "__WPOP_REMOTE_OPTION_END__";
const REMOTE_DB_BEGIN = "__WPOP_REMOTE_DB_BEGIN__";
export type SyncComponent = (typeof SYNC_COMPONENTS)[number];
export type SyncOptions = {
include?: string;
skipSearchReplace?: boolean;
};
type SyncReport = {
command: "sync";
cwd: string;
include: SyncComponent[];
remote: { host: string; port: number; user: string; path: string };
cacheDir: string;
dryRun: boolean;
steps: string[];
};
export async function sync(context: WPopContext, options: SyncOptions): Promise<void> {
const env = await readDeployEnv(process.env, { cwd: context.cwd });
const promptedForComponents = shouldPromptForComponents(context, options.include);
const components = await resolveComponents(context, options.include);
if (!components) {
consola.warn("Aborted.");
return;
}
const report = buildReport(context, env, components);
if (context.json) {
if (!context.yes && !context.dryRun) {
throw new Error("--json requires --yes (cannot prompt for confirmation in JSON mode)");
}
} else {
consola.info(
`Planning sync of ${[...components].join(", ")} from ${sshTargetSummary(env)}:${env.REMOTE_PATH}`,
);
if (context.dryRun) {
consola.info("Dry-run: no local changes will be made");
}
if (components.has("database")) {
consola.warn("This will OVERWRITE the local database.");
}
if (!promptedForComponents && !(await confirm(context, components, env))) {
consola.warn("Aborted.");
return;
}
}
const ssh = await prepareSsh(context, createSshConfig(env), env.WPOP_CACHE_DIR);
await warnContentDrift(context, env, ssh, components);
let localSiteurl: string | undefined;
if (components.has("database") && !options.skipSearchReplace) {
localSiteurl = await readLocalOption(context, "siteurl");
}
for (const component of CONTENT_SYNC_COMPONENTS) {
if (components.has(component)) {
await syncContentComponent(context, env, ssh, component);
report.steps.push(`sync:${component}`);
}
}
if (components.has("database")) {
await syncDatabase(context, env, ssh);
report.steps.push("db:import");
if (!options.skipSearchReplace) {
await runSearchReplace(context, ssh, env, localSiteurl);
report.steps.push("db:search-replace");
}
}
if (context.json) {
emitJson(report);
} else {
consola.success("Sync complete.");
}
}
function buildReport(
context: WPopContext,
env: DeployEnv,
components: Set<SyncComponent>,
): SyncReport {
return {
command: "sync",
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}`;
}
function shouldPromptForComponents(context: WPopContext, include?: string): boolean {
return !include && !context.yes && !context.dryRun && !context.json;
}
async function resolveComponents(
context: WPopContext,
include?: string,
): Promise<Set<SyncComponent> | undefined> {
if (!shouldPromptForComponents(context, include)) {
return parseComponents(include);
}
const response = await prompts({
type: "multiselect",
name: "components",
message: "Select sync components",
choices: SYNC_COMPONENTS.map((component) => ({
title: component,
value: component,
selected: DEFAULT_SYNC_COMPONENTS.includes(
component as (typeof DEFAULT_SYNC_COMPONENTS)[number],
),
})),
instructions: false,
});
if (!Array.isArray(response.components)) {
return undefined;
}
if (response.components.length === 0) {
throw new Error("Select at least one sync component");
}
return new Set(response.components as SyncComponent[]);
}
async function confirm(
context: WPopContext,
components: Set<SyncComponent>,
env: DeployEnv,
): Promise<boolean> {
if (context.yes || context.dryRun) {
return true;
}
const response = await prompts({
type: "confirm",
name: "confirmed",
message: `Sync ${[...components].join(",")} from ${sshTargetSummary(env)}:${env.REMOTE_PATH} into ${context.cwd}?`,
initial: false,
});
return Boolean(response.confirmed);
}
function parseComponents(include?: string): Set<SyncComponent> {
if (!include) {
return new Set(DEFAULT_SYNC_COMPONENTS);
}
if (include === "all") {
return new Set(SYNC_COMPONENTS);
}
const components = include
.split(",")
.map((component) => component.trim())
.filter(Boolean);
const invalid = components.filter(
(component) => !SYNC_COMPONENTS.includes(component as SyncComponent),
);
if (invalid.length > 0) {
throw new Error(`Invalid sync component(s): ${invalid.join(", ")}`);
}
return new Set(components as SyncComponent[]);
}
function contentPath(component: (typeof CONTENT_SYNC_COMPONENTS)[number]): string {
return component === "uploads" ? "wp-content/uploads" : `wp-content/${component}`;
}
async function syncContentComponent(
context: WPopContext,
env: DeployEnv,
ssh: PreparedSsh,
component: (typeof CONTENT_SYNC_COMPONENTS)[number],
): Promise<void> {
const path = contentPath(component);
const localPath = join(context.cwd, path);
if (!context.dryRun) {
mkdirSync(localPath, { recursive: true });
}
consola.info(`Synchronizing ${component} from remote`);
const args: string[] = [];
if (component !== "uploads") {
args.push("--delete");
}
args.push(`${sshTarget(ssh)}:${env.REMOTE_PATH}/${path}/`, `${localPath}/`);
await rsync(context, ssh, args);
}
async function warnContentDrift(
context: WPopContext,
env: DeployEnv,
ssh: PreparedSsh,
components: Set<SyncComponent>,
): Promise<void> {
for (const component of CODE_SYNC_COMPONENTS) {
if (!components.has(component)) {
continue;
}
const localPath = join(context.cwd, contentPath(component));
const remote = await listRemoteTopLevelDirs(context, env, ssh, contentPath(component));
const local = listLocalTopLevelDirs(localPath);
const onlyLocal = local.filter((name) => !remote.includes(name));
if (onlyLocal.length > 0) {
consola.warn(
[
`Local ${component} contains entries that are not present on the remote:`,
...onlyLocal.map((name) => `- ${contentPath(component)}/${name}`),
"rsync --delete will remove them on sync.",
].join("\n"),
);
}
}
}
function listLocalTopLevelDirs(path: string): string[] {
if (!existsSync(path)) {
return [];
}
return readdirSync(path, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort();
}
async function listRemoteTopLevelDirs(
context: WPopContext,
env: DeployEnv,
ssh: PreparedSsh,
path: string,
): Promise<string[]> {
const script = `set -e
printf '%s\\n' ${JSON.stringify(REMOTE_LIST_BEGIN)}
cd ${JSON.stringify(env.REMOTE_PATH)}
if [ -d ${JSON.stringify(path)} ]; then
find ${JSON.stringify(path)} -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | sort
fi
printf '%s\\n' ${JSON.stringify(REMOTE_LIST_END)}
`;
const { stdout } = await sshOutput(context, ssh, script);
if (context.dryRun) {
return [];
}
return parseMarkedOutput(stdout, REMOTE_LIST_BEGIN, REMOTE_LIST_END, "remote directory listing");
}
async function syncDatabase(context: WPopContext, env: DeployEnv, ssh: PreparedSsh): Promise<void> {
consola.info("Importing remote database into local");
const remoteDump = [
`cd ${JSON.stringify(env.REMOTE_PATH)}`,
`printf '%s\\n' ${JSON.stringify(REMOTE_DB_BEGIN)}`,
"wp db export --skip-plugins --skip-themes --single-transaction --quick --default-character-set=utf8mb4 -",
].join(" && ");
const pipeline = [
`ssh ${sshArgs(ssh).join(" ")} ${sshTarget(ssh)} ${JSON.stringify(remoteDump)}`,
`sed -n '/^${REMOTE_DB_BEGIN}$/,$p' | sed '1d'`,
`wp --path=${JSON.stringify(context.cwd)} db import --skip-plugins --skip-themes -`,
].join(" | ");
await run(context, "bash", ["-o", "pipefail", "-c", pipeline]);
}
async function runSearchReplace(
context: WPopContext,
ssh: PreparedSsh,
env: DeployEnv,
localSiteurl: string | undefined,
): Promise<void> {
const remoteSiteurl = await readRemoteOption(context, ssh, env, "siteurl");
const remoteHome = await readRemoteOption(context, ssh, env, "home");
if (context.dryRun) {
consola.info("[dry-run] Would run wp search-replace for siteurl and home");
await run(context, "wp", ["search-replace", "<REMOTE_URL>", "<LOCAL_URL>", "--all-tables"], {
cwd: context.cwd,
});
return;
}
if (!localSiteurl) {
consola.warn("Could not determine local siteurl; skipping search-replace");
return;
}
if (!remoteSiteurl) {
consola.warn("Could not determine remote siteurl; skipping search-replace");
return;
}
if (remoteSiteurl === localSiteurl) {
consola.info(`Local and remote siteurl match (${localSiteurl}); skipping search-replace`);
return;
}
consola.info(`Rewriting URLs: ${remoteSiteurl} -> ${localSiteurl}`);
await run(
context,
"wp",
[
"search-replace",
remoteSiteurl,
localSiteurl,
"--all-tables",
"--report-changed-only",
"--skip-columns=guid",
],
{ cwd: context.cwd },
);
if (remoteHome && remoteHome !== remoteSiteurl) {
const localHome = localSiteurl;
consola.info(`Rewriting home URLs: ${remoteHome} -> ${localHome}`);
await run(
context,
"wp",
[
"search-replace",
remoteHome,
localHome,
"--all-tables",
"--report-changed-only",
"--skip-columns=guid",
],
{ cwd: context.cwd },
);
}
await run(context, "wp", ["cache", "flush"], { cwd: context.cwd });
}
async function readLocalOption(context: WPopContext, option: string): Promise<string | undefined> {
const result = await run(context, "wp", ["option", "get", option], {
capture: true,
cwd: context.cwd,
reject: false,
});
if (context.dryRun) {
return undefined;
}
if (result.exitCode !== 0) {
return undefined;
}
return result.stdout.trim() || undefined;
}
async function readRemoteOption(
context: WPopContext,
ssh: PreparedSsh,
env: DeployEnv,
option: string,
): Promise<string | undefined> {
const script = `printf '%s\\n' ${JSON.stringify(REMOTE_OPTION_BEGIN)}
cd ${JSON.stringify(env.REMOTE_PATH)} && wp option get ${JSON.stringify(option)} || true
printf '%s\\n' ${JSON.stringify(REMOTE_OPTION_END)}
`;
const { stdout } = await sshOutput(context, ssh, script);
if (context.dryRun) {
return undefined;
}
const lines = parseMarkedOutput(
stdout,
REMOTE_OPTION_BEGIN,
REMOTE_OPTION_END,
`remote option ${option}`,
);
return lines.join("\n").trim() || undefined;
}

View File

@@ -1,4 +1,6 @@
import { z } from "zod"; import { z } from "zod";
import { consola } from "consola";
import { execa } from "execa";
const deployEnvSchema = z.object({ const deployEnvSchema = z.object({
REMOTE_HOST: z.string().min(1), REMOTE_HOST: z.string().min(1),
@@ -11,8 +13,266 @@ const deployEnvSchema = z.object({
WP_LOCALE: z.string().default("fr_CA"), WP_LOCALE: z.string().default("fr_CA"),
}); });
const remoteEnvKeys = ["REMOTE_HOST", "REMOTE_PORT", "REMOTE_USER", "REMOTE_PATH"] as const;
const requiredRemoteEnvKeys = ["REMOTE_HOST", "REMOTE_USER", "REMOTE_PATH"] as const;
const giteaVariableKeys = [
"REMOTE_HOST",
"REMOTE_PORT",
"REMOTE_USER",
"REMOTE_PATH",
"WP_SITE_URL",
] as const;
export type DeployEnv = z.infer<typeof deployEnvSchema>; export type DeployEnv = z.infer<typeof deployEnvSchema>;
export function readDeployEnv(env: NodeJS.ProcessEnv = process.env): DeployEnv { type GiteaVariableKey = (typeof giteaVariableKeys)[number];
return deployEnvSchema.parse(env);
type GiteaRepo = {
owner: string;
repo: string;
};
type GiteaVariable = {
name?: string;
data?: string;
value?: string;
};
export async function readDeployEnv(
env: NodeJS.ProcessEnv = process.env,
options: { cwd?: string } = {},
): Promise<DeployEnv> {
const normalizedEnv = normalizeEnv(env);
const missingRemoteKeys = remoteEnvKeys.filter((key) => !hasValue(normalizedEnv[key]));
if (missingRemoteKeys.length > 0) {
const giteaEnv = await readGiteaDeployEnv(normalizedEnv, options.cwd);
for (const key of giteaVariableKeys) {
if (!hasValue(normalizedEnv[key]) && hasValue(giteaEnv[key])) {
normalizedEnv[key] = giteaEnv[key];
}
}
}
const missingRequiredKeys = requiredRemoteEnvKeys.filter((key) => !hasValue(normalizedEnv[key]));
if (missingRequiredKeys.length > 0) {
throw new Error(
[
`Missing deploy environment variable(s): ${missingRequiredKeys.join(", ")}`,
"Set them directly or provide WEBSIMPLE_GITEA_API_TOKEN so wpop can read Gitea Actions variables.",
].join("\n"),
);
}
return deployEnvSchema.parse(normalizedEnv);
}
function normalizeEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const normalized = { ...env };
for (const key of [...remoteEnvKeys, "WP_SITE_URL"] as const) {
if (typeof normalized[key] === "string" && normalized[key].trim() === "") {
delete normalized[key];
}
}
return normalized;
}
async function readGiteaDeployEnv(
env: NodeJS.ProcessEnv,
cwd = process.cwd(),
): Promise<Partial<Record<GiteaVariableKey, string>>> {
const token = getGiteaToken(env);
if (!token) {
return {};
}
const repo = await resolveGiteaRepo(env, cwd);
if (!repo) {
consola.warn("Could not infer Gitea repository from git origin; skipping Gitea env lookup");
return {};
}
consola.info(
`Reading deploy environment from Gitea Actions variables (${repo.owner}/${repo.repo})`,
);
const baseUrl = getGiteaBaseUrl(env);
const [orgVariables, repoVariables] = await Promise.all([
fetchGiteaVariables(
baseUrl,
token,
`/api/v1/orgs/${encodeURIComponent(repo.owner)}/actions/variables`,
true,
),
fetchGiteaVariables(
baseUrl,
token,
`/api/v1/repos/${encodeURIComponent(repo.owner)}/${encodeURIComponent(repo.repo)}/actions/variables`,
false,
),
]);
return {
...variablesToEnv(orgVariables),
...variablesToEnv(repoVariables),
};
}
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,
): Promise<GiteaRepo | undefined> {
const explicit = parseRepoSpec(env.WPOP_GITEA_REPO);
if (explicit) {
return explicit;
}
const explicitRepo = firstValue(env.WPOP_GITEA_REPO_NAME, env.GITEA_REPOSITORY);
if (explicitRepo) {
const owner = firstValue(env.WPOP_GITEA_OWNER, env.GITEA_REPOSITORY_OWNER) ?? "wp-sites";
return parseRepoSpec(explicitRepo) ?? { owner, repo: trimGitSuffix(explicitRepo) };
}
const originUrl = await readGitOriginUrl(cwd);
if (!originUrl) {
return undefined;
}
return parseGiteaRemoteUrl(originUrl);
}
async function readGitOriginUrl(cwd: string): Promise<string | undefined> {
const result = await execa("git", ["remote", "get-url", "origin"], {
cwd,
reject: false,
});
if (result.exitCode !== 0) {
return undefined;
}
return result.stdout.trim() || undefined;
}
function parseRepoSpec(spec: string | undefined): GiteaRepo | undefined {
const value = firstValue(spec);
if (!value) {
return undefined;
}
const [owner, repo] = value.split("/", 2);
if (!owner || !repo) {
return undefined;
}
return { owner, repo: trimGitSuffix(repo) };
}
function parseGiteaRemoteUrl(remoteUrl: string): GiteaRepo | undefined {
try {
const url = new URL(remoteUrl);
if (url.hostname === "gitea.websimple.com") {
return parseRepoSpec(url.pathname.replace(/^\/+/, ""));
}
} catch {
// Not a URL-form remote; try scp-style below.
}
const urlMatch = remoteUrl.match(
/gitea\.websimple\.com[:/](?<owner>[^/]+)\/(?<repo>[^/]+?)(?:\.git)?$/,
);
if (urlMatch?.groups) {
return {
owner: urlMatch.groups.owner,
repo: trimGitSuffix(urlMatch.groups.repo),
};
}
return undefined;
}
async function fetchGiteaVariables(
baseUrl: string,
token: string,
path: string,
optional: boolean,
): Promise<GiteaVariable[]> {
const variables: GiteaVariable[] = [];
for (let page = 1; page <= 100; page += 1) {
const url = new URL(path, baseUrl);
url.searchParams.set("page", String(page));
url.searchParams.set("limit", "100");
const response = await fetch(url, {
headers: {
Accept: "application/json",
Authorization: `token ${token}`,
},
});
if (response.status === 404 && optional) {
return variables;
}
if (!response.ok) {
throw new Error(`Gitea API request failed (${response.status}) while reading ${path}`);
}
const pageVariables = (await response.json()) as GiteaVariable[];
variables.push(...pageVariables);
if (pageVariables.length < 100) {
return variables;
}
}
throw new Error("Gitea Actions variables pagination exceeded safety limit");
}
function variablesToEnv(variables: GiteaVariable[]): Partial<Record<GiteaVariableKey, string>> {
const env: Partial<Record<GiteaVariableKey, string>> = {};
for (const variable of variables) {
if (!isGiteaVariableKey(variable.name)) {
continue;
}
const value = firstValue(variable.data, variable.value);
if (value) {
env[variable.name] = value;
}
}
return env;
}
function isGiteaVariableKey(value: string | undefined): value is GiteaVariableKey {
return giteaVariableKeys.includes(value as GiteaVariableKey);
}
function firstValue(...values: (string | undefined)[]): string | undefined {
for (const value of values) {
const trimmed = value?.trim();
if (trimmed) {
return trimmed;
}
}
return undefined;
}
function hasValue(value: string | undefined): boolean {
return Boolean(firstValue(value));
}
function trimGitSuffix(value: string): string {
return value.endsWith(".git") ? value.slice(0, -4) : value;
} }

129
src/lib/remote.ts Normal file
View File

@@ -0,0 +1,129 @@
import type { WPopContext } from "./context";
import { run } from "./run";
import { type PreparedSsh, rsyncSshShell, sshArgs, sshTarget } from "./ssh";
const SSH_RUN_BEGIN = "__WPOP_SSH_RUN_BEGIN__";
const SSH_RUN_END = "__WPOP_SSH_RUN_END__";
export async function rsync(context: WPopContext, ssh: PreparedSsh, args: string[]): Promise<void> {
const result = await run(context, "rsync", ["-az", "-e", rsyncSshShell(ssh), ...args], {
capture: true,
reject: false,
});
if (context.dryRun) {
return;
}
const output = filterRemoteBanner([result.stdout, result.stderr].filter(Boolean).join("\n"));
if (context.verbose && output) {
process.stdout.write(`${output}\n`);
}
if (result.exitCode !== 0) {
if (!context.verbose && output) {
process.stderr.write(`${output}\n`);
}
throw new Error(`rsync failed with exit code ${result.exitCode}`);
}
}
export async function sshRun(
context: WPopContext,
ssh: PreparedSsh,
script: string,
): Promise<void> {
const result = await run(context, "ssh", [...sshArgs(ssh), sshTarget(ssh)], {
stdin: wrapRemoteScriptOutput(script),
capture: true,
reject: false,
});
if (context.dryRun) {
return;
}
const output = parseMarkedOutput(
result.stdout,
SSH_RUN_BEGIN,
SSH_RUN_END,
"remote command output",
);
if (context.verbose && output.length > 0) {
process.stdout.write(`${output.join("\n")}\n`);
}
if (result.exitCode !== 0) {
if (!context.verbose && output.length > 0) {
process.stderr.write(`${output.join("\n")}\n`);
}
throw new Error(`Remote SSH command failed with exit code ${result.exitCode}`);
}
}
export async function sshOutput(
context: WPopContext,
ssh: PreparedSsh,
script: string,
): Promise<{ stdout: string }> {
return run(context, "ssh", [...sshArgs(ssh), sshTarget(ssh)], {
stdin: script,
capture: true,
});
}
export function parseMarkedOutput(
stdout: string,
beginMarker: string,
endMarker: string,
label: string,
): string[] {
const output = stdout.split("\n").map((line) => line.trim());
const begin = output.indexOf(beginMarker);
const end = output.indexOf(endMarker);
if (begin === -1 || end === -1 || end < begin) {
throw new Error(`Could not parse ${label} from SSH output`);
}
return output.slice(begin + 1, end).filter(Boolean);
}
function wrapRemoteScriptOutput(script: string): string {
return `printf '%s\\n' ${JSON.stringify(SSH_RUN_BEGIN)}
(
${script}
) 2>&1
status=$?
printf '%s\\n' ${JSON.stringify(SSH_RUN_END)}
exit "$status"
`;
}
function filterRemoteBanner(output: string): string {
const lines = output.split("\n");
const filtered: string[] = [];
let skippingBanner = false;
for (const line of lines) {
if (line.includes("This server is managed by Ansible and Cloud-init.")) {
skippingBanner = true;
continue;
}
if (skippingBanner) {
if (line.trim().startsWith("Last deployment:")) {
skippingBanner = false;
}
continue;
}
filtered.push(line);
}
return filtered
.map((line) => line.trimEnd())
.filter((line, index, all) => line.trim() || (index > 0 && index < all.length - 1))
.join("\n")
.trim();
}

View File

@@ -1,16 +1,17 @@
import { consola } from "consola"; import { consola } from "consola";
import { execa } from "execa"; import { execa, type ExecaError } from "execa";
import type { WPopContext } from "./context"; import type { WPopContext } from "./context";
export type RunOptions = { export type RunOptions = {
cwd?: string; cwd?: string;
env?: NodeJS.ProcessEnv; env?: NodeJS.ProcessEnv;
reject?: boolean;
stdin?: string; stdin?: string;
}; };
export type CaptureOptions = RunOptions & { capture: true }; export type CaptureOptions = RunOptions & { capture: true };
export type CaptureResult = { stdout: string }; export type CaptureResult = { exitCode: number; stderr: string; stdout: string };
export async function run( export async function run(
context: WPopContext, context: WPopContext,
@@ -35,24 +36,50 @@ export async function run(
if (context.dryRun) { if (context.dryRun) {
consola.info(`[dry-run] ${printable}`); consola.info(`[dry-run] ${printable}`);
return capture ? { stdout: "" } : undefined; return capture ? { exitCode: 0, stderr: "", stdout: "" } : undefined;
} }
consola.debug(printable); consola.debug(printable);
if (capture) { if (capture) {
const { stdout } = await execa(command, args, { const result = await execa(command, args, {
cwd: options.cwd ?? context.cwd, cwd: options.cwd ?? context.cwd,
env: options.env, env: options.env,
input: options.stdin, input: options.stdin,
reject: options.reject,
}); });
return { stdout }; return { exitCode: result.exitCode ?? 0, stderr: result.stderr, stdout: result.stdout };
} }
if (context.verbose) {
await execa(command, args, { await execa(command, args, {
cwd: options.cwd ?? context.cwd, cwd: options.cwd ?? context.cwd,
env: options.env, env: options.env,
input: options.stdin, input: options.stdin,
stdio: options.stdin ? ["pipe", "inherit", "inherit"] : "inherit", stdio: options.stdin ? ["pipe", "inherit", "inherit"] : "inherit",
}); });
return;
}
try {
await execa(command, args, {
cwd: options.cwd ?? context.cwd,
env: options.env,
input: options.stdin,
});
} catch (error) {
printProcessFailureOutput(error);
throw error;
}
}
function printProcessFailureOutput(error: unknown): void {
const processError = error as Partial<ExecaError>;
const output = [processError.stdout, processError.stderr]
.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
.join("\n");
if (output) {
process.stderr.write(`${output}\n`);
}
} }

View File

@@ -61,12 +61,15 @@ export function sshTarget(config: PreparedSsh): string {
export function sshArgs(config: PreparedSsh): string[] { export function sshArgs(config: PreparedSsh): string[] {
const args = [ const args = [
"-T",
"-p", "-p",
String(config.port), String(config.port),
"-o", "-o",
`UserKnownHostsFile=${config.knownHostsFile}`, `UserKnownHostsFile=${config.knownHostsFile}`,
"-o", "-o",
"GlobalKnownHostsFile=/dev/null", "GlobalKnownHostsFile=/dev/null",
"-o",
"LogLevel=ERROR",
]; ];
if (config.identityFile) { if (config.identityFile) {