scripts/version.mjs
Rama main · réplica de lectura del repositorio original.
/** Synchronize the framework release unit; custom plugins keep their own versions. */
import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
export const frameworkPackages = [
'packages/plugin-manager/package.json',
'packages/plugin-kit/package.json',
'plugins/builtin/dsh-auth/package.json',
'plugins/builtin/dsh-example/package.json',
];
export const versionTemplates = [
'packages/plugin-manager/README.md.tmpl',
'packages/plugin-manager/DELIVERY.md.tmpl',
'packages/plugin-kit/README.md.tmpl',
'doc/getting-started.md.tmpl',
'doc/first-deployment.md.tmpl',
'doc/plugin-development.md.tmpl',
'doc/versioning.md.tmpl',
'examples/standalone-kit/README.md.tmpl',
'examples/standalone-plugin/README.md.tmpl',
'plugins/builtin/dsh-example/knowledge/guide.md.tmpl',
'deploy/DEPLOYMENT.md.tmpl',
'deploy/STARTERS.md.tmpl',
'plugins/builtin/dsh-example/examples/README.md.tmpl',
];
// Fixed public excerpts only. sync/check owns every rendered document; builds only consume it.
export const documentationFragments = {
'author-tools': 'doc/plugin-development.md.tmpl',
'author-pack': 'doc/plugin-development.md.tmpl',
'author-page-test': 'doc/plugin-development.md.tmpl',
'deployment-start': 'doc/first-deployment.md.tmpl',
'deployment-update': 'doc/first-deployment.md.tmpl',
'install-retry': 'deploy/README.md',
'source-rebuild': 'deploy/README.md',
'model-credentials': 'doc/framework-configuration.md',
'platform-defaults': 'doc/framework-configuration.md',
'first-login': 'doc/getting-started.md.tmpl',
'root-auth': 'doc/FAQ.md',
};
function composeDocumentation(source, read, template) {
return source.replace(/<!-- include:([^\s]+) -->/g, (_marker, id) => {
if (!Object.hasOwn(documentationFragments, id)) throw new Error(`未知文档片段 ${id}:${template}`);
const path = documentationFragments[id];
const text = read(path), start = `<!-- excerpt:${id} -->`, end = `<!-- /excerpt:${id} -->`;
const from = text.indexOf(start), to = text.indexOf(end);
if (from < 0 || to < from || text.indexOf(start, from + start.length) >= 0 || text.indexOf(end, to + end.length) >= 0) {
throw new Error(`缺失或重复文档片段 ${id}:${path}`);
}
const body = text.slice(from + start.length, to).trim();
if (!body || body.includes('<!-- include:')) throw new Error(`文档片段为空或包含嵌套引用:${path}#${id}`);
return `<!-- Excerpt from ${path}#${id}; edit its source. -->\n${body}`;
});
}
const stableVersion = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
function validateVersion(value) {
if (typeof value !== 'string' || !stableVersion.test(value)) {
throw new Error(`框架版本必须是稳定版本 X.Y.Z,收到:${JSON.stringify(value)}`);
}
return value;
}
function isOlder(version, previous) {
const left = version.split('.').map(BigInt), right = previous.split('.').map(BigInt);
for (let index = 0; index < 3; index++) {
if (left[index] !== right[index]) return left[index] < right[index];
}
return false;
}
// 发版输入核验:tools/builtin-build 是发版交付物,其 frameworkVersion 必须与框架版本一起走
// (发行包没有子模块也要检查)。
function checkDeliveredInputs(root, read, framework) {
if (!existsSync(resolve(root, 'tools/builtin-build/input.json'))) return [];
return JSON.parse(read('tools/builtin-build/input.json')).frameworkVersion === framework ? []
: ['tools/builtin-build/input.json 的 frameworkVersion 与框架版本不一致(发版输入须随发版提交一起更新)'];
}
// 宿主锚点核验:deepseek-harness/package.json 的版本是宿主侧唯一事实源,根 workspace 的
// 供应链豁免、兼容文档与私有仓说明都引用它。历史上升级宿主时漏改过这些锚点,这里在 check 时
// 集中比对,升级只需保证各锚点与子模块一致。发行包检出没有子模块,天然跳过。
function checkHostAnchors(root, read, framework) {
if (!existsSync(resolve(root, 'deepseek-harness/package.json'))) return [];
const host = JSON.parse(read('deepseek-harness/package.json')).version;
const problems = [];
const excludes = [...read('pnpm-workspace.yaml').matchAll(/'(@deepseek-ai\/[^']+)@([^']+)'/g)];
const stale = excludes.filter(([, , versions]) => !versions.split(' || ').includes(host));
if (stale.length) problems.push(`pnpm-workspace.yaml 的宿主豁免未包含 ${host}:${[...new Set(stale.map(([, name]) => name))].join('、')}`);
const compatibility = existsSync(resolve(root, 'doc/host-compatibility.md')) ? read('doc/host-compatibility.md') : '';
if (compatibility && !compatibility.includes(`\`${host}\``)) problems.push(`doc/host-compatibility.md 未提及宿主版本 ${host}`);
if (existsSync(resolve(root, 'PRIVATE.md')) && !read('PRIVATE.md').includes(host)) problems.push(`PRIVATE.md 未提及宿主版本 ${host}`);
try {
const gitlink = execFileSync('git', ['-C', root, 'rev-parse', 'HEAD:deepseek-harness'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
if (compatibility && /^[0-9a-f]{40}$/.test(gitlink) && !compatibility.includes(gitlink)) problems.push(`doc/host-compatibility.md 未提及子模块提交 ${gitlink.slice(0, 12)}`);
} catch { /* 无 git 或未提交 gitlink 时跳过提交号比对 */ }
return problems;
}
// —— 宿主 peer 版本批量写(QAa-债-3 短期项:host <版本> 子命令)——
// 宿主内部包没有稳定插件 API 门面,各 workspace 包以精确版本 pin 声明 @deepseek-ai/dsh-*
// optional peer;宿主升级时用本子命令一处改、处处写(package.json 的 peer/dev pin +
// 供应链豁免并集并列新版本),再以 check 兜底。deepseek-harness 子模块源码树不碰。
const hostVersionPattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(-[0-9A-Za-z.-]+)?$/;
// 按 pnpm-workspace.yaml 的 packages 模式展开 workspace 包清单;模式形如
// 'plugins/external/*/agents/*',通配段展开目录、最后一段 * 是包目录层。
// 子模块与 tests fixture 不在任何模式内,天然不进清单。
function workspaceManifests(root, patterns) {
const manifests = new Set();
for (const pattern of patterns) {
const segments = pattern.split('/');
if (segments.pop() !== '*') throw new Error(`不支持的 workspace 模式(结尾必须是 *):${pattern}`);
let parents = [''];
for (const segment of segments) {
const next = [];
for (const dir of parents) {
for (const entry of readdirSync(resolve(root, dir || '.'), { withFileTypes: true })) {
if (entry.isDirectory() && (segment === '*' || entry.name === segment)) {
next.push(dir ? `${dir}/${entry.name}` : entry.name);
}
}
}
parents = next;
}
// parents 是 * 包目录层的父目录;每个直接子目录含 package.json 即一个 workspace 包。
for (const dir of parents) {
for (const entry of readdirSync(resolve(root, dir || '.'), { withFileTypes: true })) {
if (entry.isDirectory() && existsSync(resolve(root, dir, entry.name, 'package.json'))) {
manifests.add(`${dir ? `${dir}/` : ''}${entry.name}/package.json`);
}
}
}
}
return [...manifests].sort();
}
export function hostPeerVersions(root, { version, dryRun = false } = {}) {
if (!hostVersionPattern.test(version)) {
throw new Error(`宿主版本必须是 X.Y.Z 或 X.Y.Z-预发布,收到:${JSON.stringify(version)}`);
}
const read = name => readFileSync(resolve(root, name), 'utf8').replace(/\r\n/g, '\n');
const yamlName = 'pnpm-workspace.yaml';
const yamlText = read(yamlName);
// 只取 packages: 块内的清单模式(豁免清单行同样是两空格缩进的 - '...',不能混入)。
const yamlLines = yamlText.split('\n');
const start = yamlLines.indexOf('packages:');
if (start < 0) throw new Error(`pnpm-workspace.yaml 缺少 packages 块`);
const patterns = [];
for (let index = start + 1; index < yamlLines.length; index += 1) {
const line = yamlLines[index];
if (line !== '' && !line.startsWith(' ')) break;
const match = line.match(/^ {2}- '([^']+)'$/);
if (match) patterns.push(match[1]);
}
const planned = new Map();
const lines = [];
let pins = 0, pinsDone = 0, excludes = 0, excludesDone = 0;
// 依赖字段里的精确 pin 文本级重写(保引号保缩进,diff 只含版本字符串);
// ^/~ 区间、workspace: 协议与已等于目标值的条目原样保留。
for (const name of workspaceManifests(root, patterns)) {
const rewritten = read(name).replace(/("@deepseek-ai\/dsh-[a-z0-9.-]+":\s*")([^"]+)(")/g, (whole, head, old) => {
if (!hostVersionPattern.test(old)) return whole;
if (old === version) {
pinsDone += 1;
return whole;
}
const pkg = head.replace(/^"/, '').replace(/":\s*"$/, '');
lines.push(` ${name} ${pkg}: ${old} → ${version}`);
pins += 1;
return `${head}${version}"`;
});
if (rewritten !== read(name)) planned.set(name, rewritten);
}
// 豁免清单是「并集」语义:追加目标版本、保留历史版本(minimumReleaseAge 冷静期
// 需要旧版本仍在清单里),引号风格与缩进逐行保留。
const yamlRewritten = yamlText.replace(/^(\s*-\s*)(['"])(@deepseek-ai\/[^'"]+)@([^'"]+)\2$/gm, (whole, lead, quote, pkg, versions) => {
if (versions.split(' || ').includes(version)) {
excludesDone += 1;
return whole;
}
lines.push(` ${yamlName} ${pkg} 豁免并列 ${version}`);
excludes += 1;
return `${lead}${quote}${pkg}@${versions} || ${version}${quote}`;
});
if (yamlRewritten !== yamlText) planned.set(yamlName, yamlRewritten);
const changed = [...planned].filter(([name, content]) => read(name) !== content);
if (!dryRun) for (const [name, content] of changed) writeFileSync(resolve(root, name), content);
// 子模块版本是宿主侧唯一事实源(check 的 checkHostAnchors 据此核验豁免清单),
// 目标版本与它不一致时只提示不阻断:演练与分步升级都允许先跑 host。
const hostManifest = 'deepseek-harness/package.json';
const note = existsSync(resolve(root, hostManifest)) && JSON.parse(read(hostManifest)).version !== version
? `提示:deepseek-harness 子模块当前版本 ${JSON.parse(read(hostManifest)).version},与目标 ${version} 不一致;正常升级流程应先升级子模块再运行 host。`
: '';
return { version, dryRun, files: planned.size, pins, pinsDone, excludes, excludesDone, lines, note };
}
export function frameworkVersion(root, { mode = 'check', version } = {}) {
if (!['check', 'sync', 'set'].includes(mode) || (mode !== 'set' && version !== undefined)) {
throw new Error('用法:node scripts/version.mjs check | sync | set X.Y.Z');
}
const read = name => readFileSync(resolve(root, name), 'utf8').replace(/\r\n/g, '\n');
const workspace = JSON.parse(read('package.json'));
const current = validateVersion(workspace.version);
const target = validateVersion(mode === 'set' ? version : current);
if (mode === 'set' && isOlder(target, current)) throw new Error('框架版本不能倒退。');
const planned = new Map();
if (mode === 'set') planned.set('package.json', JSON.stringify({ ...workspace, version: target }, null, 2) + '\n');
for (const name of frameworkPackages) {
const manifest = JSON.parse(read(name));
validateVersion(manifest.version);
if (mode !== 'check' && isOlder(target, manifest.version)) {
throw new Error(`框架版本 ${target} 低于 ${name} 的 ${manifest.version}。`);
}
planned.set(name, JSON.stringify({ ...manifest, version: target }, null, 2) + '\n');
}
for (const template of versionTemplates) {
const source = read(template);
if (!source.includes('{{FRAMEWORK_VERSION}}')) throw new Error(`模板缺少 {{FRAMEWORK_VERSION}}:${template}`);
const rendered = composeDocumentation(source, read, template).replaceAll('{{FRAMEWORK_VERSION}}', target);
const unknown = rendered.match(/\{\{[A-Z][A-Z0-9_]*\}\}/);
if (unknown) throw new Error(`未知版本模板变量 ${unknown[0]}:${template}`);
planned.set(template.slice(0, -5), `<!-- Generated from ${template} by scripts/version.mjs; edit the template. -->\n\n${rendered.trimEnd()}\n`);
}
// Read and validate every input before writing anything, including on set.
const changed = [...planned].filter(([name, content]) => !existsSync(resolve(root, name)) || read(name) !== content);
if (mode === 'check') {
const problems = [...changed.length ? [`框架版本或文档未同步:\n${changed.map(([name]) => ` ${name}`).join('\n')}\n请运行 node scripts/version.mjs sync 并提交结果。`] : [], ...checkHostAnchors(root, read, target), ...checkDeliveredInputs(root, read, target)];
if (problems.length) throw new Error(problems.join('\n'));
}
if (mode !== 'check') for (const [name, content] of changed) writeFileSync(resolve(root, name), content);
return { version: target, changed: changed.map(([name]) => name) };
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
try {
const args = process.argv.slice(2);
const dryRun = args.includes('--dry-run');
const positional = args.filter(argument => argument !== '--dry-run');
const [mode = 'check', version, ...extra] = positional;
if (extra.length) throw new Error('用法:node scripts/version.mjs check | sync | set X.Y.Z | host <版本> [--dry-run]');
const root = fileURLToPath(new URL('../', import.meta.url));
if (mode === 'host') {
if (!version) throw new Error('用法:node scripts/version.mjs host <版本> [--dry-run]');
const result = hostPeerVersions(root, { version, dryRun });
const summary = `扫描 peer/dev 精确 pin 共 ${result.pins + result.pinsDone} 处、豁免行共 ${result.excludes + result.excludesDone} 行;`
+ `${result.dryRun ? '待改' : '已改'} ${result.pins + result.excludes} 处(pin ${result.pins}、豁免并列 ${result.excludes}),涉及 ${result.files} 个文件${result.dryRun ? ';dry-run 未写入' : ''}。`;
console.log([`宿主 ${result.version}:`, ...result.lines, result.note, summary].filter(part => part !== '').join('\n'));
} else {
if (dryRun) throw new Error('--dry-run 只用于 host 子命令。');
const result = frameworkVersion(root, { mode, version });
console.log(`框架 ${result.version}:${mode === 'check' ? '版本与文档校验通过' : `同步 ${result.changed.length} 个文件`}。`);
}
} catch (error) {
console.error(error.message);
process.exitCode = 1;
}
}