9 Commits

Author SHA1 Message Date
aad5f78aba chore(release): v0.0.9 2026-06-11 14:28:34 -04:00
ebdaf110d0 feat: wpop destroy 2026-06-11 14:28:25 -04:00
9bd489e0c0 chore(release): v0.0.8 2026-06-11 14:14:28 -04:00
9b7cb13e65 feat: wpop provisino 2026-06-11 14:13:56 -04:00
b8973559ca chore(release): v0.0.7 2026-05-29 08:55:02 -04:00
f6485830b8 fix: local site url from env + cwd 2026-05-29 08:54:30 -04:00
b9a06880b4 chore(release): v0.0.6 2026-05-27 08:54:11 -04:00
e0d5abce65 chore(release): v0.0.5 2026-05-27 08:53:49 -04:00
fedb05fee5 feat: wpop sync 2026-05-27 08:37:11 -04:00
12 changed files with 1297 additions and 140 deletions

View File

@@ -1,5 +1,41 @@
# Changelog # Changelog
## v0.0.9
[compare changes](https://gitea.websimple.com/pascalmartineau/wpop/compare/v0.0.8...v0.0.9)
### 🚀 Enhancements
- Wpop destroy (ebdaf11)
## v0.0.8
[compare changes](https://gitea.websimple.com/pascalmartineau/wpop/compare/v0.0.7...v0.0.8)
### 🚀 Enhancements
- Wpop provisino (9b7cb13)
## v0.0.7
[compare changes](https://gitea.websimple.com/pascalmartineau/wpop/compare/v0.0.6...v0.0.7)
### 🩹 Fixes
- Local site url from env + cwd (f648583)
## v0.0.6
[compare changes](https://gitea.websimple.com/pascalmartineau/wpop/compare/v0.0.5...v0.0.6)
## 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)

View File

@@ -22,12 +22,13 @@ Package manager is pnpm@10 (declared via `packageManager`); husky + lint-staged
## Architecture ## Architecture
Single command today (`deploy`), but the layout assumes more will be added. Commands today: `deploy` (local → remote), `sync` (remote → local), `provision` (bootstrap a local site), and `destroy` (tear down a local site). The layout assumes more will be added.
- `src/cli.ts` — commander entry. Defines global flags (`--cwd`, `--dry-run`, `--json`, `--yes`, `--verbose`) on the root program and registers subcommands. Each subcommand action calls `createContext(program.opts())` and passes the context as the first argument to its handler. New commands should follow this pattern: register in `cli.ts`, implement in `src/commands/<name>.ts`, take `(context, options)`. - `src/cli.ts` — commander entry. Defines global flags (`--cwd`, `--dry-run`, `--json`, `--yes`, `--verbose`) on the root program and registers subcommands. Each subcommand action calls `createContext(program.opts())` and passes the context as the first argument to its handler. New commands should follow this pattern: register in `cli.ts`, implement in `src/commands/<name>.ts`, take `(context, options)`.
- `src/lib/context.ts``WPopContext` carries the resolved cwd and the global flags. Every side-effecting helper takes a context; nothing reads `process.cwd()` or the flags directly. - `src/lib/context.ts``WPopContext` carries the resolved cwd and the global flags. Every side-effecting helper takes a context; nothing reads `process.cwd()` or the flags directly.
- `src/lib/run.ts``run(context, cmd, args, opts)` is the single chokepoint for spawning processes via `execa`. **In dry-run mode it logs and returns without executing.** Any new shell-out must go through `run` (or use `execa` directly only when capturing stdout, and in that case branch on `context.dryRun` like `sshOutput` in `deploy.ts`). - `src/lib/run.ts``run(context, cmd, args, opts)` is the single chokepoint for spawning processes via `execa`. **In dry-run mode it logs and returns without executing.** Any new shell-out must go through `run` (or use `execa` directly only when capturing stdout, and in that case branch on `context.dryRun` like `sshOutput` in `deploy.ts`).
- `src/lib/env.ts` — Zod schema and resolution for deploy-time env vars (`REMOTE_HOST`, `REMOTE_USER`, `REMOTE_PATH`, `REMOTE_PORT`, `SSH_PRIVATE_KEY`, `WPOP_CACHE_DIR`, `WP_VERSION`, `WP_LOCALE`). Schema is parsed lazily inside the command, not at import. If any `REMOTE_*` value is missing, it can read Gitea Actions variables from the inferred repo/org using `WEBSIMPLE_GITEA_API_TOKEN`, `WPOP_GITEA_TOKEN`, or `GITEA_TOKEN`; repo inference comes from `git remote get-url origin` and can be overridden with `WPOP_GITEA_REPO` or `WPOP_GITEA_OWNER` + `WPOP_GITEA_REPO_NAME`. - `src/lib/env.ts` — Zod schema and resolution for deploy-time env vars (`REMOTE_HOST`, `REMOTE_USER`, `REMOTE_PATH`, `REMOTE_PORT`, `SSH_PRIVATE_KEY`, `WPOP_CACHE_DIR`, `WP_VERSION`, `WP_LOCALE`). Schema is parsed lazily inside the command, not at import. If any `REMOTE_*` value is missing, it can read Gitea Actions variables from the inferred repo/org using `WEBSIMPLE_GITEA_API_TOKEN`, `WPOP_GITEA_TOKEN`, or `GITEA_TOKEN`; repo inference comes from `git remote get-url origin` and can be overridden with `WPOP_GITEA_REPO` or `WPOP_GITEA_OWNER` + `WPOP_GITEA_REPO_NAME`.
- `src/lib/gitea.ts` — shared Gitea API primitives: `getGiteaToken`/`getGiteaBaseUrl` (env resolution, used by both `env.ts` and `provision`), plus `giteaRepoExists` (GET repo) and `generateGiteaRepo` (POST `/generate` from a template repo).
- `src/lib/ssh.ts` — builds `PreparedSsh` from env: optionally writes `SSH_PRIVATE_KEY` to a 0600 tempfile, runs `ssh-keyscan` to populate `~/.ssh/known_hosts`, then verifies access with `ssh -o BatchMode=yes`. `sshArgs`/`sshTarget`/`rsyncSshShell` are used to construct ssh and rsync invocations consistently. - `src/lib/ssh.ts` — builds `PreparedSsh` from env: optionally writes `SSH_PRIVATE_KEY` to a 0600 tempfile, runs `ssh-keyscan` to populate `~/.ssh/known_hosts`, then verifies access with `ssh -o BatchMode=yes`. `sshArgs`/`sshTarget`/`rsyncSshShell` are used to construct ssh and rsync invocations consistently.
- `src/commands/deploy.ts` — orchestrates the deploy. Order matters: - `src/commands/deploy.ts` — orchestrates the deploy. Order matters:
1. Parse `--include` (default `vendor,plugins,themes,mu-plugins`; `all` adds `core`). 1. Parse `--include` (default `vendor,plugins,themes,mu-plugins`; `all` adds `core`).
@@ -35,9 +36,11 @@ Single command today (`deploy`), but the layout assumes more will be added.
3. **Drift check** — for every content component being deployed, list remote top-level dirs under `wp-content/<component>` and abort if any are absent locally. This guards against `rsync --delete` wiping plugins/themes installed out-of-band on the server. 3. **Drift check** — for every content component being deployed, list remote top-level dirs under `wp-content/<component>` and abort if any are absent locally. This guards against `rsync --delete` wiping plugins/themes installed out-of-band on the server.
4. Rsync, in this order: core (excludes `wp-config.php`, `wp-content/`, `.htaccess`, `.user.ini`, `php.ini`, `robots.txt`, `.well-known/`), `vendor/` + `composer.json/lock`, then each content component. After vendor sync, asserts `vendor/autoload.php` is referenced from remote `wp-config.php`. 4. Rsync, in this order: core (excludes `wp-config.php`, `wp-content/`, `.htaccess`, `.user.ini`, `php.ini`, `robots.txt`, `.well-known/`), `vendor/` + `composer.json/lock`, then each content component. After vendor sync, asserts `vendor/autoload.php` is referenced from remote `wp-config.php`.
5. Remote DB updates: `wp core update-db`, `wp wc update` if WooCommerce is present, `wp acf json sync` if ACF ≥ 6.8 is installed. 5. Remote DB updates: `wp core update-db`, `wp wc update` if WooCommerce is present, `wp acf json sync` if ACF ≥ 6.8 is installed.
- `src/commands/provision.ts` — bootstraps a minimal local site from a positional `<slug>`. Resolves env via `readProvisionEnv` (`WP_LOCAL_ROOT_PATH`, `WEBSIMPLE_STACK_DOMAIN`, `WEBSIMPLE_STACK_PROTOCOL`, `WP_VERSION`, `WP_LOCALE`, `WPOP_GITEA_OWNER`, `WPOP_GITEA_TEMPLATE`, plus a Gitea token) and derives path (`${WP_LOCAL_ROOT_PATH}/<slug>`), DB (`wp_<slug>`), and URL. Each step is idempotent ("check, then act"): ensure the Gitea `wp-sites/<slug>` repo (create from `templates/wp-boilerplate` if missing), clone it, report git freshness, `CREATE DATABASE IF NOT EXISTS`, `wp core download --skip-content`, `wp config create`, and inject a `vendor/autoload.php` guard into `wp-config.php`. It does **not** run `wp core install` or `composer install`.
- `src/commands/destroy.ts` — the local inverse of `provision`. Resolves env via `readDestroyEnv` (only `WP_LOCAL_ROOT_PATH`), derives path (`${WP_LOCAL_ROOT_PATH}/<slug>`) and DB (`wp_<slug>`), confirms (yes/no, default No; `--yes` skips), guards that the path is a direct child of the root, then `rm -rf` the folder and `DROP DATABASE IF EXISTS`. Idempotent and **local only** — it never touches the Gitea repo or the remote.
- Caches (`composer/`, `npm/`, `pnpm/`, `yarn/`) live under `WPOP_CACHE_DIR` (default `/tmp/wpop`) and are wired into the child env so repeated runs reuse downloads. - Caches (`composer/`, `npm/`, `pnpm/`, `yarn/`) live under `WPOP_CACHE_DIR` (default `/tmp/wpop`) and are wired into the child env so repeated runs reuse downloads.
Logging uses `consola`; structured output is opt-in via `--json` (currently only `deploy` emits a one-shot JSON header). Logging uses `consola`; structured output is opt-in via `--json` (`deploy`, `provision`, and `destroy` emit a one-shot JSON report).
## Conventions ## Conventions

View File

@@ -1,6 +1,6 @@
{ {
"name": "@lewebsimple/wpop", "name": "@lewebsimple/wpop",
"version": "0.0.4", "version": "0.0.9",
"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>",

View File

@@ -1,6 +1,9 @@
#!/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 { destroy } from "./commands/destroy";
import { provision } from "./commands/provision";
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 +40,55 @@ 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;
}
});
program
.command("provision <slug>")
.description(
"Provision a minimal local WordPress site (Gitea repo, clone, database, core, wp-config)",
)
.action(async (slug: string, options: Record<string, never>) => {
const context = createContext(program.opts());
configureLogger(context);
try {
await provision(context, slug, options);
} catch (error) {
handleError(context, error);
process.exitCode = 1;
}
});
program
.command("destroy <slug>")
.description("Destroy a local WordPress site (remove folder, drop database)")
.action(async (slug: string, options: Record<string, never>) => {
const context = createContext(program.opts());
configureLogger(context);
try {
await destroy(context, slug, options);
} catch (error) {
handleError(context, error);
process.exitCode = 1;
}
});
function handleError(context: { json: boolean }, error: unknown): void { function handleError(context: { json: boolean }, error: unknown): void {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
if (context.json) { if (context.json) {

View File

@@ -5,15 +5,9 @@ import prompts from "prompts";
import type { WPopContext } from "../lib/context"; import type { WPopContext } from "../lib/context";
import { readDeployEnv, type DeployEnv } from "../lib/env"; import { readDeployEnv, type DeployEnv } from "../lib/env";
import { emitJson } from "../lib/output"; import { emitJson } from "../lib/output";
import { parseMarkedOutput, rsync, sshOutput, sshRun } from "../lib/remote";
import { run } from "../lib/run"; import { run } from "../lib/run";
import { import { createSshConfig, prepareSsh, sshTarget, type PreparedSsh } from "../lib/ssh";
createSshConfig,
prepareSsh,
rsyncSshShell,
sshArgs,
sshTarget,
type PreparedSsh,
} from "../lib/ssh";
import { createTempDir } from "../lib/tempdir"; import { createTempDir } from "../lib/tempdir";
const DEFAULT_COMPONENTS = ["vendor", "plugins", "themes", "mu-plugins"] as const; const DEFAULT_COMPONENTS = ["vendor", "plugins", "themes", "mu-plugins"] as const;
@@ -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();
}

163
src/commands/destroy.ts Normal file
View File

@@ -0,0 +1,163 @@
import { existsSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { consola } from "consola";
import prompts from "prompts";
import type { WPopContext } from "../lib/context";
import { readDestroyEnv, type DestroyEnv } from "../lib/env";
import { emitJson } from "../lib/output";
import { run } from "../lib/run";
export type DestroyOptions = Record<string, never>;
type Derived = {
sitePath: string;
dbName: string;
};
type DestroyReport = {
command: "destroy";
slug: string;
sitePath: string;
dbName: string;
dryRun: boolean;
steps: string[];
};
export async function destroy(
context: WPopContext,
slug: string,
_options: DestroyOptions,
): Promise<void> {
if (!/^[a-zA-Z0-9._-]+$/.test(slug)) {
throw new Error(`Invalid site slug "${slug}": use letters, numbers, ".", "_" or "-" only`);
}
const env = readDestroyEnv(process.env);
const derived = resolveDerived(env, slug);
const report = buildReport(context, slug, derived);
if (!context.dryRun) {
const dirExists = existsSync(derived.sitePath);
const dbExists = await databaseExists(context, derived.dbName);
if (!dirExists && !dbExists) {
if (context.json) {
emitJson(report);
} else {
consola.warn(
`Nothing to destroy for ${slug}: no directory at ${derived.sitePath} and no database ${derived.dbName}`,
);
}
return;
}
}
if (context.json) {
if (!context.yes && !context.dryRun) {
throw new Error("--json requires --yes (cannot prompt for confirmation in JSON mode)");
}
} else {
logPlan(context, slug, derived);
if (!(await confirm(context, slug, derived))) {
consola.warn("Aborted.");
return;
}
}
assertWithinRoot(env, derived.sitePath);
await removeSiteDirectory(context, derived.sitePath, report);
await dropDatabase(context, derived.dbName, report);
if (context.json) {
emitJson(report);
} else {
consola.success(`Destroyed ${slug}`);
}
}
function resolveDerived(env: DestroyEnv, slug: string): Derived {
return {
sitePath: join(env.WP_LOCAL_ROOT_PATH, slug),
dbName: `wp_${slug}`,
};
}
function buildReport(context: WPopContext, slug: string, derived: Derived): DestroyReport {
return {
command: "destroy",
slug,
sitePath: derived.sitePath,
dbName: derived.dbName,
dryRun: context.dryRun,
steps: [],
};
}
function logPlan(context: WPopContext, slug: string, derived: Derived): void {
consola.info(`Planning destroy of ${slug}`);
consola.info(` path: ${derived.sitePath}`);
consola.info(` database: ${derived.dbName}`);
if (context.dryRun) {
consola.info("Dry-run: no changes will be made");
}
}
async function confirm(context: WPopContext, slug: string, derived: Derived): Promise<boolean> {
if (context.yes || context.dryRun) {
return true;
}
const response = await prompts({
type: "confirm",
name: "confirmed",
message: `Destroy ${slug}? This deletes ${derived.sitePath} and drops ${derived.dbName}.`,
initial: false,
});
return Boolean(response.confirmed);
}
async function databaseExists(context: WPopContext, dbName: string): Promise<boolean> {
const result = await run(context, "mysql", ["-N", "-e", `SHOW DATABASES LIKE '${dbName}'`], {
capture: true,
reject: false,
});
if (result.exitCode !== 0) {
// Could not query (e.g. mysql unreachable); assume it may exist so we don't wrongly skip teardown.
return true;
}
return result.stdout.trim().length > 0;
}
function assertWithinRoot(env: DestroyEnv, sitePath: string): void {
if (dirname(resolve(sitePath)) !== resolve(env.WP_LOCAL_ROOT_PATH)) {
throw new Error(
`Refusing to delete ${sitePath}: not a direct child of ${env.WP_LOCAL_ROOT_PATH}`,
);
}
}
async function removeSiteDirectory(
context: WPopContext,
sitePath: string,
report: DestroyReport,
): Promise<void> {
if (!context.dryRun && !existsSync(sitePath)) {
consola.info(`Site directory already absent at ${sitePath}`);
return;
}
consola.info(`Removing site directory ${sitePath}`);
await run(context, "rm", ["-rf", sitePath]);
report.steps.push("fs:remove");
}
async function dropDatabase(
context: WPopContext,
dbName: string,
report: DestroyReport,
): Promise<void> {
consola.info(`Dropping database ${dbName}`);
await run(context, "mysql", ["-e", `DROP DATABASE IF EXISTS \`${dbName}\`;`]);
report.steps.push("db:drop");
}

332
src/commands/provision.ts Normal file
View File

@@ -0,0 +1,332 @@
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { consola } from "consola";
import prompts from "prompts";
import type { WPopContext } from "../lib/context";
import { readProvisionEnv, type ProvisionEnv } from "../lib/env";
import { generateGiteaRepo, giteaRepoExists } from "../lib/gitea";
import { emitJson } from "../lib/output";
import { run } from "../lib/run";
const AUTOLOAD_MARKER = "vendor/autoload.php";
const WP_SETTINGS_PATTERN = /^([ \t]*)require_once ABSPATH \. ['"]wp-settings\.php['"];$/m;
const COMPOSER_AUTOLOAD_GUARD = [
"if ( file_exists( __DIR__ . '/vendor/autoload.php' ) ) {",
"\trequire_once __DIR__ . '/vendor/autoload.php';",
"}",
"",
"",
].join("\n");
export type ProvisionOptions = Record<string, never>;
type Derived = {
sitePath: string;
dbName: string;
siteUrl: string;
owner: string;
templateOwner: string;
templateRepo: string;
cloneUrl: string;
};
type ProvisionReport = {
command: "provision";
slug: string;
sitePath: string;
dbName: string;
siteUrl: string;
repo: string;
dryRun: boolean;
steps: string[];
};
export async function provision(
context: WPopContext,
slug: string,
_options: ProvisionOptions,
): Promise<void> {
if (!/^[a-zA-Z0-9._-]+$/.test(slug)) {
throw new Error(`Invalid site slug "${slug}": use letters, numbers, ".", "_" or "-" only`);
}
const env = readProvisionEnv(process.env);
const derived = resolveDerived(env, slug);
const report = buildReport(context, slug, derived);
if (context.json) {
if (!context.yes && !context.dryRun) {
throw new Error("--json requires --yes (cannot prompt for confirmation in JSON mode)");
}
} else {
logPlan(context, slug, derived);
if (!(await confirm(context, slug, derived))) {
consola.warn("Aborted.");
return;
}
}
if (!context.dryRun && !existsSync(env.WP_LOCAL_ROOT_PATH)) {
throw new Error(`Local root path does not exist: ${env.WP_LOCAL_ROOT_PATH}`);
}
await ensureGiteaRepo(context, env, slug, derived, report);
await ensureSiteDirectory(context, env, slug, derived, report);
await checkGitFreshness(context, derived.sitePath);
await ensureDatabase(context, derived.dbName, report);
await ensureCore(context, env, derived.sitePath, report);
await ensureWpConfig(context, derived, report);
if (context.json) {
emitJson(report);
} else {
consola.success(`Provisioned ${slug} at ${derived.siteUrl}`);
}
}
function resolveDerived(env: ProvisionEnv, slug: string): Derived {
const [templateOwner, templateRepo] = env.WPOP_GITEA_TEMPLATE.split("/");
if (!templateOwner || !templateRepo) {
throw new Error(
`Invalid WPOP_GITEA_TEMPLATE "${env.WPOP_GITEA_TEMPLATE}": expected "owner/repo"`,
);
}
const host = new URL(env.giteaBaseUrl).hostname;
return {
sitePath: join(env.WP_LOCAL_ROOT_PATH, slug),
dbName: `wp_${slug}`,
siteUrl: `${env.WEBSIMPLE_STACK_PROTOCOL}://${slug}.${env.WEBSIMPLE_STACK_DOMAIN}`,
owner: env.WPOP_GITEA_OWNER,
templateOwner,
templateRepo,
cloneUrl: `ssh://${env.WPOP_GITEA_SSH_USER}@${host}:${env.WPOP_GITEA_SSH_PORT}/${env.WPOP_GITEA_OWNER}/${slug}.git`,
};
}
function buildReport(context: WPopContext, slug: string, derived: Derived): ProvisionReport {
return {
command: "provision",
slug,
sitePath: derived.sitePath,
dbName: derived.dbName,
siteUrl: derived.siteUrl,
repo: `${derived.owner}/${slug}`,
dryRun: context.dryRun,
steps: [],
};
}
function logPlan(context: WPopContext, slug: string, derived: Derived): void {
consola.info(`Planning provision of ${slug}`);
consola.info(` repo: ${derived.owner}/${slug}`);
consola.info(` path: ${derived.sitePath}`);
consola.info(` database: ${derived.dbName}`);
consola.info(` url: ${derived.siteUrl}`);
if (context.dryRun) {
consola.info("Dry-run: no changes will be made");
}
}
async function confirm(context: WPopContext, slug: string, derived: Derived): Promise<boolean> {
if (context.yes || context.dryRun) {
return true;
}
const response = await prompts({
type: "confirm",
name: "confirmed",
message: `Provision ${slug} at ${derived.sitePath}?`,
initial: false,
});
return Boolean(response.confirmed);
}
async function ensureGiteaRepo(
context: WPopContext,
env: ProvisionEnv,
slug: string,
derived: Derived,
report: ProvisionReport,
): Promise<void> {
if (context.dryRun) {
consola.info(
`[dry-run] Would ensure Gitea repo ${derived.owner}/${slug} exists (create from ${env.WPOP_GITEA_TEMPLATE} if missing)`,
);
return;
}
const exists = await giteaRepoExists(env.giteaBaseUrl, env.giteaToken, derived.owner, slug);
if (exists) {
consola.info(`Gitea repo ${derived.owner}/${slug} already exists`);
return;
}
if (!context.yes) {
const response = await prompts({
type: "confirm",
name: "confirmed",
message: `Repo ${derived.owner}/${slug} does not exist. Create it from ${env.WPOP_GITEA_TEMPLATE}?`,
initial: true,
});
if (!response.confirmed) {
throw new Error(`Gitea repo ${derived.owner}/${slug} is required but was not created`);
}
}
consola.info(`Creating Gitea repo ${derived.owner}/${slug} from ${env.WPOP_GITEA_TEMPLATE}`);
await generateGiteaRepo(env.giteaBaseUrl, env.giteaToken, {
templateOwner: derived.templateOwner,
templateRepo: derived.templateRepo,
owner: derived.owner,
name: slug,
});
report.steps.push("gitea:create");
}
async function ensureSiteDirectory(
context: WPopContext,
env: ProvisionEnv,
slug: string,
derived: Derived,
report: ProvisionReport,
): Promise<void> {
if (!context.dryRun && existsSync(derived.sitePath)) {
if (!existsSync(join(derived.sitePath, ".git"))) {
consola.warn(`${derived.sitePath} exists but is not a git repository`);
} else {
consola.info(`Site directory already present at ${derived.sitePath}`);
}
return;
}
consola.info(`Cloning ${derived.cloneUrl} into ${derived.sitePath}`);
await run(context, "git", ["clone", derived.cloneUrl, derived.sitePath], {
cwd: env.WP_LOCAL_ROOT_PATH,
});
report.steps.push("git:clone");
}
async function checkGitFreshness(context: WPopContext, sitePath: string): Promise<void> {
if (context.dryRun) {
return;
}
await run(context, "git", ["fetch", "--quiet"], { cwd: sitePath, capture: true, reject: false });
const status = await run(context, "git", ["status", "--porcelain"], {
cwd: sitePath,
capture: true,
reject: false,
});
if (status.exitCode === 0 && status.stdout.trim()) {
consola.warn("Working tree has uncommitted changes");
}
const counts = await run(context, "git", ["rev-list", "--left-right", "--count", "@{u}...HEAD"], {
cwd: sitePath,
capture: true,
reject: false,
});
if (counts.exitCode !== 0) {
return;
}
const [behind, ahead] = counts.stdout.trim().split(/\s+/).map(Number);
if (behind > 0 || ahead > 0) {
consola.warn(`Branch diverged from upstream (behind ${behind}, ahead ${ahead})`);
} else {
consola.success("Git is up to date with upstream");
}
}
async function ensureDatabase(
context: WPopContext,
dbName: string,
report: ProvisionReport,
): Promise<void> {
consola.info(`Ensuring database ${dbName} exists`);
await run(context, "mysql", [
"-e",
`CREATE DATABASE IF NOT EXISTS \`${dbName}\` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;`,
]);
report.steps.push("db:ensure");
}
async function ensureCore(
context: WPopContext,
env: ProvisionEnv,
sitePath: string,
report: ProvisionReport,
): Promise<void> {
if (!context.dryRun && existsSync(join(sitePath, "wp-includes", "version.php"))) {
consola.info("WordPress core already present");
return;
}
consola.info("Downloading WordPress core (--skip-content)");
await run(context, "wp", [
"core",
"download",
"--skip-content",
`--version=${env.WP_VERSION}`,
`--locale=${env.WP_LOCALE}`,
`--path=${sitePath}`,
]);
report.steps.push("core:download");
}
async function ensureWpConfig(
context: WPopContext,
derived: Derived,
report: ProvisionReport,
): Promise<void> {
const configPath = join(derived.sitePath, "wp-config.php");
if (context.dryRun || !existsSync(configPath)) {
consola.info("Creating wp-config.php");
await run(context, "wp", [
"config",
"create",
`--dbname=${derived.dbName}`,
"--dbprefix=wp_",
"--skip-check",
`--path=${derived.sitePath}`,
]);
report.steps.push("config:create");
} else {
consola.info("wp-config.php already present");
}
ensureComposerAutoload(context, configPath, report);
}
function ensureComposerAutoload(
context: WPopContext,
configPath: string,
report: ProvisionReport,
): void {
if (context.dryRun) {
consola.info("[dry-run] Would ensure Composer autoload guard in wp-config.php");
return;
}
const contents = readFileSync(configPath, "utf8");
if (contents.includes(AUTOLOAD_MARKER)) {
consola.info("Composer autoload guard already present in wp-config.php");
return;
}
let updated: string;
if (WP_SETTINGS_PATTERN.test(contents)) {
updated = contents.replace(WP_SETTINGS_PATTERN, `${COMPOSER_AUTOLOAD_GUARD}$&`);
} else {
updated = contents.replace(/<\?php\n/, `$&\n${COMPOSER_AUTOLOAD_GUARD}`);
}
writeFileSync(configPath, updated);
consola.info("Added Composer autoload guard to wp-config.php");
report.steps.push("config:autoload");
}

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

@@ -0,0 +1,439 @@
import { existsSync, mkdirSync, readdirSync } from "node:fs";
import { basename, 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 = resolveLocalSiteurl(context) ?? (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 });
}
function resolveLocalSiteurl(context: WPopContext): string | undefined {
const protocol = process.env.WEBSIMPLE_STACK_PROTOCOL?.trim();
const domain = process.env.WEBSIMPLE_STACK_DOMAIN?.trim();
if (!protocol || !domain) {
return undefined;
}
const subdomain = basename(context.cwd);
if (!subdomain) {
return undefined;
}
return `${protocol}://${subdomain}.${domain}`;
}
async function readLocalOption(context: WPopContext, option: string): Promise<string | undefined> {
const result = await run(context, "wp", ["option", "get", option], {
capture: true,
cwd: context.cwd,
reject: false,
});
if (context.dryRun) {
return undefined;
}
if (result.exitCode !== 0) {
return undefined;
}
return result.stdout.trim() || undefined;
}
async function readRemoteOption(
context: WPopContext,
ssh: PreparedSsh,
env: DeployEnv,
option: string,
): Promise<string | undefined> {
const script = `printf '%s\\n' ${JSON.stringify(REMOTE_OPTION_BEGIN)}
cd ${JSON.stringify(env.REMOTE_PATH)} && wp option get ${JSON.stringify(option)} || true
printf '%s\\n' ${JSON.stringify(REMOTE_OPTION_END)}
`;
const { stdout } = await sshOutput(context, ssh, script);
if (context.dryRun) {
return undefined;
}
const lines = parseMarkedOutput(
stdout,
REMOTE_OPTION_BEGIN,
REMOTE_OPTION_END,
`remote option ${option}`,
);
return lines.join("\n").trim() || undefined;
}

View File

@@ -1,6 +1,7 @@
import { z } from "zod"; import { z } from "zod";
import { consola } from "consola"; import { consola } from "consola";
import { execa } from "execa"; import { execa } from "execa";
import { getGiteaBaseUrl, getGiteaToken } from "./gitea";
const deployEnvSchema = z.object({ const deployEnvSchema = z.object({
REMOTE_HOST: z.string().min(1), REMOTE_HOST: z.string().min(1),
@@ -25,6 +26,62 @@ const giteaVariableKeys = [
export type DeployEnv = z.infer<typeof deployEnvSchema>; export type DeployEnv = z.infer<typeof deployEnvSchema>;
const provisionEnvSchema = z.object({
WP_LOCAL_ROOT_PATH: z.string().min(1),
WEBSIMPLE_STACK_DOMAIN: z.string().min(1),
WEBSIMPLE_STACK_PROTOCOL: z.string().default("https"),
WP_VERSION: z.string().default("latest"),
WP_LOCALE: z.string().default("fr_CA"),
WPOP_GITEA_OWNER: z.string().default("wp-sites"),
WPOP_GITEA_TEMPLATE: z.string().default("templates/wp-boilerplate"),
WPOP_GITEA_SSH_USER: z.string().default("git"),
WPOP_GITEA_SSH_PORT: z.coerce.number().int().positive().default(222),
});
export type ProvisionEnv = z.infer<typeof provisionEnvSchema> & {
giteaToken: string;
giteaBaseUrl: string;
};
export function readProvisionEnv(env: NodeJS.ProcessEnv = process.env): ProvisionEnv {
const missing = (["WP_LOCAL_ROOT_PATH", "WEBSIMPLE_STACK_DOMAIN"] as const).filter(
(key) => !hasValue(env[key]),
);
if (missing.length > 0) {
throw new Error(`Missing provision environment variable(s): ${missing.join(", ")}`);
}
const giteaToken = getGiteaToken(env);
if (!giteaToken) {
throw new Error(
[
"Missing Gitea API token for provisioning.",
"Set WPOP_GITEA_TOKEN, WEBSIMPLE_GITEA_API_TOKEN, or GITEA_TOKEN (write access required to create the site repository).",
].join("\n"),
);
}
return {
...provisionEnvSchema.parse(env),
giteaToken,
giteaBaseUrl: getGiteaBaseUrl(env),
};
}
const destroyEnvSchema = z.object({
WP_LOCAL_ROOT_PATH: z.string().min(1),
});
export type DestroyEnv = z.infer<typeof destroyEnvSchema>;
export function readDestroyEnv(env: NodeJS.ProcessEnv = process.env): DestroyEnv {
if (!hasValue(env.WP_LOCAL_ROOT_PATH)) {
throw new Error("Missing destroy environment variable(s): WP_LOCAL_ROOT_PATH");
}
return destroyEnvSchema.parse(env);
}
type GiteaVariableKey = (typeof giteaVariableKeys)[number]; type GiteaVariableKey = (typeof giteaVariableKeys)[number];
type GiteaRepo = { type GiteaRepo = {
@@ -118,14 +175,6 @@ async function readGiteaDeployEnv(
}; };
} }
function getGiteaToken(env: NodeJS.ProcessEnv): string | undefined {
return firstValue(env.WPOP_GITEA_TOKEN, env.WEBSIMPLE_GITEA_API_TOKEN, env.GITEA_TOKEN);
}
function getGiteaBaseUrl(env: NodeJS.ProcessEnv): string {
return firstValue(env.WPOP_GITEA_BASE_URL, env.GITEA_BASE_URL) ?? "https://gitea.websimple.com";
}
async function resolveGiteaRepo( async function resolveGiteaRepo(
env: NodeJS.ProcessEnv, env: NodeJS.ProcessEnv,
cwd: string, cwd: string,

79
src/lib/gitea.ts Normal file
View File

@@ -0,0 +1,79 @@
export function getGiteaToken(env: NodeJS.ProcessEnv): string | undefined {
return firstValue(env.WPOP_GITEA_TOKEN, env.WEBSIMPLE_GITEA_API_TOKEN, env.GITEA_TOKEN);
}
export function getGiteaBaseUrl(env: NodeJS.ProcessEnv): string {
return firstValue(env.WPOP_GITEA_BASE_URL, env.GITEA_BASE_URL) ?? "https://gitea.websimple.com";
}
export async function giteaRepoExists(
baseUrl: string,
token: string,
owner: string,
repo: string,
): Promise<boolean> {
const url = new URL(
`/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`,
baseUrl,
);
const response = await fetch(url, {
headers: {
Accept: "application/json",
Authorization: `token ${token}`,
},
});
if (response.status === 200) {
return true;
}
if (response.status === 404) {
return false;
}
throw new Error(`Gitea API request failed (${response.status}) while checking ${owner}/${repo}`);
}
export async function generateGiteaRepo(
baseUrl: string,
token: string,
options: { templateOwner: string; templateRepo: string; owner: string; name: string },
): Promise<void> {
const url = new URL(
`/api/v1/repos/${encodeURIComponent(options.templateOwner)}/${encodeURIComponent(options.templateRepo)}/generate`,
baseUrl,
);
const response = await fetch(url, {
method: "POST",
headers: {
Accept: "application/json",
Authorization: `token ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
owner: options.owner,
name: options.name,
git_content: true,
private: true,
}),
});
if (!response.ok) {
throw new Error(
`Gitea API request failed (${response.status}) while generating ${options.owner}/${options.name} from ${options.templateOwner}/${options.templateRepo}`,
);
}
}
function firstValue(...values: (string | undefined)[]): string | undefined {
for (const value of values) {
const trimmed = value?.trim();
if (trimmed) {
return trimmed;
}
}
return undefined;
}

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

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

View File

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