253 lines
9.9 KiB
JavaScript
253 lines
9.9 KiB
JavaScript
import { execFileSync } from "node:child_process";
|
|
import { existsSync, readFileSync } from "node:fs";
|
|
import { resolve } from "node:path";
|
|
|
|
const command = process.argv[2] ?? "setup";
|
|
const dryRun = process.argv.includes("--dry-run");
|
|
const apiBase = "https://api.cloudflare.com/client/v4";
|
|
|
|
if (!["setup"].includes(command)) {
|
|
console.error("Usage: bun scripts/cloudflare-workers-builds.mjs setup [--dry-run]");
|
|
process.exit(1);
|
|
}
|
|
|
|
const parseEnv = (content) => {
|
|
const values = new Map();
|
|
|
|
for (const line of content.split(/\r?\n/)) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed || trimmed.startsWith("#") || !trimmed.includes("=")) continue;
|
|
|
|
const separator = trimmed.indexOf("=");
|
|
const key = trimmed.slice(0, separator).trim();
|
|
let value = trimmed.slice(separator + 1).trim();
|
|
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
value = value.slice(1, -1);
|
|
}
|
|
values.set(key, value.replace(/\\\$/g, "$"));
|
|
}
|
|
|
|
return values;
|
|
};
|
|
|
|
const localValues = existsSync(resolve(".env.local"))
|
|
? parseEnv(readFileSync(resolve(".env.local"), "utf8"))
|
|
: new Map();
|
|
|
|
const value = (name) => process.env[name]?.trim() || localValues.get(name)?.trim() || "";
|
|
const instanceKey = value("EDGE_EVER_INSTANCE").replace(/[^a-zA-Z0-9]/g, "_").toUpperCase();
|
|
const instanceValue = (name) => (instanceKey ? value(`EDGE_EVER_${instanceKey}_${name}`) : "") || value(`EDGE_EVER_${name}`);
|
|
|
|
const gitRepository = () => {
|
|
const configured = value("EDGE_EVER_GITHUB_REPOSITORY");
|
|
if (/^[^/\s]+\/[^/\s]+$/.test(configured)) {
|
|
const [owner, repository] = configured.split("/");
|
|
return { owner, repository };
|
|
}
|
|
const remote = configured || (() => {
|
|
try {
|
|
return execFileSync("git", ["config", "--get", "remote.origin.url"], { encoding: "utf8" }).trim();
|
|
} catch {
|
|
return "";
|
|
}
|
|
})();
|
|
const match = remote.match(/github\.com[/:]([^/]+)\/([^/#\s]+?)(?:\.git)?$/i);
|
|
if (!match) {
|
|
throw new Error(
|
|
"Cannot identify the GitHub repository. Set EDGE_EVER_GITHUB_REPOSITORY=owner/repository or configure origin.",
|
|
);
|
|
}
|
|
return { owner: match[1], repository: match[2] };
|
|
};
|
|
|
|
const cfToken = value("EDGE_EVER_BUILDS_API_TOKEN") || value("CLOUDFLARE_API_TOKEN");
|
|
const accountId = value("CLOUDFLARE_ACCOUNT_ID");
|
|
const workerName = instanceValue("WORKER_NAME");
|
|
const pagesProjectName = value("EDGE_EVER_PAGES_PROJECT_NAME");
|
|
|
|
const request = async (method, path, body) => {
|
|
if (dryRun) return undefined;
|
|
|
|
const response = await fetch(`${apiBase}${path}`, {
|
|
method,
|
|
headers: {
|
|
Authorization: `Bearer ${cfToken}`,
|
|
...(body ? { "Content-Type": "application/json" } : {}),
|
|
},
|
|
...(body ? { body: JSON.stringify(body) } : {}),
|
|
});
|
|
const payload = await response.json().catch(() => ({}));
|
|
if (!response.ok || payload.success === false) {
|
|
const detail = payload.errors?.map((error) => error.message).join("; ") || response.statusText;
|
|
throw new Error(`${method} ${path} failed: ${detail}`);
|
|
}
|
|
return payload.result;
|
|
};
|
|
|
|
const buildVariables = () => {
|
|
const merged = new Map(localValues);
|
|
for (const [key, current] of Object.entries(process.env)) {
|
|
if (current !== undefined) merged.set(key, current);
|
|
}
|
|
|
|
const runtimeNames = [
|
|
"WORKER_NAME",
|
|
"WORKERS_DEV",
|
|
"D1_DATABASE_NAME",
|
|
"D1_DATABASE_ID",
|
|
"R2_BUCKET_NAME",
|
|
"R2_PREVIEW_BUCKET_NAME",
|
|
"AUTH_USERNAME",
|
|
"AUTH_PASSWORD_HASH",
|
|
"SESSION_TTL_DAYS",
|
|
"DEMO_MODE",
|
|
"DEMO_RESET_CRON",
|
|
"CUSTOM_DOMAIN",
|
|
"ROUTE_PATTERN",
|
|
];
|
|
const entries = [];
|
|
|
|
if (instanceKey) entries.push(["EDGE_EVER_INSTANCE", instanceKey]);
|
|
for (const name of runtimeNames) {
|
|
const scoped = instanceKey ? `EDGE_EVER_${instanceKey}_${name}` : "";
|
|
const key = scoped && merged.get(scoped)?.trim() ? scoped : `EDGE_EVER_${name}`;
|
|
const current = merged.get(key)?.trim();
|
|
if (current) entries.push([key, current]);
|
|
}
|
|
|
|
if (!entries.some(([key]) => key.endsWith("AUTH_PASSWORD_HASH"))) {
|
|
throw new Error("Missing EDGE_EVER_AUTH_PASSWORD_HASH. Run deploy:setup before configuring Workers Builds.");
|
|
}
|
|
|
|
return Object.fromEntries(entries.map(([key, current]) => [key, {
|
|
value: current.replace(/\\\$/g, "$"),
|
|
is_secret: key.endsWith("AUTH_PASSWORD_HASH"),
|
|
}]));
|
|
};
|
|
|
|
const triggerPayload = (workerTag, repoConnectionUuid, buildTokenUuid) => ({
|
|
external_script_id: workerTag,
|
|
repo_connection_uuid: repoConnectionUuid,
|
|
build_token_uuid: buildTokenUuid,
|
|
trigger_name: "Deploy EdgeEver production",
|
|
build_command: "bun install --frozen-lockfile && bun run build:cloudflare",
|
|
deploy_command: "bun run deploy:cloudflare-builds",
|
|
root_directory: "/",
|
|
branch_includes: ["main"],
|
|
branch_excludes: [],
|
|
path_includes: ["*"],
|
|
path_excludes: [
|
|
"apps/site/*",
|
|
"apps/mobile/*",
|
|
"apps/extension/*",
|
|
"docs/*",
|
|
"README*",
|
|
".github/*",
|
|
],
|
|
build_caching_enabled: true,
|
|
});
|
|
|
|
const configurePagesWatchPaths = async () => {
|
|
if (!pagesProjectName) return;
|
|
|
|
try {
|
|
await request("PATCH", `/accounts/${accountId}/pages/projects/${encodeURIComponent(pagesProjectName)}`, {
|
|
source: {
|
|
config: {
|
|
// `*` spans nested directories in Cloudflare Pages. Root lockfiles and
|
|
// package metadata are shared build inputs, so they intentionally trigger
|
|
// both projects when changed.
|
|
path_includes: ["apps/site/*", "bun.lock", "package.json"],
|
|
path_excludes: [],
|
|
},
|
|
},
|
|
});
|
|
console.log(`[ok] configured Pages watch paths for ${pagesProjectName}`);
|
|
} catch (error) {
|
|
if (error instanceof Error && error.message.includes("Direct Uploads project")) {
|
|
console.log(`[skip] ${pagesProjectName} is a Direct Upload Pages project; its deploy workflow already controls path filtering.`);
|
|
return;
|
|
}
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
const setup = async () => {
|
|
if (!accountId) throw new Error("Missing CLOUDFLARE_ACCOUNT_ID.");
|
|
if (!workerName) throw new Error("Missing EDGE_EVER_WORKER_NAME.");
|
|
if (!cfToken) {
|
|
throw new Error(
|
|
"Missing EDGE_EVER_BUILDS_API_TOKEN (or CLOUDFLARE_API_TOKEN). It must be a user-scoped token with Workers Builds Configuration: Edit and Workers Scripts: Read.",
|
|
);
|
|
}
|
|
|
|
const repository = gitRepository();
|
|
const variables = buildVariables();
|
|
console.log(`[ok] repository: ${repository.owner}/${repository.repository}`);
|
|
console.log(`[ok] Worker: ${workerName}`);
|
|
console.log(`[ok] build variables: ${Object.keys(variables).length} (${Object.values(variables).filter((item) => item.is_secret).length} secret)`);
|
|
|
|
if (dryRun) {
|
|
console.log("[ok] dry run complete; no Cloudflare configuration was changed");
|
|
return;
|
|
}
|
|
|
|
const github = await fetch(`https://api.github.com/repos/${repository.owner}/${repository.repository}`, {
|
|
headers: { Accept: "application/vnd.github+json" },
|
|
});
|
|
const githubRepository = await github.json().catch(() => ({}));
|
|
if (!github.ok || !githubRepository.id || !githubRepository.owner?.id) {
|
|
throw new Error(`Cannot read GitHub repository ${repository.owner}/${repository.repository}. For a private repository, configure an accessible origin or run this command through an authenticated Agent.`);
|
|
}
|
|
|
|
const scripts = await request("GET", `/accounts/${accountId}/workers/scripts`);
|
|
const worker = scripts?.find((script) => script.id === workerName);
|
|
if (!worker?.tag) {
|
|
throw new Error(`Worker ${workerName} was not found. Complete the first bun run deploy before enabling Workers Builds.`);
|
|
}
|
|
|
|
const requestedBuildToken = value("EDGE_EVER_BUILDS_BUILD_TOKEN_UUID");
|
|
const buildTokens = await request("GET", `/accounts/${accountId}/builds/tokens`);
|
|
const buildToken = requestedBuildToken
|
|
? buildTokens?.find((token) => token.build_token_uuid === requestedBuildToken)
|
|
: buildTokens?.length === 1 ? buildTokens[0] : undefined;
|
|
if (!buildToken?.build_token_uuid) {
|
|
throw new Error(
|
|
"No unambiguous Workers Builds build token was found. In Cloudflare Dashboard open this Worker -> Settings -> Builds -> API token, create/select a token with D1 edit permission, then set EDGE_EVER_BUILDS_BUILD_TOKEN_UUID and rerun.",
|
|
);
|
|
}
|
|
|
|
const connection = await request("PUT", `/accounts/${accountId}/builds/repos/connections`, {
|
|
provider_type: "github",
|
|
provider_account_id: String(githubRepository.owner.id),
|
|
provider_account_name: githubRepository.owner.login,
|
|
repo_id: String(githubRepository.id),
|
|
repo_name: githubRepository.name,
|
|
});
|
|
const repoConnectionUuid = connection?.repo_connection_uuid;
|
|
if (!repoConnectionUuid) throw new Error("Cloudflare did not return repo_connection_uuid.");
|
|
|
|
const triggers = await request("GET", `/accounts/${accountId}/builds/workers/${worker.tag}/triggers`);
|
|
const existing = triggers?.find((trigger) => trigger.branch_includes?.includes("main"));
|
|
const payload = triggerPayload(worker.tag, repoConnectionUuid, buildToken.build_token_uuid);
|
|
const trigger = existing
|
|
? await request("PATCH", `/accounts/${accountId}/builds/triggers/${existing.trigger_uuid}`, payload)
|
|
: await request("POST", `/accounts/${accountId}/builds/triggers`, payload);
|
|
const triggerUuid = trigger?.trigger_uuid ?? existing?.trigger_uuid;
|
|
if (!triggerUuid) throw new Error("Cloudflare did not return trigger_uuid.");
|
|
|
|
await request("PATCH", `/accounts/${accountId}/builds/triggers/${triggerUuid}/environment_variables`, variables);
|
|
console.log(`[ok] ${existing ? "updated" : "created"} production trigger`);
|
|
await configurePagesWatchPaths();
|
|
if (!existing) {
|
|
const build = await request("POST", `/accounts/${accountId}/builds/triggers/${triggerUuid}/builds`, { branch: "main" });
|
|
console.log(`[ok] started first build${build?.build_uuid ? `: ${build.build_uuid}` : ""}`);
|
|
}
|
|
console.log("[ok] Workers Builds is ready; future pushes to main deploy automatically");
|
|
};
|
|
|
|
setup().catch((error) => {
|
|
console.error(`[fail] ${error instanceof Error ? error.message : error}`);
|
|
process.exit(1);
|
|
});
|