feat: enhance deployment process with improved error handling and logging
This commit is contained in:
19
src/cli.ts
19
src/cli.ts
@@ -2,6 +2,7 @@
|
|||||||
import { Command } from "commander";
|
import { Command } from "commander";
|
||||||
import { deploy } from "./commands/deploy";
|
import { deploy } from "./commands/deploy";
|
||||||
import { createContext } from "./lib/context";
|
import { createContext } from "./lib/context";
|
||||||
|
import { configureLogger, emitJson } from "./lib/output";
|
||||||
import pkg from "../package.json" with { type: "json" };
|
import pkg from "../package.json" with { type: "json" };
|
||||||
|
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
@@ -26,7 +27,23 @@ program
|
|||||||
.option("--skip-composer", "skip Composer dependency installation")
|
.option("--skip-composer", "skip Composer dependency installation")
|
||||||
.option("--skip-node", "skip theme asset builds")
|
.option("--skip-node", "skip theme asset builds")
|
||||||
.action(async (options: { include?: string; skipComposer?: boolean; skipNode?: boolean }) => {
|
.action(async (options: { include?: string; skipComposer?: boolean; skipNode?: boolean }) => {
|
||||||
await deploy(createContext(program.opts()), options);
|
const context = createContext(program.opts());
|
||||||
|
configureLogger(context);
|
||||||
|
try {
|
||||||
|
await deploy(context, options);
|
||||||
|
} catch (error) {
|
||||||
|
handleError(context, error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function handleError(context: { json: boolean }, error: unknown): void {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
if (context.json) {
|
||||||
|
emitJson({ ok: false, error: message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
process.stderr.write(`Error: ${message}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
program.parse();
|
program.parse();
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, statSync } from "node:fs";
|
import { existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { consola } from "consola";
|
import { consola } from "consola";
|
||||||
import { execa } from "execa";
|
import prompts from "prompts";
|
||||||
import type { WPopContext } from "../lib/context";
|
import type { WPopContext } from "../lib/context";
|
||||||
import { readDeployEnv } from "../lib/env";
|
import { readDeployEnv, type DeployEnv } from "../lib/env";
|
||||||
|
import { emitJson } from "../lib/output";
|
||||||
import { run } from "../lib/run";
|
import { run } from "../lib/run";
|
||||||
import {
|
import {
|
||||||
createSshConfig,
|
createSshConfig,
|
||||||
@@ -14,11 +14,53 @@ import {
|
|||||||
sshTarget,
|
sshTarget,
|
||||||
type PreparedSsh,
|
type PreparedSsh,
|
||||||
} from "../lib/ssh";
|
} from "../lib/ssh";
|
||||||
|
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;
|
||||||
const ALL_COMPONENTS = ["core", ...DEFAULT_COMPONENTS] as const;
|
const ALL_COMPONENTS = ["core", ...DEFAULT_COMPONENTS] as const;
|
||||||
const CONTENT_COMPONENTS = ["plugins", "themes", "mu-plugins"] as const;
|
const CONTENT_COMPONENTS = ["plugins", "themes", "mu-plugins"] as const;
|
||||||
|
|
||||||
|
const CACHE_BUCKETS = {
|
||||||
|
composer: "COMPOSER_CACHE_DIR",
|
||||||
|
npm: "npm_config_cache",
|
||||||
|
pnpm: "PNPM_STORE_DIR",
|
||||||
|
yarn: "YARN_CACHE_FOLDER",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const CORE_RSYNC_EXCLUDES = [
|
||||||
|
"wp-config.php",
|
||||||
|
"wp-content/",
|
||||||
|
".htaccess",
|
||||||
|
".user.ini",
|
||||||
|
"php.ini",
|
||||||
|
"robots.txt",
|
||||||
|
".well-known/",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type PackageManager = {
|
||||||
|
lockfile: string;
|
||||||
|
install: readonly string[];
|
||||||
|
build: readonly string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const PACKAGE_MANAGERS: readonly PackageManager[] = [
|
||||||
|
{
|
||||||
|
lockfile: "pnpm-lock.yaml",
|
||||||
|
install: ["pnpm", "install", "--frozen-lockfile", "--silent"],
|
||||||
|
build: ["pnpm", "build"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lockfile: "yarn.lock",
|
||||||
|
install: ["yarn", "install", "--frozen-lockfile", "--silent"],
|
||||||
|
build: ["yarn", "build"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lockfile: "package-lock.json",
|
||||||
|
install: ["npm", "ci", "--no-audit", "--loglevel=error"],
|
||||||
|
build: ["npm", "run", "build"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export type DeployComponent = (typeof ALL_COMPONENTS)[number];
|
export type DeployComponent = (typeof ALL_COMPONENTS)[number];
|
||||||
|
|
||||||
export type DeployOptions = {
|
export type DeployOptions = {
|
||||||
@@ -27,47 +69,55 @@ export type DeployOptions = {
|
|||||||
skipNode?: boolean;
|
skipNode?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type DeployReport = {
|
||||||
|
command: "deploy";
|
||||||
|
cwd: string;
|
||||||
|
include: DeployComponent[];
|
||||||
|
remote: { host: string; port: number; user: string; path: string };
|
||||||
|
cacheDir: string;
|
||||||
|
dryRun: boolean;
|
||||||
|
steps: string[];
|
||||||
|
};
|
||||||
|
|
||||||
export async function deploy(context: WPopContext, options: DeployOptions): Promise<void> {
|
export async function deploy(context: WPopContext, options: DeployOptions): Promise<void> {
|
||||||
const env = readDeployEnv();
|
const env = readDeployEnv();
|
||||||
const components = parseComponents(options.include);
|
const components = parseComponents(options.include);
|
||||||
const commandEnv = createCommandEnv(env.WPOP_CACHE_DIR);
|
const commandEnv = createCommandEnv(env.WPOP_CACHE_DIR);
|
||||||
|
const report = buildReport(context, env, components);
|
||||||
|
|
||||||
if (context.json) {
|
if (context.json) {
|
||||||
console.log(
|
if (!context.yes && !context.dryRun) {
|
||||||
JSON.stringify(
|
throw new Error("--json requires --yes (cannot prompt for confirmation in JSON mode)");
|
||||||
{
|
}
|
||||||
command: "deploy",
|
} else {
|
||||||
cwd: context.cwd,
|
consola.info(
|
||||||
include: [...components],
|
`Planning deploy of ${[...components].join(", ")} to ${sshTargetSummary(env)}:${env.REMOTE_PATH}`,
|
||||||
remote: {
|
|
||||||
host: env.REMOTE_HOST,
|
|
||||||
port: env.REMOTE_PORT,
|
|
||||||
user: env.REMOTE_USER,
|
|
||||||
path: env.REMOTE_PATH,
|
|
||||||
},
|
|
||||||
cacheDir: env.WPOP_CACHE_DIR,
|
|
||||||
dryRun: context.dryRun,
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
if (context.dryRun) {
|
||||||
|
consola.info("Dry-run: no remote changes will be made");
|
||||||
|
}
|
||||||
|
if (!(await confirm(context, components, env))) {
|
||||||
|
consola.warn("Aborted.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ensureCacheDirs(env.WPOP_CACHE_DIR);
|
ensureCacheDirs(env.WPOP_CACHE_DIR);
|
||||||
const ssh = await prepareSsh(context, createSshConfig(env));
|
const ssh = await prepareSsh(context, createSshConfig(env), env.WPOP_CACHE_DIR);
|
||||||
|
|
||||||
await installComposerDependencies(context, options, commandEnv);
|
await installComposerDependencies(context, options, commandEnv, report);
|
||||||
await buildThemes(context, options, commandEnv);
|
await buildThemes(context, options, commandEnv, report);
|
||||||
await checkRemoteContentDrift(context, env, ssh, components);
|
await checkRemoteContentDrift(context, env, ssh, components);
|
||||||
|
|
||||||
if (components.has("core")) {
|
if (components.has("core")) {
|
||||||
await syncCore(context, env, ssh, commandEnv);
|
await syncCore(context, env, ssh, commandEnv);
|
||||||
|
report.steps.push("sync:core");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (components.has("vendor")) {
|
if (components.has("vendor")) {
|
||||||
const syncedVendor = await syncVendor(context, env, ssh);
|
await syncVendor(context, env, ssh);
|
||||||
if (syncedVendor) {
|
report.steps.push("sync:vendor");
|
||||||
|
if (existsSync(join(context.cwd, "vendor"))) {
|
||||||
await checkRemoteComposerAutoload(context, env, ssh);
|
await checkRemoteComposerAutoload(context, env, ssh);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -75,10 +125,62 @@ export async function deploy(context: WPopContext, options: DeployOptions): Prom
|
|||||||
for (const component of CONTENT_COMPONENTS) {
|
for (const component of CONTENT_COMPONENTS) {
|
||||||
if (components.has(component)) {
|
if (components.has(component)) {
|
||||||
await syncContentComponent(context, env, ssh, component);
|
await syncContentComponent(context, env, ssh, component);
|
||||||
|
report.steps.push(`sync:${component}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await updateRemoteDatabase(context, env, ssh);
|
await updateRemoteDatabase(context, env, ssh);
|
||||||
|
report.steps.push("db:update");
|
||||||
|
|
||||||
|
if (context.json) {
|
||||||
|
emitJson(report);
|
||||||
|
} else {
|
||||||
|
consola.success("Deploy complete.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildReport(
|
||||||
|
context: WPopContext,
|
||||||
|
env: DeployEnv,
|
||||||
|
components: Set<DeployComponent>,
|
||||||
|
): DeployReport {
|
||||||
|
return {
|
||||||
|
command: "deploy",
|
||||||
|
cwd: context.cwd,
|
||||||
|
include: [...components],
|
||||||
|
remote: {
|
||||||
|
host: env.REMOTE_HOST,
|
||||||
|
port: env.REMOTE_PORT,
|
||||||
|
user: env.REMOTE_USER,
|
||||||
|
path: env.REMOTE_PATH,
|
||||||
|
},
|
||||||
|
cacheDir: env.WPOP_CACHE_DIR,
|
||||||
|
dryRun: context.dryRun,
|
||||||
|
steps: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sshTargetSummary(env: DeployEnv): string {
|
||||||
|
return `${env.REMOTE_USER}@${env.REMOTE_HOST}:${env.REMOTE_PORT}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirm(
|
||||||
|
context: WPopContext,
|
||||||
|
components: Set<DeployComponent>,
|
||||||
|
env: DeployEnv,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (context.yes || context.dryRun) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await prompts({
|
||||||
|
type: "confirm",
|
||||||
|
name: "confirmed",
|
||||||
|
message: `Deploy ${[...components].join(",")} to ${sshTargetSummary(env)}:${env.REMOTE_PATH}?`,
|
||||||
|
initial: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return Boolean(response.confirmed);
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseComponents(include?: string): Set<DeployComponent> {
|
function parseComponents(include?: string): Set<DeployComponent> {
|
||||||
@@ -95,7 +197,7 @@ function parseComponents(include?: string): Set<DeployComponent> {
|
|||||||
.map((component) => component.trim())
|
.map((component) => component.trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
const invalid = components.filter(
|
const invalid = components.filter(
|
||||||
(component): component is string => !ALL_COMPONENTS.includes(component as DeployComponent),
|
(component) => !ALL_COMPONENTS.includes(component as DeployComponent),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (invalid.length > 0) {
|
if (invalid.length > 0) {
|
||||||
@@ -106,19 +208,16 @@ function parseComponents(include?: string): Set<DeployComponent> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createCommandEnv(cacheDir: string): NodeJS.ProcessEnv {
|
function createCommandEnv(cacheDir: string): NodeJS.ProcessEnv {
|
||||||
return {
|
const overrides: NodeJS.ProcessEnv = { WP_CLI_ALLOW_ROOT: "1" };
|
||||||
...process.env,
|
for (const [bucket, envVar] of Object.entries(CACHE_BUCKETS)) {
|
||||||
COMPOSER_CACHE_DIR: join(cacheDir, "composer"),
|
overrides[envVar] = join(cacheDir, bucket);
|
||||||
npm_config_cache: join(cacheDir, "npm"),
|
}
|
||||||
PNPM_STORE_DIR: join(cacheDir, "pnpm"),
|
return { ...process.env, ...overrides };
|
||||||
YARN_CACHE_FOLDER: join(cacheDir, "yarn"),
|
|
||||||
WP_CLI_ALLOW_ROOT: "1",
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureCacheDirs(cacheDir: string): void {
|
function ensureCacheDirs(cacheDir: string): void {
|
||||||
for (const child of ["composer", "npm", "pnpm", "yarn"]) {
|
for (const bucket of Object.keys(CACHE_BUCKETS)) {
|
||||||
mkdirSync(join(cacheDir, child), { recursive: true });
|
mkdirSync(join(cacheDir, bucket), { recursive: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,6 +225,7 @@ async function installComposerDependencies(
|
|||||||
context: WPopContext,
|
context: WPopContext,
|
||||||
options: DeployOptions,
|
options: DeployOptions,
|
||||||
commandEnv: NodeJS.ProcessEnv,
|
commandEnv: NodeJS.ProcessEnv,
|
||||||
|
report: DeployReport,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (options.skipComposer) {
|
if (options.skipComposer) {
|
||||||
consola.info("Skipping Composer installation");
|
consola.info("Skipping Composer installation");
|
||||||
@@ -144,12 +244,14 @@ async function installComposerDependencies(
|
|||||||
["install", "--no-dev", "--no-interaction", "--optimize-autoloader", "--prefer-dist"],
|
["install", "--no-dev", "--no-interaction", "--optimize-autoloader", "--prefer-dist"],
|
||||||
{ env: commandEnv },
|
{ env: commandEnv },
|
||||||
);
|
);
|
||||||
|
report.steps.push("composer:install");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function buildThemes(
|
async function buildThemes(
|
||||||
context: WPopContext,
|
context: WPopContext,
|
||||||
options: DeployOptions,
|
options: DeployOptions,
|
||||||
commandEnv: NodeJS.ProcessEnv,
|
commandEnv: NodeJS.ProcessEnv,
|
||||||
|
report: DeployReport,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (options.skipNode) {
|
if (options.skipNode) {
|
||||||
consola.info("Skipping theme builds");
|
consola.info("Skipping theme builds");
|
||||||
@@ -171,38 +273,21 @@ async function buildThemes(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
consola.info(`Building theme ${entry.name}`);
|
const manager = PACKAGE_MANAGERS.find((pm) => existsSync(join(themeDir, pm.lockfile)));
|
||||||
if (existsSync(join(themeDir, "pnpm-lock.yaml"))) {
|
const install = manager?.install ?? PACKAGE_MANAGERS[2].install;
|
||||||
await run(
|
const build = manager?.build ?? PACKAGE_MANAGERS[2].build;
|
||||||
context,
|
|
||||||
"pnpm",
|
consola.info(`Building theme ${entry.name} (${install[0]})`);
|
||||||
[
|
|
||||||
"install",
|
const installArgs = install.slice(1);
|
||||||
"--frozen-lockfile",
|
if (install[0] === "pnpm" && commandEnv.PNPM_STORE_DIR) {
|
||||||
"--silent",
|
installArgs.push("--store-dir", commandEnv.PNPM_STORE_DIR);
|
||||||
"--store-dir",
|
|
||||||
commandEnv.PNPM_STORE_DIR ?? "",
|
|
||||||
],
|
|
||||||
{
|
|
||||||
cwd: themeDir,
|
|
||||||
env: commandEnv,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
await run(context, "pnpm", ["build"], { cwd: themeDir, env: commandEnv });
|
|
||||||
} else if (existsSync(join(themeDir, "yarn.lock"))) {
|
|
||||||
await run(context, "yarn", ["install", "--frozen-lockfile", "--silent"], {
|
|
||||||
cwd: themeDir,
|
|
||||||
env: commandEnv,
|
|
||||||
});
|
|
||||||
await run(context, "yarn", ["build"], { cwd: themeDir, env: commandEnv });
|
|
||||||
} else {
|
|
||||||
await run(context, "npm", ["ci", "--no-audit", "--loglevel=error"], {
|
|
||||||
cwd: themeDir,
|
|
||||||
env: commandEnv,
|
|
||||||
});
|
|
||||||
await run(context, "npm", ["run", "build"], { cwd: themeDir, env: commandEnv });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await run(context, install[0], installArgs, { cwd: themeDir, env: commandEnv });
|
||||||
|
await run(context, build[0], build.slice(1), { cwd: themeDir, env: commandEnv });
|
||||||
|
report.steps.push(`theme:${entry.name}`);
|
||||||
|
|
||||||
if (!context.dryRun) {
|
if (!context.dryRun) {
|
||||||
rmSync(join(themeDir, "node_modules"), { force: true, recursive: true });
|
rmSync(join(themeDir, "node_modules"), { force: true, recursive: true });
|
||||||
}
|
}
|
||||||
@@ -211,7 +296,7 @@ async function buildThemes(
|
|||||||
|
|
||||||
async function checkRemoteContentDrift(
|
async function checkRemoteContentDrift(
|
||||||
context: WPopContext,
|
context: WPopContext,
|
||||||
env: ReturnType<typeof readDeployEnv>,
|
env: DeployEnv,
|
||||||
ssh: PreparedSsh,
|
ssh: PreparedSsh,
|
||||||
components: Set<DeployComponent>,
|
components: Set<DeployComponent>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@@ -249,10 +334,12 @@ function listLocalTopLevelDirs(path: string): string[] {
|
|||||||
|
|
||||||
async function listRemoteTopLevelDirs(
|
async function listRemoteTopLevelDirs(
|
||||||
context: WPopContext,
|
context: WPopContext,
|
||||||
env: ReturnType<typeof readDeployEnv>,
|
env: DeployEnv,
|
||||||
ssh: PreparedSsh,
|
ssh: PreparedSsh,
|
||||||
path: string,
|
path: string,
|
||||||
): Promise<string[]> {
|
): Promise<string[]> {
|
||||||
|
// Paths flow through env vars and are quoted with JSON.stringify, which is
|
||||||
|
// close enough to POSIX double-quote escaping for the values we handle here.
|
||||||
const script = `set -e
|
const script = `set -e
|
||||||
cd ${JSON.stringify(env.REMOTE_PATH)}
|
cd ${JSON.stringify(env.REMOTE_PATH)}
|
||||||
if [ -d ${JSON.stringify(path)} ]; then
|
if [ -d ${JSON.stringify(path)} ]; then
|
||||||
@@ -260,7 +347,7 @@ if [ -d ${JSON.stringify(path)} ]; then
|
|||||||
fi
|
fi
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const stdout = await sshOutput(context, ssh, script);
|
const { stdout } = await sshOutput(context, ssh, script);
|
||||||
return stdout
|
return stdout
|
||||||
.split("\n")
|
.split("\n")
|
||||||
.map((line) => line.trim())
|
.map((line) => line.trim())
|
||||||
@@ -269,13 +356,11 @@ fi
|
|||||||
|
|
||||||
async function syncCore(
|
async function syncCore(
|
||||||
context: WPopContext,
|
context: WPopContext,
|
||||||
env: ReturnType<typeof readDeployEnv>,
|
env: DeployEnv,
|
||||||
ssh: PreparedSsh,
|
ssh: PreparedSsh,
|
||||||
commandEnv: NodeJS.ProcessEnv,
|
commandEnv: NodeJS.ProcessEnv,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const coreDir = context.dryRun
|
const { path: coreDir, cleanup } = createTempDir(context, "wpop-core");
|
||||||
? join(tmpdir(), "wpop-core")
|
|
||||||
: mkdtempSync(join(tmpdir(), "wpop-core-"));
|
|
||||||
|
|
||||||
consola.info("Preparing WordPress core deploy payload");
|
consola.info("Preparing WordPress core deploy payload");
|
||||||
await run(
|
await run(
|
||||||
@@ -295,29 +380,15 @@ async function syncCore(
|
|||||||
consola.info("Synchronizing WordPress core");
|
consola.info("Synchronizing WordPress core");
|
||||||
await rsync(context, ssh, [
|
await rsync(context, ssh, [
|
||||||
"--delete",
|
"--delete",
|
||||||
"--exclude=wp-config.php",
|
...CORE_RSYNC_EXCLUDES.map((entry) => `--exclude=${entry}`),
|
||||||
"--exclude=wp-content/",
|
|
||||||
"--exclude=.htaccess",
|
|
||||||
"--exclude=.user.ini",
|
|
||||||
"--exclude=php.ini",
|
|
||||||
"--exclude=robots.txt",
|
|
||||||
"--exclude=.well-known/",
|
|
||||||
`${coreDir}/`,
|
`${coreDir}/`,
|
||||||
`${sshTarget(ssh)}:${env.REMOTE_PATH}/`,
|
`${sshTarget(ssh)}:${env.REMOTE_PATH}/`,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!context.dryRun) {
|
cleanup();
|
||||||
rmSync(coreDir, { force: true, recursive: true });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function syncVendor(
|
async function syncVendor(context: WPopContext, env: DeployEnv, ssh: PreparedSsh): Promise<void> {
|
||||||
context: WPopContext,
|
|
||||||
env: ReturnType<typeof readDeployEnv>,
|
|
||||||
ssh: PreparedSsh,
|
|
||||||
): Promise<boolean> {
|
|
||||||
let syncedVendor = false;
|
|
||||||
|
|
||||||
if (existsSync(join(context.cwd, "vendor"))) {
|
if (existsSync(join(context.cwd, "vendor"))) {
|
||||||
await ensureRemoteDirectory(context, env, ssh, "vendor");
|
await ensureRemoteDirectory(context, env, ssh, "vendor");
|
||||||
consola.info("Synchronizing vendor");
|
consola.info("Synchronizing vendor");
|
||||||
@@ -326,25 +397,22 @@ async function syncVendor(
|
|||||||
`${join(context.cwd, "vendor")}/`,
|
`${join(context.cwd, "vendor")}/`,
|
||||||
`${sshTarget(ssh)}:${env.REMOTE_PATH}/vendor/`,
|
`${sshTarget(ssh)}:${env.REMOTE_PATH}/vendor/`,
|
||||||
]);
|
]);
|
||||||
syncedVendor = true;
|
|
||||||
} else {
|
} else {
|
||||||
consola.warn("vendor directory not found, skipping vendor sync");
|
consola.warn("vendor directory not found, skipping vendor sync");
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const file of ["composer.json", "composer.lock"]) {
|
for (const file of ["composer.json", "composer.lock"]) {
|
||||||
const localPath = join(context.cwd, file);
|
const localPath = join(context.cwd, file);
|
||||||
if (existsSync(localPath) && statSync(localPath).isFile()) {
|
if (existsSync(localPath)) {
|
||||||
consola.info(`Synchronizing ${file}`);
|
consola.info(`Synchronizing ${file}`);
|
||||||
await rsync(context, ssh, [localPath, `${sshTarget(ssh)}:${env.REMOTE_PATH}/${file}`]);
|
await rsync(context, ssh, [localPath, `${sshTarget(ssh)}:${env.REMOTE_PATH}/${file}`]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return syncedVendor;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function checkRemoteComposerAutoload(
|
async function checkRemoteComposerAutoload(
|
||||||
context: WPopContext,
|
context: WPopContext,
|
||||||
env: ReturnType<typeof readDeployEnv>,
|
env: DeployEnv,
|
||||||
ssh: PreparedSsh,
|
ssh: PreparedSsh,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
consola.info("Checking remote Composer autoload inclusion");
|
consola.info("Checking remote Composer autoload inclusion");
|
||||||
@@ -359,7 +427,7 @@ grep -q 'vendor/autoload.php' wp-config.php
|
|||||||
|
|
||||||
async function syncContentComponent(
|
async function syncContentComponent(
|
||||||
context: WPopContext,
|
context: WPopContext,
|
||||||
env: ReturnType<typeof readDeployEnv>,
|
env: DeployEnv,
|
||||||
ssh: PreparedSsh,
|
ssh: PreparedSsh,
|
||||||
component: (typeof CONTENT_COMPONENTS)[number],
|
component: (typeof CONTENT_COMPONENTS)[number],
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@@ -386,7 +454,7 @@ function remoteContentPath(component: (typeof CONTENT_COMPONENTS)[number]): stri
|
|||||||
|
|
||||||
async function ensureRemoteDirectory(
|
async function ensureRemoteDirectory(
|
||||||
context: WPopContext,
|
context: WPopContext,
|
||||||
env: ReturnType<typeof readDeployEnv>,
|
env: DeployEnv,
|
||||||
ssh: PreparedSsh,
|
ssh: PreparedSsh,
|
||||||
path: string,
|
path: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@@ -395,7 +463,7 @@ async function ensureRemoteDirectory(
|
|||||||
|
|
||||||
async function updateRemoteDatabase(
|
async function updateRemoteDatabase(
|
||||||
context: WPopContext,
|
context: WPopContext,
|
||||||
env: ReturnType<typeof readDeployEnv>,
|
env: DeployEnv,
|
||||||
ssh: PreparedSsh,
|
ssh: PreparedSsh,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
consola.info("Updating remote database");
|
consola.info("Updating remote database");
|
||||||
@@ -439,15 +507,13 @@ async function sshRun(context: WPopContext, ssh: PreparedSsh, script: string): P
|
|||||||
await run(context, "ssh", [...sshArgs(ssh), sshTarget(ssh)], { stdin: script });
|
await run(context, "ssh", [...sshArgs(ssh), sshTarget(ssh)], { stdin: script });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sshOutput(context: WPopContext, ssh: PreparedSsh, script: string): Promise<string> {
|
async function sshOutput(
|
||||||
if (context.dryRun) {
|
context: WPopContext,
|
||||||
await sshRun(context, ssh, script);
|
ssh: PreparedSsh,
|
||||||
return "";
|
script: string,
|
||||||
}
|
): Promise<{ stdout: string }> {
|
||||||
|
return run(context, "ssh", [...sshArgs(ssh), sshTarget(ssh)], {
|
||||||
const { stdout } = await execa("ssh", [...sshArgs(ssh), sshTarget(ssh)], {
|
stdin: script,
|
||||||
input: script,
|
capture: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
return stdout;
|
|
||||||
}
|
}
|
||||||
|
|||||||
14
src/lib/output.ts
Normal file
14
src/lib/output.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { consola } from "consola";
|
||||||
|
import type { WPopContext } from "./context";
|
||||||
|
|
||||||
|
export function configureLogger(context: WPopContext): void {
|
||||||
|
if (context.json) {
|
||||||
|
consola.level = -999;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
consola.level = context.verbose ? 4 : 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emitJson(payload: unknown): void {
|
||||||
|
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { consola } from "consola";
|
||||||
import { execa } from "execa";
|
import { execa } from "execa";
|
||||||
import type { WPopContext } from "./context";
|
import type { WPopContext } from "./context";
|
||||||
|
|
||||||
@@ -7,17 +8,45 @@ export type RunOptions = {
|
|||||||
stdin?: string;
|
stdin?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type CaptureOptions = RunOptions & { capture: true };
|
||||||
|
|
||||||
|
export type CaptureResult = { stdout: string };
|
||||||
|
|
||||||
export async function run(
|
export async function run(
|
||||||
context: WPopContext,
|
context: WPopContext,
|
||||||
command: string,
|
command: string,
|
||||||
args: string[],
|
args: string[],
|
||||||
options: RunOptions = {},
|
options?: RunOptions,
|
||||||
): Promise<void> {
|
): Promise<void>;
|
||||||
|
export async function run(
|
||||||
|
context: WPopContext,
|
||||||
|
command: string,
|
||||||
|
args: string[],
|
||||||
|
options: CaptureOptions,
|
||||||
|
): Promise<CaptureResult>;
|
||||||
|
export async function run(
|
||||||
|
context: WPopContext,
|
||||||
|
command: string,
|
||||||
|
args: string[],
|
||||||
|
options: RunOptions | CaptureOptions = {},
|
||||||
|
): Promise<void | CaptureResult> {
|
||||||
const printable = [command, ...args].join(" ");
|
const printable = [command, ...args].join(" ");
|
||||||
|
const capture = "capture" in options && options.capture === true;
|
||||||
|
|
||||||
if (context.dryRun) {
|
if (context.dryRun) {
|
||||||
console.log(`[dry-run] ${printable}`);
|
consola.info(`[dry-run] ${printable}`);
|
||||||
return;
|
return capture ? { stdout: "" } : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
consola.debug(printable);
|
||||||
|
|
||||||
|
if (capture) {
|
||||||
|
const { stdout } = await execa(command, args, {
|
||||||
|
cwd: options.cwd ?? context.cwd,
|
||||||
|
env: options.env,
|
||||||
|
input: options.stdin,
|
||||||
|
});
|
||||||
|
return { stdout };
|
||||||
}
|
}
|
||||||
|
|
||||||
await execa(command, args, {
|
await execa(command, args, {
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { appendFileSync, mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
|
import { appendFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { consola } from "consola";
|
import { consola } from "consola";
|
||||||
import { execa } from "execa";
|
import { execa } from "execa";
|
||||||
import type { WPopContext } from "./context";
|
import type { WPopContext } from "./context";
|
||||||
import type { DeployEnv } from "./env";
|
import type { DeployEnv } from "./env";
|
||||||
import { run } from "./run";
|
import { run } from "./run";
|
||||||
|
import { createTempDir } from "./tempdir";
|
||||||
|
|
||||||
export type SshConfig = {
|
export type SshConfig = {
|
||||||
host: string;
|
host: string;
|
||||||
@@ -14,8 +14,12 @@ export type SshConfig = {
|
|||||||
privateKey?: string;
|
privateKey?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PreparedSsh = SshConfig & {
|
export type PreparedSsh = {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
user: string;
|
||||||
identityFile?: string;
|
identityFile?: string;
|
||||||
|
knownHostsFile: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createSshConfig(env: DeployEnv): SshConfig {
|
export function createSshConfig(env: DeployEnv): SshConfig {
|
||||||
@@ -27,29 +31,43 @@ export function createSshConfig(env: DeployEnv): SshConfig {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function prepareSsh(context: WPopContext, config: SshConfig): Promise<PreparedSsh> {
|
export async function prepareSsh(
|
||||||
|
context: WPopContext,
|
||||||
|
config: SshConfig,
|
||||||
|
cacheDir: string,
|
||||||
|
): Promise<PreparedSsh> {
|
||||||
|
const knownHostsFile = ensureKnownHostsFile(context, cacheDir);
|
||||||
|
|
||||||
const prepared: PreparedSsh = {
|
const prepared: PreparedSsh = {
|
||||||
host: config.host,
|
host: config.host,
|
||||||
port: config.port,
|
port: config.port,
|
||||||
user: config.user,
|
user: config.user,
|
||||||
|
knownHostsFile,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (config.privateKey) {
|
if (config.privateKey) {
|
||||||
prepared.identityFile = writePrivateKey(context, config.privateKey);
|
prepared.identityFile = writePrivateKey(context, config.privateKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
await addKnownHost(context, prepared);
|
await ensureKnownHost(context, prepared);
|
||||||
await verifySshAuth(context, prepared);
|
await verifySshAuth(context, prepared);
|
||||||
|
|
||||||
return prepared;
|
return prepared;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sshTarget(config: SshConfig): string {
|
export function sshTarget(config: PreparedSsh): string {
|
||||||
return `${config.user}@${config.host}`;
|
return `${config.user}@${config.host}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sshArgs(config: PreparedSsh): string[] {
|
export function sshArgs(config: PreparedSsh): string[] {
|
||||||
const args = ["-p", String(config.port)];
|
const args = [
|
||||||
|
"-p",
|
||||||
|
String(config.port),
|
||||||
|
"-o",
|
||||||
|
`UserKnownHostsFile=${config.knownHostsFile}`,
|
||||||
|
"-o",
|
||||||
|
"GlobalKnownHostsFile=/dev/null",
|
||||||
|
];
|
||||||
|
|
||||||
if (config.identityFile) {
|
if (config.identityFile) {
|
||||||
args.push("-i", config.identityFile);
|
args.push("-i", config.identityFile);
|
||||||
@@ -62,34 +80,66 @@ export function rsyncSshShell(config: PreparedSsh): string {
|
|||||||
return ["ssh", ...sshArgs(config)].join(" ");
|
return ["ssh", ...sshArgs(config)].join(" ");
|
||||||
}
|
}
|
||||||
|
|
||||||
function writePrivateKey(context: WPopContext, privateKey: string): string | undefined {
|
function ensureKnownHostsFile(context: WPopContext, cacheDir: string): string {
|
||||||
consola.info("Using SSH private key from SSH_PRIVATE_KEY");
|
const path = join(cacheDir, "known_hosts");
|
||||||
|
|
||||||
if (context.dryRun) {
|
if (context.dryRun) {
|
||||||
return "/tmp/wpop-ssh-key";
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdirSync(cacheDir, { recursive: true });
|
||||||
|
if (!existsSync(path)) {
|
||||||
|
writeFileSync(path, "", { mode: 0o600 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writePrivateKey(context: WPopContext, privateKey: string): string {
|
||||||
|
consola.info("Using SSH private key from SSH_PRIVATE_KEY");
|
||||||
|
|
||||||
|
const { path: keyDir, cleanup } = createTempDir(context, "wpop-ssh");
|
||||||
|
|
||||||
|
if (context.dryRun) {
|
||||||
|
return join(keyDir, "id_ed25519");
|
||||||
}
|
}
|
||||||
|
|
||||||
const keyDir = mkdtempSync(join(tmpdir(), "wpop-ssh-"));
|
|
||||||
const keyPath = join(keyDir, "id_ed25519");
|
const keyPath = join(keyDir, "id_ed25519");
|
||||||
writeFileSync(keyPath, privateKey.endsWith("\n") ? privateKey : `${privateKey}\n`, {
|
writeFileSync(keyPath, privateKey.endsWith("\n") ? privateKey : `${privateKey}\n`, {
|
||||||
mode: 0o600,
|
mode: 0o600,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
process.once("exit", cleanup);
|
||||||
|
|
||||||
return keyPath;
|
return keyPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addKnownHost(context: WPopContext, config: PreparedSsh): Promise<void> {
|
async function ensureKnownHost(context: WPopContext, config: PreparedSsh): Promise<void> {
|
||||||
consola.info(`Scanning SSH host key for ${config.host}`);
|
|
||||||
|
|
||||||
if (context.dryRun) {
|
if (context.dryRun) {
|
||||||
|
consola.info(`Ensuring SSH host key for ${config.host} is known`);
|
||||||
await run(context, "ssh-keyscan", ["-p", String(config.port), "-H", config.host]);
|
await run(context, "ssh-keyscan", ["-p", String(config.port), "-H", config.host]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (await hostInKnownHosts(config)) {
|
||||||
|
consola.debug(`Host ${config.host}:${config.port} already in known_hosts`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
consola.info(`Scanning SSH host key for ${config.host}`);
|
||||||
const { stdout } = await execa("ssh-keyscan", ["-p", String(config.port), "-H", config.host]);
|
const { stdout } = await execa("ssh-keyscan", ["-p", String(config.port), "-H", config.host]);
|
||||||
const sshDir = join(process.env.HOME ?? context.cwd, ".ssh");
|
appendFileSync(config.knownHostsFile, `${stdout}\n`);
|
||||||
mkdirSync(sshDir, { mode: 0o700, recursive: true });
|
}
|
||||||
appendFileSync(join(sshDir, "known_hosts"), `${stdout}\n`);
|
|
||||||
|
async function hostInKnownHosts(config: PreparedSsh): Promise<boolean> {
|
||||||
|
if (!existsSync(config.knownHostsFile)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const lookup = config.port === 22 ? config.host : `[${config.host}]:${config.port}`;
|
||||||
|
const result = await execa("ssh-keygen", ["-F", lookup, "-f", config.knownHostsFile], {
|
||||||
|
reject: false,
|
||||||
|
});
|
||||||
|
return result.exitCode === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function verifySshAuth(context: WPopContext, config: PreparedSsh): Promise<void> {
|
async function verifySshAuth(context: WPopContext, config: PreparedSsh): Promise<void> {
|
||||||
|
|||||||
24
src/lib/tempdir.ts
Normal file
24
src/lib/tempdir.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { mkdtempSync, rmSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import type { WPopContext } from "./context";
|
||||||
|
|
||||||
|
export type TempDir = {
|
||||||
|
path: string;
|
||||||
|
cleanup: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createTempDir(context: WPopContext, prefix: string): TempDir {
|
||||||
|
if (context.dryRun) {
|
||||||
|
return {
|
||||||
|
path: join(tmpdir(), prefix),
|
||||||
|
cleanup: () => {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = mkdtempSync(join(tmpdir(), `${prefix}-`));
|
||||||
|
return {
|
||||||
|
path,
|
||||||
|
cleanup: () => rmSync(path, { force: true, recursive: true }),
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user