Files
wpop/src/lib/env.ts

279 lines
7.3 KiB
TypeScript

import { z } from "zod";
import { consola } from "consola";
import { execa } from "execa";
const deployEnvSchema = z.object({
REMOTE_HOST: z.string().min(1),
REMOTE_USER: z.string().min(1),
REMOTE_PATH: z.string().min(1),
REMOTE_PORT: z.coerce.number().int().positive().default(22),
SSH_PRIVATE_KEY: z.string().optional(),
WPOP_CACHE_DIR: z.string().default("/tmp/wpop"),
WP_VERSION: z.string().default("latest"),
WP_LOCALE: z.string().default("fr_CA"),
});
const remoteEnvKeys = ["REMOTE_HOST", "REMOTE_PORT", "REMOTE_USER", "REMOTE_PATH"] as const;
const requiredRemoteEnvKeys = ["REMOTE_HOST", "REMOTE_USER", "REMOTE_PATH"] as const;
const giteaVariableKeys = [
"REMOTE_HOST",
"REMOTE_PORT",
"REMOTE_USER",
"REMOTE_PATH",
"WP_SITE_URL",
] as const;
export type DeployEnv = z.infer<typeof deployEnvSchema>;
type GiteaVariableKey = (typeof giteaVariableKeys)[number];
type GiteaRepo = {
owner: string;
repo: string;
};
type GiteaVariable = {
name?: string;
data?: string;
value?: string;
};
export async function readDeployEnv(
env: NodeJS.ProcessEnv = process.env,
options: { cwd?: string } = {},
): Promise<DeployEnv> {
const normalizedEnv = normalizeEnv(env);
const missingRemoteKeys = remoteEnvKeys.filter((key) => !hasValue(normalizedEnv[key]));
if (missingRemoteKeys.length > 0) {
const giteaEnv = await readGiteaDeployEnv(normalizedEnv, options.cwd);
for (const key of giteaVariableKeys) {
if (!hasValue(normalizedEnv[key]) && hasValue(giteaEnv[key])) {
normalizedEnv[key] = giteaEnv[key];
}
}
}
const missingRequiredKeys = requiredRemoteEnvKeys.filter((key) => !hasValue(normalizedEnv[key]));
if (missingRequiredKeys.length > 0) {
throw new Error(
[
`Missing deploy environment variable(s): ${missingRequiredKeys.join(", ")}`,
"Set them directly or provide WEBSIMPLE_GITEA_API_TOKEN so wpop can read Gitea Actions variables.",
].join("\n"),
);
}
return deployEnvSchema.parse(normalizedEnv);
}
function normalizeEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const normalized = { ...env };
for (const key of [...remoteEnvKeys, "WP_SITE_URL"] as const) {
if (typeof normalized[key] === "string" && normalized[key].trim() === "") {
delete normalized[key];
}
}
return normalized;
}
async function readGiteaDeployEnv(
env: NodeJS.ProcessEnv,
cwd = process.cwd(),
): Promise<Partial<Record<GiteaVariableKey, string>>> {
const token = getGiteaToken(env);
if (!token) {
return {};
}
const repo = await resolveGiteaRepo(env, cwd);
if (!repo) {
consola.warn("Could not infer Gitea repository from git origin; skipping Gitea env lookup");
return {};
}
consola.info(
`Reading deploy environment from Gitea Actions variables (${repo.owner}/${repo.repo})`,
);
const baseUrl = getGiteaBaseUrl(env);
const [orgVariables, repoVariables] = await Promise.all([
fetchGiteaVariables(
baseUrl,
token,
`/api/v1/orgs/${encodeURIComponent(repo.owner)}/actions/variables`,
true,
),
fetchGiteaVariables(
baseUrl,
token,
`/api/v1/repos/${encodeURIComponent(repo.owner)}/${encodeURIComponent(repo.repo)}/actions/variables`,
false,
),
]);
return {
...variablesToEnv(orgVariables),
...variablesToEnv(repoVariables),
};
}
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(
env: NodeJS.ProcessEnv,
cwd: string,
): Promise<GiteaRepo | undefined> {
const explicit = parseRepoSpec(env.WPOP_GITEA_REPO);
if (explicit) {
return explicit;
}
const explicitRepo = firstValue(env.WPOP_GITEA_REPO_NAME, env.GITEA_REPOSITORY);
if (explicitRepo) {
const owner = firstValue(env.WPOP_GITEA_OWNER, env.GITEA_REPOSITORY_OWNER) ?? "wp-sites";
return parseRepoSpec(explicitRepo) ?? { owner, repo: trimGitSuffix(explicitRepo) };
}
const originUrl = await readGitOriginUrl(cwd);
if (!originUrl) {
return undefined;
}
return parseGiteaRemoteUrl(originUrl);
}
async function readGitOriginUrl(cwd: string): Promise<string | undefined> {
const result = await execa("git", ["remote", "get-url", "origin"], {
cwd,
reject: false,
});
if (result.exitCode !== 0) {
return undefined;
}
return result.stdout.trim() || undefined;
}
function parseRepoSpec(spec: string | undefined): GiteaRepo | undefined {
const value = firstValue(spec);
if (!value) {
return undefined;
}
const [owner, repo] = value.split("/", 2);
if (!owner || !repo) {
return undefined;
}
return { owner, repo: trimGitSuffix(repo) };
}
function parseGiteaRemoteUrl(remoteUrl: string): GiteaRepo | undefined {
try {
const url = new URL(remoteUrl);
if (url.hostname === "gitea.websimple.com") {
return parseRepoSpec(url.pathname.replace(/^\/+/, ""));
}
} catch {
// Not a URL-form remote; try scp-style below.
}
const urlMatch = remoteUrl.match(
/gitea\.websimple\.com[:/](?<owner>[^/]+)\/(?<repo>[^/]+?)(?:\.git)?$/,
);
if (urlMatch?.groups) {
return {
owner: urlMatch.groups.owner,
repo: trimGitSuffix(urlMatch.groups.repo),
};
}
return undefined;
}
async function fetchGiteaVariables(
baseUrl: string,
token: string,
path: string,
optional: boolean,
): Promise<GiteaVariable[]> {
const variables: GiteaVariable[] = [];
for (let page = 1; page <= 100; page += 1) {
const url = new URL(path, baseUrl);
url.searchParams.set("page", String(page));
url.searchParams.set("limit", "100");
const response = await fetch(url, {
headers: {
Accept: "application/json",
Authorization: `token ${token}`,
},
});
if (response.status === 404 && optional) {
return variables;
}
if (!response.ok) {
throw new Error(`Gitea API request failed (${response.status}) while reading ${path}`);
}
const pageVariables = (await response.json()) as GiteaVariable[];
variables.push(...pageVariables);
if (pageVariables.length < 100) {
return variables;
}
}
throw new Error("Gitea Actions variables pagination exceeded safety limit");
}
function variablesToEnv(variables: GiteaVariable[]): Partial<Record<GiteaVariableKey, string>> {
const env: Partial<Record<GiteaVariableKey, string>> = {};
for (const variable of variables) {
if (!isGiteaVariableKey(variable.name)) {
continue;
}
const value = firstValue(variable.data, variable.value);
if (value) {
env[variable.name] = value;
}
}
return env;
}
function isGiteaVariableKey(value: string | undefined): value is GiteaVariableKey {
return giteaVariableKeys.includes(value as GiteaVariableKey);
}
function firstValue(...values: (string | undefined)[]): string | undefined {
for (const value of values) {
const trimmed = value?.trim();
if (trimmed) {
return trimmed;
}
}
return undefined;
}
function hasValue(value: string | undefined): boolean {
return Boolean(firstValue(value));
}
function trimGitSuffix(value: string): string {
return value.endsWith(".git") ? value.slice(0, -4) : value;
}