From 2916d697996b11bace4616561dbf71010dfc607e Mon Sep 17 00:00:00 2001 From: gdz66601 Date: Mon, 21 Sep 2026 03:58:57 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20make=20Actions=20deployments=20portable?= =?UTF-8?q?=20/=20=E4=BF=AE=E5=A4=8D=E8=B7=A8=E8=B4=A6=E6=88=B7=E6=A8=A1?= =?UTF-8?q?=E6=9D=BF=E9=83=A8=E7=BD=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 23 ++++- .gitignore | 1 + package-lock.json | 32 ++++++ package.json | 7 +- scripts/cloudflare-d1.ts | 34 +++++++ scripts/deploy.ts | 50 +++++++++ scripts/deployment-config.ts | 84 ++++++++++++++++ tests/deployment.test.ts | 189 +++++++++++++++++++++++++++++++++++ tsconfig.scripts.json | 15 +++ wrangler.toml | 7 +- 10 files changed, 431 insertions(+), 11 deletions(-) create mode 100644 scripts/cloudflare-d1.ts create mode 100644 scripts/deploy.ts create mode 100644 scripts/deployment-config.ts create mode 100644 tests/deployment.test.ts create mode 100644 tsconfig.scripts.json diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index a86dff5..20a5206 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -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 }} diff --git a/.gitignore b/.gitignore index 69b8cd0..1e53566 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ AGENTS.md deliverables/ frontend/dist-* scripts-tmp/ +.wrangler.generated.toml diff --git a/package-lock.json b/package-lock.json index 6925ab4..ce0b892 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 167baf4..2d7ac3b 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/scripts/cloudflare-d1.ts b/scripts/cloudflare-d1.ts new file mode 100644 index 0000000..e559d55 --- /dev/null +++ b/scripts/cloudflare-d1.ts @@ -0,0 +1,34 @@ +import type { Database, DeploymentSettings } from './deployment-config.ts'; + +export async function ensureDatabase( + settings: Pick, + fetcher: typeof fetch = fetch, +): Promise { + const base = `https://api.cloudflare.com/client/v4/accounts/${settings.accountId}/d1/database`; + async function request(suffix: string, method = 'GET', body?: unknown): Promise { + 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(`/${settings.databaseId}`); + + // 按名称复用并读取全部分页,避免已有数据库在后续页时误创建同名资源。 + const pageSize = 100; + for (let page = 1; ; page++) { + const databases = await request(`?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('', 'POST', { name: settings.databaseName }); +} diff --git a/scripts/deploy.ts b/scripts/deploy.ts new file mode 100644 index 0000000..bbcf386 --- /dev/null +++ b/scripts/deploy.ts @@ -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 { + await new Promise((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 { + 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; +}); diff --git a/scripts/deployment-config.ts b/scripts/deployment-config.ts new file mode 100644 index 0000000..461fe86 --- /dev/null +++ b/scripts/deployment-config.ts @@ -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), + }; +} diff --git a/tests/deployment.test.ts b/tests/deployment.test.ts new file mode 100644 index 0000000..206084d --- /dev/null +++ b/tests/deployment.test.ts @@ -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).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); +}); diff --git a/tsconfig.scripts.json b/tsconfig.scripts.json new file mode 100644 index 0000000..5585aaa --- /dev/null +++ b/tsconfig.scripts.json @@ -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"] +} diff --git a/wrangler.toml b/wrangler.toml index 8055438..6526a58 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -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"