279 lines
7.3 KiB
TypeScript
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;
|
|
}
|