feat: enhance deployment process with improved error handling and logging

This commit is contained in:
2026-05-07 16:34:21 -04:00
parent ab0da30e78
commit e3ac72906d
6 changed files with 332 additions and 132 deletions

View File

@@ -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();

View File

@@ -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
View 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`);
}

View File

@@ -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, {

View File

@@ -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
View 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 }),
};
}