fix: make Actions deployments portable / 修复跨账户模板部署

This commit is contained in:
gdz66601 committed 2026-09-21 03:58:57 +08:00
1 parent 14ce519aca
commit 2916d69799
10 files changed
+431 -11

No files matched your search

+18 -5
View File
@@ -19,8 +19,12 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 15
env:
CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID || secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
WORKER_NAME: ${{ vars.WORKER_NAME }}
D1_DATABASE_NAME: ${{ vars.D1_DATABASE_NAME }}
D1_DATABASE_ID: ${{ vars.D1_DATABASE_ID }}
CUSTOM_DOMAIN: ${{ vars.CUSTOM_DOMAIN }}
steps:
- name: Check out repository
uses: actions/checkout@v4
@@ -34,11 +38,20 @@ jobs:
- name: Install dependencies
run: npm ci
# 先校验配置,避免构建完成后才发现缺少 Secret,或创建不完整的远端资源。
- name: Validate deployment settings
run: npm run deploy:validate
env:
ENCRYPTION_KEY: ${{ secrets.ENCRYPTION_KEY }}
ACCESS_TEAM_DOMAIN: ${{ secrets.ACCESS_TEAM_DOMAIN }}
ACCESS_AUD: ${{ secrets.ACCESS_AUD }}
- name: Check project
run: npm run check
- name: Apply D1 migrations
run: npx wrangler d1 migrations apply DB --remote
- name: Deploy Worker
- name: Provision D1, migrate, sync secrets and deploy Worker
run: npm run deploy
env:
ENCRYPTION_KEY: ${{ secrets.ENCRYPTION_KEY }}
ACCESS_TEAM_DOMAIN: ${{ secrets.ACCESS_TEAM_DOMAIN }}
ACCESS_AUD: ${{ secrets.ACCESS_AUD }}
+1
View File
@@ -11,3 +11,4 @@ AGENTS.md
deliverables/
frontend/dist-*
scripts-tmp/
.wrangler.generated.toml
+32
View File
@@ -17,7 +17,9 @@
},
"devDependencies": {
"@cloudflare/workers-types": "^5.20260727.1",
"@types/node": "^22.20.4",
"flag-icons": "^7.5.0",
"smol-toml": "^1.8.0",
"typescript": "^5.9.2",
"vite": "^7.0.6",
"wrangler": "^4.134.0"
@@ -1571,6 +1573,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.20.4",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.4.tgz",
"integrity": "sha512-zJRE40jpHtKqE/C4fgHrAKQLJuSpzEnP9ff9Y7YtoR3Wd2pwqzlekDeEuUQXjRd+QCYnVnNwuJYmhdk9XV8gvA==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@xterm/addon-fit": {
"version": "0.11.0",
"resolved": "https://mirrors.huaweicloud.com/repository/npm/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
@@ -1967,6 +1979,19 @@
"node": ">=0.12.18"
}
},
"node_modules/smol-toml": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.8.0.tgz",
"integrity": "sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">= 18"
},
"funding": {
"url": "https://github.com/sponsors/cyyynthia"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://mirrors.huaweicloud.com/repository/npm/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -2039,6 +2064,13 @@
"node": ">=20.18.1"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/unenv": {
"version": "2.0.0-rc.24",
"resolved": "https://mirrors.huaweicloud.com/repository/npm/unenv/-/unenv-2.0.0-rc.24.tgz",
+5 -2
View File
@@ -8,10 +8,11 @@
"dev": "wrangler dev",
"dev:web": "vite --config vite.config.ts",
"build:web": "vite build --config vite.config.ts",
"typecheck": "tsc -p tsconfig.worker.json --noEmit && tsc -p tsconfig.frontend.json --noEmit",
"typecheck": "tsc -p tsconfig.worker.json --noEmit && tsc -p tsconfig.frontend.json --noEmit && tsc -p tsconfig.scripts.json --noEmit",
"test": "node --test --experimental-strip-types tests/*.test.ts",
"check": "npm run typecheck && npm run test && wrangler deploy --dry-run --outdir .wrangler/build",
"deploy": "wrangler deploy"
"deploy:validate": "node --experimental-strip-types scripts/deploy.ts --validate-only",
"deploy": "node --experimental-strip-types scripts/deploy.ts"
},
"dependencies": {
"@xterm/addon-fit": "^0.11.0",
@@ -23,7 +24,9 @@
},
"devDependencies": {
"@cloudflare/workers-types": "^5.20260727.1",
"@types/node": "^22.20.4",
"flag-icons": "^7.5.0",
"smol-toml": "^1.8.0",
"typescript": "^5.9.2",
"vite": "^7.0.6",
"wrangler": "^4.134.0"
+34
View File
@@ -0,0 +1,34 @@
import type { Database, DeploymentSettings } from './deployment-config.ts';
export async function ensureDatabase(
settings: Pick<DeploymentSettings, 'accountId' | 'apiToken' | 'databaseName' | 'databaseId'>,
fetcher: typeof fetch = fetch,
): Promise<Database> {
const base = `https://api.cloudflare.com/client/v4/accounts/${settings.accountId}/d1/database`;
async function request<T>(suffix: string, method = 'GET', body?: unknown): Promise<T> {
const response = await fetcher(`${base}${suffix}`, {
method,
headers: { Authorization: `Bearer ${settings.apiToken}`, 'Content-Type': 'application/json' },
...(body ? { body: JSON.stringify(body) } : {}),
signal: AbortSignal.timeout(30_000),
});
// 不回显 API 响应体,防止远端诊断信息意外包含凭据或内部细节。
if (!response.ok) throw new Error(`D1 ${method} 请求失败(HTTP ${response.status})。请检查目标账户、数据库 ID 和 Token 的 D1 权限。`);
const payload = await response.json() as { success: boolean; result: T };
if (!payload.success) throw new Error(`D1 ${method} 请求未成功,请检查 Token 权限与账户资源配额。`);
return payload.result;
}
// 显式 ID 必须属于目标账户;不存在或无权限时直接失败,不能偷偷创建空库替代。
if (settings.databaseId) return await request<Database>(`/${settings.databaseId}`);
// 按名称复用并读取全部分页,避免已有数据库在后续页时误创建同名资源。
const pageSize = 100;
for (let page = 1; ; page++) {
const databases = await request<Database[]>(`?per_page=${pageSize}&page=${page}`);
const existing = databases.find((database) => database.name === settings.databaseName);
if (existing) return existing;
if (databases.length < pageSize) break;
}
return await request<Database>('', 'POST', { name: settings.databaseName });
}
+50
View File
@@ -0,0 +1,50 @@
import { spawn } from 'node:child_process';
import { readFile, writeFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { parse, stringify } from 'smol-toml';
import { ensureDatabase } from './cloudflare-d1.ts';
import { createDeploymentConfig, readDeploymentSettings } from './deployment-config.ts';
const root = fileURLToPath(new URL('../', import.meta.url));
const generatedConfig = '.wrangler.generated.toml';
const wrangler = fileURLToPath(new URL('../node_modules/wrangler/bin/wrangler.js', import.meta.url));
async function runWrangler(args: string[], input?: string): Promise<void> {
await new Promise<void>((resolve, reject) => {
const child = spawn(process.execPath, [wrangler, ...args, '--config', generatedConfig], {
cwd: root,
env: { ...process.env, CI: 'true' },
stdio: [input === undefined ? 'ignore' : 'pipe', 'inherit', 'inherit'],
});
child.on('error', reject);
child.on('close', (code) => code === 0 ? resolve() : reject(new Error(`Wrangler ${args[0]} 失败(退出码 ${code})。`)));
if (input !== undefined) {
child.stdin!.on('error', reject);
child.stdin!.end(input);
}
});
}
async function main(): Promise<void> {
const template = parse(await readFile(new URL('../wrangler.toml', import.meta.url), 'utf8'));
const settings = readDeploymentSettings(template, process.env);
if (process.argv.includes('--validate-only')) {
console.log('部署配置校验通过。');
return;
}
const database = await ensureDatabase(settings);
const config = createDeploymentConfig(template, settings, database);
// 临时配置放在仓库根目录,保持 assets、main、migrations_dir 的相对路径语义。
await writeFile(new URL(`../${generatedConfig}`, import.meta.url), stringify(config), 'utf8');
console.log(`部署 Worker ${settings.workerName},使用 D1 ${database.name}(${database.uuid})。`);
await runWrangler(['d1', 'migrations', 'apply', 'DB', '--remote']);
// Secret 只通过标准输入发送,不写临时文件或命令行参数;首次部署由 Wrangler 创建草稿 Worker。
await runWrangler(['secret', 'bulk'], JSON.stringify(settings.secrets));
await runWrangler(['deploy']);
}
main().catch((error: Error) => {
console.error(error.message);
process.exitCode = 1;
});
+84
View File
@@ -0,0 +1,84 @@
import type { TomlTable } from 'smol-toml';
export const runtimeSecretNames = ['ENCRYPTION_KEY', 'ACCESS_TEAM_DOMAIN', 'ACCESS_AUD'] as const;
export type RuntimeSecrets = Record<(typeof runtimeSecretNames)[number], string>;
export interface DeploymentSettings {
accountId: string;
apiToken: string;
workerName: string;
databaseName: string;
databaseId?: string;
customDomain?: string;
secrets: RuntimeSecrets;
}
export interface Database {
uuid: string;
name: string;
}
export function readDeploymentSettings(
template: TomlTable,
env: NodeJS.ProcessEnv,
): DeploymentSettings {
const required = ['CLOUDFLARE_ACCOUNT_ID', 'CLOUDFLARE_API_TOKEN', ...runtimeSecretNames];
const missing = required.filter((name) => !env[name]?.trim());
if (missing.length) throw new Error(`缺少部署配置:${missing.join(', ')}。请在 GitHub Actions 中配置。`);
const accountId = env.CLOUDFLARE_ACCOUNT_ID!.trim();
if (!/^[a-f0-9]{32}$/i.test(accountId)) throw new Error('CLOUDFLARE_ACCOUNT_ID 必须是 32 位十六进制账户 ID。');
const workerName = env.WORKER_NAME?.trim() || String(template.name);
if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(workerName)) throw new Error('WORKER_NAME 必须是 1–63 位小写字母、数字或连字符,且不能以连字符开头。');
const databases = template.d1_databases as TomlTable[] | undefined;
const database = databases?.find((binding) => binding.binding === 'DB');
if (!database) throw new Error('wrangler.toml 缺少 DB 数据库绑定。');
// 改 Worker 名时默认隔离数据库;只有显式指定名称或 ID 才复用另一实例的数据。
const databaseName = env.D1_DATABASE_NAME?.trim()
|| (workerName === template.name ? String(database.database_name) : `${workerName}-accounts`);
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(databaseName)) throw new Error('D1_DATABASE_NAME 必须是 1–64 位字母、数字、下划线或连字符,且以字母或数字开头。');
const databaseId = env.D1_DATABASE_ID?.trim() || (database.database_id as string | undefined);
if (databaseId && !/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(databaseId)) {
throw new Error('D1_DATABASE_ID 必须是有效的数据库 UUID。');
}
const customDomain = env.CUSTOM_DOMAIN?.trim();
if (customDomain && !/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i.test(customDomain)) {
throw new Error('CUSTOM_DOMAIN 只能填写完整域名,不能包含协议、路径或通配符。');
}
const secrets = Object.fromEntries(runtimeSecretNames.map((name) => [name, env[name]!.trim()])) as RuntimeSecrets;
const key = Buffer.from(secrets.ENCRYPTION_KEY, 'base64');
if (!/^[A-Za-z0-9+/]{43}=?$/.test(secrets.ENCRYPTION_KEY)
|| key.length !== 32
|| key.toString('base64').replace(/=+$/, '') !== secrets.ENCRYPTION_KEY.replace(/=+$/, '')) {
throw new Error('ENCRYPTION_KEY 必须是 Base64 编码的 32 字节密钥;已有数据时必须继续使用原密钥。');
}
if (!/^[a-z0-9-]+\.cloudflareaccess\.com$/.test(secrets.ACCESS_TEAM_DOMAIN)) {
throw new Error('ACCESS_TEAM_DOMAIN 必须形如 team.cloudflareaccess.com,不包含 https://。');
}
if (runtimeSecretNames.some((name) => name in ((template.vars as TomlTable | undefined) ?? {}))) {
throw new Error('运行时 Secret 不得放在 wrangler.toml 的 vars 中,请改用 GitHub Actions Secrets。');
}
return {
accountId, apiToken: env.CLOUDFLARE_API_TOKEN!.trim(), workerName,
databaseName, databaseId, customDomain, secrets,
};
}
export function createDeploymentConfig(
template: TomlTable,
settings: DeploymentSettings,
database: Database,
): TomlTable {
return {
...template,
account_id: settings.accountId,
name: settings.workerName,
...(settings.customDomain ? { routes: [{ pattern: settings.customDomain, custom_domain: true }] } : {}),
d1_databases: (template.d1_databases as TomlTable[]).map((binding) => binding.binding === 'DB'
? { ...binding, database_name: database.name, database_id: database.uuid }
: binding),
};
}
+189
View File
@@ -0,0 +1,189 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { test } from 'node:test';
import { parse, stringify } from 'smol-toml';
import { ensureDatabase } from '../scripts/cloudflare-d1.ts';
import { createDeploymentConfig, readDeploymentSettings } from '../scripts/deployment-config.ts';
const templateText = await readFile(new URL('../wrangler.toml', import.meta.url), 'utf8');
const template = parse(templateText);
const env = {
CLOUDFLARE_ACCOUNT_ID: 'a'.repeat(32),
CLOUDFLARE_API_TOKEN: 'test-token-not-a-real-credential',
ENCRYPTION_KEY: Buffer.alloc(32, 1).toString('base64'),
ACCESS_TEAM_DOMAIN: 'example.cloudflareaccess.com',
ACCESS_AUD: 'test-audience',
};
const settings = readDeploymentSettings(template, env);
const database = { uuid: '00000000-0000-4000-8000-000000000001', name: 'edgessh-accounts' };
function json(result: unknown, success = true): Response {
return Response.json({ success, result });
}
test('public template contains no account, database ID, custom domain or runtime secrets', () => {
assert.equal(template.account_id, undefined);
assert.equal(template.routes, undefined);
assert.equal(template.workers_dev, true);
assert.deepEqual(template.d1_databases, [{
binding: 'DB', database_name: 'edgessh-accounts', migrations_dir: 'migrations',
}]);
assert.deepEqual(template.vars, { CONNECT_TIMEOUT_MS: '10000' });
});
test('generated config preserves application bindings and paths without persisting secrets', () => {
const before = structuredClone(template);
const config = createDeploymentConfig(template, settings, database);
assert.equal(config.account_id, env.CLOUDFLARE_ACCOUNT_ID);
assert.equal(config.name, 'edgessh');
assert.deepEqual(config.d1_databases, [{
binding: 'DB', database_name: database.name, database_id: database.uuid, migrations_dir: 'migrations',
}]);
for (const key of ['main', 'assets', 'build', 'migrations', 'durable_objects']) {
assert.deepEqual(config[key], template[key]);
}
const serialized = stringify(config);
for (const value of [env.CLOUDFLARE_API_TOKEN, ...Object.values(settings.secrets)]) {
assert.equal(serialized.includes(value), false);
}
assert.deepEqual(parse(serialized), config);
assert.deepEqual(template, before);
});
test('worker, database and custom domain can be configured for another account', () => {
const custom = readDeploymentSettings(template, {
...env, CLOUDFLARE_ACCOUNT_ID: 'b'.repeat(32), WORKER_NAME: 'my-ssh', CUSTOM_DOMAIN: 'ssh.example.com',
});
assert.equal(custom.databaseName, 'my-ssh-accounts');
const config = createDeploymentConfig(template, custom, { ...database, name: custom.databaseName });
assert.equal(config.name, 'my-ssh');
assert.equal(config.account_id, 'b'.repeat(32));
assert.deepEqual(config.routes, [{ pattern: 'ssh.example.com', custom_domain: true }]);
});
test('explicit database settings and manually configured template values are respected', () => {
const custom = readDeploymentSettings(template, {
...env, WORKER_NAME: 'another-worker', D1_DATABASE_NAME: 'shared-data', D1_DATABASE_ID: database.uuid,
});
assert.equal(custom.databaseName, 'shared-data');
assert.equal(custom.databaseId, database.uuid);
const manualTemplate = {
...template,
routes: [{ pattern: 'manual.example.com', custom_domain: true }],
d1_databases: [{ binding: 'DB', database_name: database.name, database_id: database.uuid }],
};
const manual = readDeploymentSettings(manualTemplate, env);
assert.equal(manual.databaseId, database.uuid);
assert.deepEqual(createDeploymentConfig(manualTemplate, manual, database).routes, manualTemplate.routes);
});
test('missing deployment settings fail before provisioning and do not reveal values', () => {
assert.throws(() => readDeploymentSettings(template, {}), /CLOUDFLARE_ACCOUNT_ID.*ENCRYPTION_KEY.*ACCESS_AUD/);
assert.throws(() => readDeploymentSettings(template, { ...env, ACCESS_AUD: ' ' }), /ACCESS_AUD/);
});
test('invalid account, resource, domain and runtime configuration fail validation', () => {
for (const [name, value] of Object.entries({
CLOUDFLARE_ACCOUNT_ID: 'wrong-account',
WORKER_NAME: 'invalid worker name',
D1_DATABASE_NAME: '../invalid-name',
D1_DATABASE_ID: 'wrong-database-id',
CUSTOM_DOMAIN: 'https://ssh.example.com/path',
ENCRYPTION_KEY: Buffer.alloc(16).toString('base64'),
ACCESS_TEAM_DOMAIN: 'https://example.cloudflareaccess.com',
})) {
assert.throws(() => readDeploymentSettings(template, { ...env, [name]: value }), new RegExp(name));
}
assert.throws(() => readDeploymentSettings(template, { ...env, ENCRYPTION_KEY: `${env.ENCRYPTION_KEY}!` }), /ENCRYPTION_KEY/);
assert.throws(() => readDeploymentSettings(template, { ...env, ENCRYPTION_KEY: `${env.ENCRYPTION_KEY}==` }), /ENCRYPTION_KEY/);
assert.throws(() => readDeploymentSettings({
...template, vars: { ENCRYPTION_KEY: env.ENCRYPTION_KEY },
}, env), /Secret/);
});
test('database lookup reuses an existing database without any writes', async () => {
const calls: string[] = [];
const fetcher: typeof fetch = async (input, init) => {
calls.push(String(input));
assert.equal(init?.method, 'GET');
assert.equal((init?.headers as Record<string, string>).Authorization, `Bearer ${env.CLOUDFLARE_API_TOKEN}`);
return json([database]);
};
assert.deepEqual(await ensureDatabase(settings, fetcher), database);
assert.deepEqual(calls, [`https://api.cloudflare.com/client/v4/accounts/${env.CLOUDFLARE_ACCOUNT_ID}/d1/database?per_page=100&page=1`]);
});
test('database lookup searches later pages before creating resources', async () => {
let calls = 0;
const fetcher: typeof fetch = async (input, init) => {
calls++;
assert.equal(init?.method, 'GET');
assert.equal(new URL(String(input)).searchParams.get('page'), String(calls));
return json(calls === 1
? Array.from({ length: 100 }, (_, index) => ({ uuid: String(index), name: `other-${index}` }))
: [database]);
};
assert.deepEqual(await ensureDatabase(settings, fetcher), database);
assert.equal(calls, 2);
});
test('first deployment creates once and subsequent deployment reuses the database', async () => {
let created = false;
let writes = 0;
const fetcher: typeof fetch = async (_input, init) => {
if (init?.method === 'GET') return json(created ? [database] : []);
assert.equal(init?.method, 'POST');
assert.deepEqual(JSON.parse(String(init.body)), { name: database.name });
writes++;
created = true;
return json(database);
};
assert.deepEqual(await ensureDatabase(settings, fetcher), database);
assert.deepEqual(await ensureDatabase(settings, fetcher), database);
assert.equal(writes, 1);
});
test('explicit ID is verified within the target account and uses the returned database name', async () => {
const fetcher: typeof fetch = async (input, init) => {
assert.equal(String(input), `https://api.cloudflare.com/client/v4/accounts/${settings.accountId}/d1/database/${database.uuid}`);
assert.equal(init?.method, 'GET');
return json({ ...database, name: 'existing-database' });
};
const resolved = await ensureDatabase({ ...settings, databaseId: database.uuid }, fetcher);
assert.equal(resolved.name, 'existing-database');
assert.deepEqual(createDeploymentConfig(template, settings, resolved).d1_databases, [{
binding: 'DB', database_name: 'existing-database', database_id: database.uuid, migrations_dir: 'migrations',
}]);
});
test('invalid explicit ID or denied API access never falls back to creating an empty database', async () => {
for (const status of [403, 404, 500]) {
let calls = 0;
const fetcher: typeof fetch = async () => {
calls++;
return new Response('private upstream details', { status });
};
await assert.rejects(ensureDatabase({ ...settings, databaseId: database.uuid }, fetcher), (error: Error) => {
assert.match(error.message, new RegExp(`HTTP ${status}`));
assert.equal(error.message.includes('private upstream details'), false);
return true;
});
assert.equal(calls, 1);
}
});
test('unsuccessful API envelope aborts resource preparation', async () => {
const fetcher: typeof fetch = async () => json(null, false);
await assert.rejects(ensureDatabase(settings, fetcher), /未成功/);
});
test('Actions supplies runtime secrets from Secrets and uses the shared deploy entry point', async () => {
const workflow = await readFile(new URL('../.github/workflows/deploy.yml', import.meta.url), 'utf8');
for (const name of ['ENCRYPTION_KEY', 'ACCESS_TEAM_DOMAIN', 'ACCESS_AUD']) {
assert.ok(workflow.includes(`${name}: \${{ secrets.${name} }}`));
assert.equal(workflow.includes(`vars.${name}`), false);
}
assert.ok(workflow.includes('run: npm run deploy:validate'));
assert.ok(workflow.includes('run: npm run deploy\n'));
assert.equal(workflow.includes('wrangler d1 migrations apply'), false);
});
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"allowImportingTsExtensions": true,
"types": ["node"],
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"isolatedModules": true,
"forceConsistentCasingInFileNames": true
},
"include": ["scripts/**/*.ts", "tests/deployment.test.ts"]
}
+3 -4
View File
@@ -4,9 +4,8 @@ compatibility_date = "2026-07-01"
workers_dev = true
preview_urls = false
routes = [
{ pattern = "ssh.dltwcnm.ccwu.cc", custom_domain = true }
]
# 模板默认使用用户自己的 workers.dev 地址,避免绑定原作者的域名。
# Actions 可通过 CUSTOM_DOMAIN 配置自定义域名。
[build]
command = "npm run build:web"
@@ -31,5 +30,5 @@ new_sqlite_classes = ["SSHSessionDO"]
[[d1_databases]]
binding = "DB"
database_name = "edgessh-accounts"
database_id = "2c1b7a95-a5d6-4a72-a8d2-4132b74aa268"
# 部署脚本在目标账户中查找或创建数据库,不把账户专属 ID 留在模板里。
migrations_dir = "migrations"