1 parent
68e0ac1588
commit
cbcccc570e
58 files changed
+117
-5801
No files matched your search
@@ -1,143 +0,0 @@
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const CLOUDFLARE_API = 'https://api.cloudflare.com/client/v4';
|
||||
const PROJECT_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
function requireValue(value, name) {
|
||||
const normalized = value?.trim();
|
||||
if (!normalized) throw new Error(`缺少 ${name}`);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function validateName(value, name, pattern) {
|
||||
if (!pattern.test(value)) throw new Error(`${name} 格式无效:${value}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeDomain(value) {
|
||||
const domain = value?.trim().toLowerCase();
|
||||
if (!domain) return '';
|
||||
|
||||
const parsed = new URL(`https://${domain}`);
|
||||
if (parsed.hostname !== domain || parsed.port || parsed.pathname !== '/' || parsed.search || parsed.hash) {
|
||||
throw new Error(`CUSTOM_DOMAIN 必须是纯域名,不能包含协议、端口或路径:${domain}`);
|
||||
}
|
||||
return domain;
|
||||
}
|
||||
|
||||
export function deploymentSettings(env = process.env) {
|
||||
const workerName = validateName(
|
||||
env.WORKER_NAME?.trim() || 'edgessh',
|
||||
'WORKER_NAME',
|
||||
/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/,
|
||||
);
|
||||
const databaseName = validateName(
|
||||
env.D1_DATABASE_NAME?.trim() || `${workerName}-accounts`,
|
||||
'D1_DATABASE_NAME',
|
||||
/^[A-Za-z0-9](?:[A-Za-z0-9_-]{0,62}[A-Za-z0-9])?$/,
|
||||
);
|
||||
|
||||
return {
|
||||
accountId: requireValue(env.CLOUDFLARE_ACCOUNT_ID, 'CLOUDFLARE_ACCOUNT_ID'),
|
||||
apiToken: requireValue(env.CLOUDFLARE_API_TOKEN, 'CLOUDFLARE_API_TOKEN'),
|
||||
workerName,
|
||||
databaseName,
|
||||
customDomain: normalizeDomain(env.CUSTOM_DOMAIN),
|
||||
};
|
||||
}
|
||||
|
||||
async function cloudflareRequest(settings, pathname, init, fetchImpl) {
|
||||
const response = await fetchImpl(`${CLOUDFLARE_API}/accounts/${settings.accountId}${pathname}`, {
|
||||
...init,
|
||||
headers: {
|
||||
Authorization: `Bearer ${settings.apiToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
...init?.headers,
|
||||
},
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok || !payload.success) {
|
||||
const message = payload.errors?.map((error) => error.message).filter(Boolean).join('; ') || response.statusText;
|
||||
throw new Error(`Cloudflare API 请求失败(${response.status}):${message}`);
|
||||
}
|
||||
return payload.result;
|
||||
}
|
||||
|
||||
async function findDatabase(settings, fetchImpl) {
|
||||
const query = new URLSearchParams({ name: settings.databaseName, per_page: '100' });
|
||||
const databases = await cloudflareRequest(settings, `/d1/database?${query}`, undefined, fetchImpl);
|
||||
return databases.find((database) => database.name === settings.databaseName);
|
||||
}
|
||||
|
||||
export async function ensureDatabase(settings, fetchImpl = fetch) {
|
||||
const existing = await findDatabase(settings, fetchImpl);
|
||||
if (existing) return { id: existing.uuid, created: false };
|
||||
|
||||
try {
|
||||
const created = await cloudflareRequest(
|
||||
settings,
|
||||
'/d1/database',
|
||||
{ method: 'POST', body: JSON.stringify({ name: settings.databaseName }) },
|
||||
fetchImpl,
|
||||
);
|
||||
return { id: created.uuid, created: true };
|
||||
} catch (error) {
|
||||
// 并行部署可能在查询后率先创建同名数据库,重新查询即可继续复用该资源。
|
||||
const concurrentlyCreated = await findDatabase(settings, fetchImpl);
|
||||
if (concurrentlyCreated) return { id: concurrentlyCreated.uuid, created: false };
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function replaceOnce(source, pattern, replacement, label) {
|
||||
if (!pattern.test(source)) throw new Error(`wrangler.toml 缺少 ${label} 配置`);
|
||||
return source.replace(pattern, replacement);
|
||||
}
|
||||
|
||||
export function renderWranglerConfig(baseConfig, settings, databaseId) {
|
||||
let config = replaceOnce(baseConfig, /^name = ".*"$/m, `name = ${JSON.stringify(settings.workerName)}`, 'Worker name');
|
||||
config = replaceOnce(
|
||||
config,
|
||||
/^database_name = ".*"$/m,
|
||||
`database_name = ${JSON.stringify(settings.databaseName)}`,
|
||||
'D1 database_name',
|
||||
);
|
||||
config = replaceOnce(
|
||||
config,
|
||||
/^database_id = ".*"$/m,
|
||||
`database_id = ${JSON.stringify(databaseId)}`,
|
||||
'D1 database_id',
|
||||
);
|
||||
|
||||
if (settings.customDomain) {
|
||||
const routes = `routes = [\n { pattern = ${JSON.stringify(settings.customDomain)}, custom_domain = true }\n]`;
|
||||
config = replaceOnce(config, /^preview_urls = false$/m, `preview_urls = false\n\n${routes}`, 'preview_urls');
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
export async function prepareDeployment(env = process.env, fetchImpl = fetch) {
|
||||
const settings = deploymentSettings(env);
|
||||
const baseConfigPath = env.WRANGLER_BASE_CONFIG || path.join(PROJECT_ROOT, 'wrangler.toml');
|
||||
const deployConfigPath = env.WRANGLER_DEPLOY_CONFIG || path.join(PROJECT_ROOT, 'wrangler.deploy.toml');
|
||||
const database = await ensureDatabase(settings, fetchImpl);
|
||||
const baseConfig = await readFile(baseConfigPath, 'utf8');
|
||||
const deployConfig = renderWranglerConfig(baseConfig, settings, database.id);
|
||||
|
||||
await mkdir(path.dirname(deployConfigPath), { recursive: true });
|
||||
await writeFile(deployConfigPath, deployConfig, 'utf8');
|
||||
|
||||
console.log(`${database.created ? '已创建' : '已复用'} D1 数据库:${settings.databaseName}`);
|
||||
console.log(`Worker:${settings.workerName}`);
|
||||
console.log(settings.customDomain ? `自定义域名:${settings.customDomain}` : '自定义域名:未配置,仅发布 workers.dev');
|
||||
return { settings, database, deployConfigPath };
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
prepareDeployment().catch((error) => {
|
||||
console.error(`部署准备失败:${error instanceof Error ? error.message : String(error)}`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const CLOUDFLARE_API = 'https://api.cloudflare.com/client/v4';
|
||||
const PROJECT_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const WRANGLER_BIN = path.join(PROJECT_ROOT, 'node_modules', 'wrangler', 'bin', 'wrangler.js');
|
||||
|
||||
function requireValue(value, name) {
|
||||
const normalized = value?.trim();
|
||||
if (!normalized) throw new Error(`缺少 ${name}`);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function pagesProjectSettings(env = process.env) {
|
||||
const projectName = env.DOCS_PROJECT_NAME?.trim() || 'edgessh-docs';
|
||||
if (!/^[a-z0-9](?:[a-z0-9-]{0,56}[a-z0-9])?$/.test(projectName)) {
|
||||
throw new Error(`DOCS_PROJECT_NAME 格式无效:${projectName}`);
|
||||
}
|
||||
|
||||
return {
|
||||
accountId: requireValue(env.CLOUDFLARE_ACCOUNT_ID, 'CLOUDFLARE_ACCOUNT_ID'),
|
||||
apiToken: requireValue(env.CLOUDFLARE_API_TOKEN, 'CLOUDFLARE_API_TOKEN'),
|
||||
projectName,
|
||||
};
|
||||
}
|
||||
|
||||
async function parseCloudflareResponse(response) {
|
||||
const payload = await response.json();
|
||||
if (!response.ok || !payload.success) {
|
||||
const message = payload.errors?.map((error) => error.message).filter(Boolean).join('; ') || response.statusText;
|
||||
throw new Error(`Cloudflare Pages API 请求失败(${response.status}):${message}`);
|
||||
}
|
||||
return payload.result;
|
||||
}
|
||||
|
||||
export async function ensurePagesProject(settings, fetchImpl = fetch) {
|
||||
const endpoint = `${CLOUDFLARE_API}/accounts/${settings.accountId}/pages/projects/${settings.projectName}`;
|
||||
const headers = { Authorization: `Bearer ${settings.apiToken}`, 'Content-Type': 'application/json' };
|
||||
const existing = await fetchImpl(endpoint, { headers });
|
||||
|
||||
if (existing.ok) {
|
||||
return { project: await parseCloudflareResponse(existing), created: false };
|
||||
}
|
||||
if (existing.status !== 404) await parseCloudflareResponse(existing);
|
||||
|
||||
const created = await fetchImpl(`${CLOUDFLARE_API}/accounts/${settings.accountId}/pages/projects`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ name: settings.projectName, production_branch: 'main' }),
|
||||
});
|
||||
return { project: await parseCloudflareResponse(created), created: true };
|
||||
}
|
||||
|
||||
export async function preparePagesProject(env = process.env, fetchImpl = fetch) {
|
||||
const settings = pagesProjectSettings(env);
|
||||
const result = await ensurePagesProject(settings, fetchImpl);
|
||||
console.log(`${result.created ? '已创建' : '已复用'} Cloudflare Pages 项目:${settings.projectName}`);
|
||||
return { settings, ...result };
|
||||
}
|
||||
|
||||
function deployPages(settings, env = process.env) {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
WRANGLER_BIN,
|
||||
'pages',
|
||||
'deploy',
|
||||
'.vitepress/dist',
|
||||
'--project-name',
|
||||
settings.projectName,
|
||||
'--branch',
|
||||
'main',
|
||||
'--commit-dirty=true',
|
||||
],
|
||||
{ cwd: path.join(PROJECT_ROOT, 'docs'), env, stdio: 'inherit' },
|
||||
);
|
||||
|
||||
if (result.error) throw result.error;
|
||||
if (result.status !== 0) throw new Error(`Cloudflare Pages 部署失败,退出码:${result.status}`);
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
preparePagesProject()
|
||||
.then(({ settings }) => {
|
||||
if (process.argv.includes('--deploy')) deployPages(settings);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(`文档站部署准备失败:${error instanceof Error ? error.message : String(error)}`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const PROJECT_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const WRANGLER_BIN = path.join(PROJECT_ROOT, 'node_modules', 'wrangler', 'bin', 'wrangler.js');
|
||||
|
||||
function requireSecret(value, name) {
|
||||
const normalized = value?.trim();
|
||||
if (!normalized) throw new Error(`缺少 ${name}`);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function accessTeamDomain(value) {
|
||||
const domain = requireSecret(value, 'ACCESS_TEAM_DOMAIN').toLowerCase();
|
||||
if (!/^[a-z0-9-]+\.cloudflareaccess\.com$/.test(domain)) {
|
||||
throw new Error('ACCESS_TEAM_DOMAIN 必须是 Cloudflare Access Team Domain,且不含协议或路径');
|
||||
}
|
||||
return domain;
|
||||
}
|
||||
|
||||
function encryptionKey(value) {
|
||||
const key = requireSecret(value, 'ENCRYPTION_KEY');
|
||||
const decoded = Buffer.from(key, 'base64');
|
||||
if (decoded.length !== 32 || decoded.toString('base64') !== key) {
|
||||
throw new Error('ENCRYPTION_KEY 必须是 32 字节安全随机数的标准 Base64');
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
export function deploymentSecrets(env = process.env) {
|
||||
return {
|
||||
ACCESS_TEAM_DOMAIN: accessTeamDomain(env.ACCESS_TEAM_DOMAIN),
|
||||
ACCESS_AUD: requireSecret(env.ACCESS_AUD, 'ACCESS_AUD'),
|
||||
ENCRYPTION_KEY: encryptionKey(env.ENCRYPTION_KEY),
|
||||
};
|
||||
}
|
||||
|
||||
export function syncWorkerSecrets(env = process.env) {
|
||||
const secrets = deploymentSecrets(env);
|
||||
const deployConfigPath = env.WRANGLER_DEPLOY_CONFIG || path.join(PROJECT_ROOT, 'wrangler.deploy.toml');
|
||||
|
||||
// Secret 仅经标准输入交给 Wrangler,避免在命令参数、配置文件或日志中留下明文。
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[WRANGLER_BIN, 'secret', 'bulk', '--config', deployConfigPath],
|
||||
{
|
||||
cwd: PROJECT_ROOT,
|
||||
env,
|
||||
input: JSON.stringify(secrets),
|
||||
stdio: ['pipe', 'inherit', 'inherit'],
|
||||
},
|
||||
);
|
||||
|
||||
if (result.error) throw result.error;
|
||||
if (result.status !== 0) throw new Error(`Wrangler Secret 同步失败,退出码:${result.status}`);
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
try {
|
||||
syncWorkerSecrets();
|
||||
} catch (error) {
|
||||
console.error(`Worker Secret 同步失败:${error instanceof Error ? error.message : String(error)}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user