fix(deploy): make KV detection env-aware to prevent misreporting

Step 2.5 in deploy.js was env-blind, so 'npm run deploy:dev' falsely
reported reusing the production KV while wrangler silently fell back to
the env binding. Combined with [env.development] inheriting top-level
routes, dev deploys could also hijack the production custom domain.
Make extractWorkerName / injectKvNamespaceId / findExistingKvId accept
an envName parameter, and preserve [env.development].routes across
Sync Upstream merges so 'routes = []' overrides survive upgrades.
This commit is contained in:
wuzf
2026-05-10 11:02:12 +08:00
parent ac7869f447
commit ca0e495ab4
4 changed files with 170 additions and 50 deletions

View File

@@ -11,55 +11,71 @@ export function injectWorkerVersion(configText, version) {
return updated;
}
export function extractWorkerName(configText) {
/**
* 提取 worker 名称。
* - envName=null默认返回顶层 `name`(生产环境名)
* - envName="X":在 `[env.X]` 块内查找 `name`,找不到则回落到顶层
*/
export function extractWorkerName(configText, envName = null) {
if (envName) {
const lines = configText.split('\n');
const envHeader = `[env.${envName}]`;
let inEnvBlock = false;
for (const line of lines) {
const trimmed = line.trim();
if (trimmed === envHeader) {
inEnvBlock = true;
continue;
}
if (!inEnvBlock) continue;
if (/^\[/.test(trimmed)) break; // 进入下一个 sectionenv 块结束
const nameMatch = trimmed.match(/^name\s*=\s*"([^"]+)"/);
if (nameMatch) return nameMatch[1];
}
// 未在 env 块内找到 name回落到顶层
}
const match = configText.match(/^name\s*=\s*"([^"]+)"/m);
return match ? match[1] : null;
}
export function injectKvNamespaceId(configText, id) {
// Match the first [[kv_namespaces]] block that contains binding = "SECRETS_KV"
// and is NOT inside an [env.*] section.
/**
* 把 KV namespace id 注入到指定块内(首个 binding = "SECRETS_KV" 的 [[kv_namespaces]] 数组项)。
* - envName=null默认目标为顶层 `[[kv_namespaces]]`
* - envName="X":目标为 `[[env.X.kv_namespaces]]`
*
* 已存在 id 时覆盖;不存在时插入到 binding 行后。其他块(含其它 env 的 KV 块)不会被改动。
*/
export function injectKvNamespaceId(configText, id, envName = null) {
const lines = configText.split('\n');
let inEnvSection = false;
let kvBlockStart = -1;
const targetHeader = envName
? `[[env.${envName}.kv_namespaces]]`
: '[[kv_namespaces]]';
let inTargetBlock = false;
let bindingLine = -1;
let existingIdLine = -1;
for (let i = 0; i < lines.length; i++) {
const trimmed = lines[i].trim();
// Track whether we're inside an [env.*] section
if (/^\[env\./.test(trimmed)) {
inEnvSection = true;
continue;
}
if (/^\[(?!env\.)/.test(trimmed) || /^\[\[(?!env\.)/.test(trimmed)) {
inEnvSection = false;
}
if (inEnvSection) continue;
if (trimmed === '[[kv_namespaces]]') {
kvBlockStart = i;
// 任何 section header 都视为当前块结束
if (/^\[/.test(trimmed)) {
if (inTargetBlock && bindingLine >= 0) {
break; // 已经在目标块里找到 binding停止扫描
}
inTargetBlock = trimmed === targetHeader;
bindingLine = -1;
existingIdLine = -1;
continue;
}
if (kvBlockStart >= 0) {
// End of block: next section header or empty line after content
if (/^\[/.test(trimmed) || /^\[\[/.test(trimmed)) {
if (bindingLine >= 0) break;
kvBlockStart = -1;
continue;
}
if (!inTargetBlock) continue;
if (/^binding\s*=\s*"SECRETS_KV"/.test(trimmed)) {
bindingLine = i;
}
if (/^id\s*=\s*"/.test(trimmed)) {
existingIdLine = i;
}
if (/^binding\s*=\s*"SECRETS_KV"/.test(trimmed)) {
bindingLine = i;
}
if (/^id\s*=\s*"/.test(trimmed)) {
existingIdLine = i;
}
}

View File

@@ -31,7 +31,8 @@ const versionStrategy = args.includes('--git') ? '--git' :
'';
const envIndex = args.indexOf('--env');
const envArg = envIndex !== -1 && args[envIndex + 1] ? `--env ${args[envIndex + 1]}` : '';
const envName = envIndex !== -1 && args[envIndex + 1] ? args[envIndex + 1] : null;
const envArg = envName ? `--env ${envName}` : '';
console.log('');
console.log('🚀 ========================================');
@@ -56,9 +57,10 @@ try {
// Step 2.5: 自动检测并绑定已有 KV namespace防止重复创建
console.log('🔍 Step 2.5: 检测已有 KV namespace...');
const existingKv = findExistingKvId(extractWorkerName(modifiedConfig));
const workerName = extractWorkerName(modifiedConfig, envName);
const existingKv = findExistingKvId(workerName, envName);
if (existingKv) {
modifiedConfig = injectKvNamespaceId(modifiedConfig, existingKv.id);
modifiedConfig = injectKvNamespaceId(modifiedConfig, existingKv.id, envName);
console.log(` ✅ 复用已有 KV: ${existingKv.title} (${existingKv.id})`);
} else {
console.log(' 未检测到已有 KV将由 Wrangler 自动创建');
@@ -112,27 +114,64 @@ function generateVersion(versionStrategyArg) {
return execSync(versionCmd, { encoding: 'utf-8' }).trim().split('\n')[0];
}
function findExistingKvId(workerName) {
function findExistingKvId(workerName, envName = null) {
if (!workerName) return null;
let namespaces;
try {
const output = execSync('npx wrangler kv namespace list', {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
});
const namespaces = JSON.parse(output);
if (!namespaces.length) return null;
// 按优先级匹配,覆盖 wrangler auto-provision、Dashboard 创建、老版本等命名格式
const match =
namespaces.find(ns => ns.title === `${workerName}-SECRETS_KV`) ||
namespaces.find(ns => ns.title === `${workerName}-secrets-kv`) ||
namespaces.find(ns => ns.title === 'SECRETS_KV') ||
namespaces.find(ns => ns.title === workerName) ||
namespaces.find(ns => ns.title.includes('SECRETS_KV')) ||
namespaces.find(ns => ns.title.includes('secrets-kv')) ||
(namespaces.length === 1 ? namespaces[0] : null);
return match ? { id: match.id, title: match.title } : null;
namespaces = JSON.parse(output);
} catch {
return null;
}
if (!namespaces.length) return null;
// 短名映射:开发环境常缩写为 dev / prod
const ENV_ALIASES = { development: 'dev', production: 'prod' };
const envAlias = envName ? (ENV_ALIASES[envName] || envName) : null;
// 推断 base 名(去除可能的 env 后缀worker "2fa-dev" + envAlias "dev" → base "2fa"
const stripSuffix = (name, suffix) =>
suffix && name.endsWith(`-${suffix}`) ? name.slice(0, -(suffix.length + 1)) : name;
const baseName = envAlias ? stripSuffix(workerName, envAlias) : workerName;
// 候选 title 列表,越靠前越优先
const candidates = [];
if (envName) {
candidates.push(
`${workerName}-secrets-kv`, // 2fa-dev-secrets-kv
`${workerName}-SECRETS_KV`,
`${baseName}-secrets-kv-${envAlias}`, // 2fa-secrets-kv-dev ← 当前命名
`${baseName}-secrets-kv-${envName}`, // 2fa-secrets-kv-development
`${envAlias}-${baseName}-SECRETS_KV`,
`${envName}-${baseName}-SECRETS_KV`,
`${envName}-SECRETS_KV`, // development-SECRETS_KV旧命名
);
} else {
candidates.push(
`${workerName}-secrets-kv`, // 2fa-secrets-kv ← 当前命名
`${workerName}-SECRETS_KV`,
'SECRETS_KV',
workerName,
);
}
for (const title of candidates) {
const match = namespaces.find(ns => ns.title === title);
if (match) return { id: match.id, title: match.title };
}
// env 部署只走精确匹配,避免误把生产 KV 命中给 dev
if (envName) return null;
// 顶层部署的 fuzzy 兜底(保持原有兼容性)
const fuzzy =
namespaces.find(ns => ns.title.includes('SECRETS_KV')) ||
namespaces.find(ns => ns.title.includes('secrets-kv')) ||
(namespaces.length === 1 ? namespaces[0] : null);
return fuzzy ? { id: fuzzy.id, title: fuzzy.title } : null;
}

View File

@@ -31,6 +31,9 @@ merged = mergeVarsSection(merged, local, '[vars]', ['SW_VERSION']);
// Preserve environment-specific names and vars.
merged = preserveSectionLineAssignment(merged, local, '[env.development]', 'name');
// 保留本地维护者在 dev 块内显式声明的 routes典型用法routes = [] 防止继承顶层自定义域名,
// 避免 deploy:dev 抢占生产域名)。注意:当前实现只支持单行赋值(含 routes = [])。
merged = preserveSectionLineAssignment(merged, local, '[env.development]', 'routes');
merged = mergeVarsSection(merged, local, '[env.development.vars]', ['SW_VERSION']);
// Merge KV bindings, preserving existing IDs while adopting upstream structure.

View File

@@ -36,6 +36,36 @@ describe('extractWorkerName', () => {
it('returns null when name is missing', () => {
expect(extractWorkerName('main = "src/worker.js"')).toBeNull();
});
it('extracts env-specific name when envName is provided', () => {
const config = `name = "2fa"
[env.development]
name = "2fa-dev"
[env.development.vars]
SW_VERSION = "v1"
`;
expect(extractWorkerName(config, 'development')).toBe('2fa-dev');
});
it('falls back to top-level name when env block has no name', () => {
const config = `name = "2fa"
[env.staging]
[env.staging.vars]
SW_VERSION = "v1"
`;
expect(extractWorkerName(config, 'staging')).toBe('2fa');
});
it('returns top-level name when env block does not exist', () => {
const config = `name = "2fa"
main = "src/worker.js"
`;
expect(extractWorkerName(config, 'production')).toBe('2fa');
});
});
describe('injectKvNamespaceId', () => {
@@ -81,4 +111,36 @@ binding = "SECRETS_KV"
const noKvConfig = 'name = "2fa"\n[vars]\nSW_VERSION = "v1"\n';
expect(injectKvNamespaceId(noKvConfig, 'abc123')).toBe(noKvConfig);
});
it('injects id into env.development block when envName is provided', () => {
const result = injectKvNamespaceId(baseConfig, 'dev-id', 'development');
const devBlock = result.split('[[env.development.kv_namespaces]]')[1];
expect(devBlock).toContain('id = "dev-id"');
});
it('does not modify top-level kv_namespaces when envName is provided', () => {
const result = injectKvNamespaceId(baseConfig, 'dev-id', 'development');
const topBlock = result.split('[[kv_namespaces]]')[1].split('[')[0];
expect(topBlock).not.toContain('id = "dev-id"');
});
it('replaces existing env id when envName is provided', () => {
const configWithDevId = baseConfig.replace(
'[[env.development.kv_namespaces]]\nbinding = "SECRETS_KV"',
'[[env.development.kv_namespaces]]\nbinding = "SECRETS_KV"\nid = "old-dev-id"'
);
const result = injectKvNamespaceId(configWithDevId, 'new-dev-id', 'development');
expect(result).toContain('id = "new-dev-id"');
expect(result).not.toContain('old-dev-id');
});
it('does not match a different env block when envName is provided', () => {
const configWithStaging = baseConfig + `
[[env.staging.kv_namespaces]]
binding = "SECRETS_KV"
`;
const result = injectKvNamespaceId(configWithStaging, 'dev-id', 'development');
const stagingBlock = result.split('[[env.staging.kv_namespaces]]')[1];
expect(stagingBlock).not.toContain('id = "dev-id"');
});
});