Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e0d5abce65 | |||
| fedb05fee5 |
@@ -1,5 +1,13 @@
|
|||||||
# 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
|
## v0.0.4
|
||||||
|
|
||||||
[compare changes](https://gitea.websimple.com/pascalmartineau/wpop/compare/v0.0.3...v0.0.4)
|
[compare changes](https://gitea.websimple.com/pascalmartineau/wpop/compare/v0.0.3...v0.0.4)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@lewebsimple/wpop",
|
"name": "@lewebsimple/wpop",
|
||||||
"version": "0.0.4",
|
"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>",
|
||||||
|
|||||||
20
src/cli.ts
20
src/cli.ts
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -41,8 +35,6 @@ const REMOTE_LIST_BEGIN = "__WPOP_REMOTE_LIST_BEGIN__";
|
|||||||
const REMOTE_LIST_END = "__WPOP_REMOTE_LIST_END__";
|
const REMOTE_LIST_END = "__WPOP_REMOTE_LIST_END__";
|
||||||
const COMPOSER_AUTOLOAD_BEGIN = "__WPOP_COMPOSER_AUTOLOAD_BEGIN__";
|
const COMPOSER_AUTOLOAD_BEGIN = "__WPOP_COMPOSER_AUTOLOAD_BEGIN__";
|
||||||
const COMPOSER_AUTOLOAD_END = "__WPOP_COMPOSER_AUTOLOAD_END__";
|
const COMPOSER_AUTOLOAD_END = "__WPOP_COMPOSER_AUTOLOAD_END__";
|
||||||
const SSH_RUN_BEGIN = "__WPOP_SSH_RUN_BEGIN__";
|
|
||||||
const SSH_RUN_END = "__WPOP_SSH_RUN_END__";
|
|
||||||
|
|
||||||
type PackageManager = {
|
type PackageManager = {
|
||||||
lockfile: string;
|
lockfile: string;
|
||||||
@@ -671,122 +663,3 @@ fi
|
|||||||
|
|
||||||
await sshRun(context, ssh, script);
|
await sshRun(context, ssh, script);
|
||||||
}
|
}
|
||||||
|
|
||||||
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}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sshOutput(
|
|
||||||
context: WPopContext,
|
|
||||||
ssh: PreparedSsh,
|
|
||||||
script: string,
|
|
||||||
): Promise<{ stdout: string }> {
|
|
||||||
return run(context, "ssh", [...sshArgs(ssh), sshTarget(ssh)], {
|
|
||||||
stdin: script,
|
|
||||||
capture: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|||||||
426
src/commands/sync.ts
Normal file
426
src/commands/sync.ts
Normal 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;
|
||||||
|
}
|
||||||
129
src/lib/remote.ts
Normal file
129
src/lib/remote.ts
Normal 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();
|
||||||
|
}
|
||||||
@@ -68,6 +68,8 @@ export function sshArgs(config: PreparedSsh): string[] {
|
|||||||
`UserKnownHostsFile=${config.knownHostsFile}`,
|
`UserKnownHostsFile=${config.knownHostsFile}`,
|
||||||
"-o",
|
"-o",
|
||||||
"GlobalKnownHostsFile=/dev/null",
|
"GlobalKnownHostsFile=/dev/null",
|
||||||
|
"-o",
|
||||||
|
"LogLevel=ERROR",
|
||||||
];
|
];
|
||||||
|
|
||||||
if (config.identityFile) {
|
if (config.identityFile) {
|
||||||
|
|||||||
Reference in New Issue
Block a user