Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9bd489e0c0 | |||
| 9b7cb13e65 | |||
| b8973559ca | |||
| f6485830b8 |
16
CHANGELOG.md
16
CHANGELOG.md
@@ -1,5 +1,21 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 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
|
## v0.0.6
|
||||||
|
|
||||||
[compare changes](https://gitea.websimple.com/pascalmartineau/wpop/compare/v0.0.5...v0.0.6)
|
[compare changes](https://gitea.websimple.com/pascalmartineau/wpop/compare/v0.0.5...v0.0.6)
|
||||||
|
|||||||
@@ -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), and `provision` (bootstrap 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,10 @@ 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`.
|
||||||
- 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` and `provision` emit a one-shot JSON report).
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@lewebsimple/wpop",
|
"name": "@lewebsimple/wpop",
|
||||||
"version": "0.0.6",
|
"version": "0.0.8",
|
||||||
"description": "WordPress operations CLI for Websimple projects.",
|
"description": "WordPress operations CLI for Websimple projects.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": "Pascal Martineau <pascal@lewebsimple.ca>",
|
"author": "Pascal Martineau <pascal@lewebsimple.ca>",
|
||||||
|
|||||||
17
src/cli.ts
17
src/cli.ts
@@ -1,6 +1,7 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
import { Command } from "commander";
|
import { Command } from "commander";
|
||||||
import { deploy } from "./commands/deploy";
|
import { deploy } from "./commands/deploy";
|
||||||
|
import { provision } from "./commands/provision";
|
||||||
import { sync } from "./commands/sync";
|
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";
|
||||||
@@ -57,6 +58,22 @@ program
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
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) {
|
||||||
|
|||||||
332
src/commands/provision.ts
Normal file
332
src/commands/provision.ts
Normal 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");
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { existsSync, mkdirSync, readdirSync } from "node:fs";
|
import { existsSync, mkdirSync, readdirSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { basename, join } from "node:path";
|
||||||
import { consola } from "consola";
|
import { consola } from "consola";
|
||||||
import prompts from "prompts";
|
import prompts from "prompts";
|
||||||
import type { WPopContext } from "../lib/context";
|
import type { WPopContext } from "../lib/context";
|
||||||
@@ -74,7 +74,7 @@ export async function sync(context: WPopContext, options: SyncOptions): Promise<
|
|||||||
|
|
||||||
let localSiteurl: string | undefined;
|
let localSiteurl: string | undefined;
|
||||||
if (components.has("database") && !options.skipSearchReplace) {
|
if (components.has("database") && !options.skipSearchReplace) {
|
||||||
localSiteurl = await readLocalOption(context, "siteurl");
|
localSiteurl = resolveLocalSiteurl(context) ?? (await readLocalOption(context, "siteurl"));
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const component of CONTENT_SYNC_COMPONENTS) {
|
for (const component of CONTENT_SYNC_COMPONENTS) {
|
||||||
@@ -382,6 +382,19 @@ async function runSearchReplace(
|
|||||||
await run(context, "wp", ["cache", "flush"], { 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> {
|
async function readLocalOption(context: WPopContext, option: string): Promise<string | undefined> {
|
||||||
const result = await run(context, "wp", ["option", "get", option], {
|
const result = await run(context, "wp", ["option", "get", option], {
|
||||||
capture: true,
|
capture: true,
|
||||||
|
|||||||
@@ -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,48 @@ 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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
type GiteaVariableKey = (typeof giteaVariableKeys)[number];
|
type GiteaVariableKey = (typeof giteaVariableKeys)[number];
|
||||||
|
|
||||||
type GiteaRepo = {
|
type GiteaRepo = {
|
||||||
@@ -118,14 +161,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
79
src/lib/gitea.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user