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; 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 { 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>> { 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 { 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 { 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[:/](?[^/]+)\/(?[^/]+?)(?:\.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 { 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> { const env: Partial> = {}; 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; }