Files
UNITRONIX 26038176eb feat(desktop): add native BetterDesk Desktop core and release path
Introduce the independent Rust + Flutter desktop client with CDAP/RustDesk
session support, packaging via build.py, and tag-triggered Windows/Linux
release artifacts. Align server CDAP desktop interop (view_only, desktop_end,
capabilities), protocol docs, pre-release checklist, version bump rules, and
WAN allowlist for /api/health and /api/server/pubkey.

Thanks: INSOLVE (Honorary); Marco Jakobs (@jacotec); MyNameisStitch (@MyNameisStitch); Redspin (@playerumpknow)
2026-08-20 11:08:29 +02:00

516 lines
18 KiB
JavaScript

#!/usr/bin/env node
'use strict';
/**
* BetterDesk product version bump / verify (Tier 1 + Tier 2).
*
* Usage:
* node scripts/bump-version.js --patch
* node scripts/bump-version.js --minor
* node scripts/bump-version.js --set 3.1.0
* node scripts/bump-version.js --verify
* node scripts/bump-version.js --sync
* node scripts/bump-version.js --patch --dry-run
*/
const fs = require('fs');
const path = require('path');
const REPO_ROOT = path.join(__dirname, '..');
const VERSION_FILE = path.join(REPO_ROOT, 'VERSION');
const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)$/;
const FILE_RULES = [
{
id: 'version-file',
path: 'VERSION',
extract: (content) => content.trim(),
apply: (content, version) => `${version}\n`,
},
{
id: 'package-json',
path: 'web-nodejs/package.json',
extract: (content) => {
const pkg = JSON.parse(content);
return pkg.version;
},
apply: (content, version) => {
const pkg = JSON.parse(content);
pkg.version = version;
return `${JSON.stringify(pkg, null, 2)}\n`;
},
},
{
id: 'betterdesk-sh',
path: 'betterdesk.sh',
extract: (content) => content.match(/^VERSION="([^"]+)"/m)?.[1],
apply: (content, version) => {
const fileVersion = content.match(/^VERSION="([^"]+)"/m)?.[1];
if (!fileVersion || fileVersion === version) return content;
return replaceAllLiteral(content, [
[`VERSION="${fileVersion}"`, `VERSION="${version}"`],
[`BetterDesk Console Manager v${fileVersion}`, `BetterDesk Console Manager v${version}`],
]);
},
},
{
id: 'betterdesk-ps1',
path: 'betterdesk.ps1',
extract: (content) => content.match(/^\$script:VERSION = "([^"]+)"/m)?.[1],
apply: (content, version) => {
const fileVersion = content.match(/^\$script:VERSION = "([^"]+)"/m)?.[1];
if (!fileVersion || fileVersion === version) return content;
return replaceAllLiteral(content, [
[`$script:VERSION = "${fileVersion}"`, `$script:VERSION = "${version}"`],
[`BetterDesk Console Manager v${fileVersion}`, `BetterDesk Console Manager v${version}`],
]);
},
},
{
id: 'betterdesk-docker-sh',
path: 'betterdesk-docker.sh',
extract: (content) => content.match(/^VERSION="([^"]+)"/m)?.[1],
apply: (content, version) => {
const fileVersion = content.match(/^VERSION="([^"]+)"/m)?.[1];
if (!fileVersion || fileVersion === version) return content;
return replaceAllLiteral(content, [
[`VERSION="${fileVersion}"`, `VERSION="${version}"`],
[`BetterDesk Console Manager v${fileVersion}`, `BetterDesk Console Manager v${version}`],
]);
},
},
{
id: 'readme-badge',
path: 'README.md',
extract: (content) => {
const m = content.match(/img\.shields\.io\/badge\/version-([^-]+(?:--[^-]+)?)-/);
if (!m) return null;
return m[1].replace(/--/g, '-');
},
apply: (content, version) => {
const m = content.match(/img\.shields\.io\/badge\/version-([^-]+(?:--[^-]+)?)-/);
const fileVersion = m ? m[1].replace(/--/g, '-') : null;
if (!fileVersion || fileVersion === version) return content;
const badgeOld = fileVersion.replace(/-/g, '--');
const badgeNew = version.replace(/-/g, '--');
return content
.replace(
new RegExp(`(img\\.shields\\.io\\/badge\\/version-)${escapeRegExp(badgeOld)}(-)`, 'g'),
`$1${badgeNew}$2`
)
.replace(
new RegExp(`(img\\.shields\\.io\\/badge\\/version-)${escapeRegExp(fileVersion)}(-)`, 'g'),
`$1${badgeNew}$2`
);
},
},
{
id: 'dockerfile',
path: 'Dockerfile',
extract: (content) => content.match(/LABEL version="([^"]+)"/)?.[1],
apply: (content, version) => {
const fileVersion = content.match(/LABEL version="([^"]+)"/)?.[1];
if (!fileVersion || fileVersion === version) return content;
return content.replace(`LABEL version="${fileVersion}"`, `LABEL version="${version}"`);
},
},
{
id: 'dockerfile-server',
path: 'Dockerfile.server',
extract: (content) => content.match(/LABEL version="([^"]+)"/)?.[1],
apply: (content, version) => {
const fileVersion = content.match(/LABEL version="([^"]+)"/)?.[1];
if (!fileVersion || fileVersion === version) return content;
return content.replace(`LABEL version="${fileVersion}"`, `LABEL version="${version}"`);
},
},
{
id: 'dockerfile-console',
path: 'Dockerfile.console',
extract: (content) => content.match(/LABEL version="([^"]+)"/)?.[1],
apply: (content, version) => {
const fileVersion = content.match(/LABEL version="([^"]+)"/)?.[1];
if (!fileVersion || fileVersion === version) return content;
return content.replace(`LABEL version="${fileVersion}"`, `LABEL version="${version}"`);
},
},
{
id: 'install-sh',
path: 'install.sh',
extract: (content) => {
const m = content.match(/BETTERDESK_VERSION="\$\{BETTERDESK_VERSION:-([^}]+)\}"/);
return m?.[1];
},
apply: (content, version) => {
let next = content.replace(
/BETTERDESK_VERSION="\$\{BETTERDESK_VERSION:-[^}]+\}"/,
`BETTERDESK_VERSION="\${BETTERDESK_VERSION:-${version}}"`
);
next = next.replace(
/(Docker image tag \/ release baseline \(default: )[^)]+(\))/,
`$1${version}$2`
);
return next;
},
},
{
id: 'docker-compose-quick',
path: 'docker-compose.quick.yml',
extract: (content) => {
const m = content.match(/\$\{BETTERDESK_IMAGE_TAG:-([^}]+)\}/);
return m?.[1];
},
apply: (content, version) =>
content.replace(/\$\{BETTERDESK_IMAGE_TAG:-[^}]+\}/g, `\${BETTERDESK_IMAGE_TAG:-${version}}`),
},
{
id: 'docker-compose-quick-single',
path: 'docker-compose.quick.single.yml',
extract: (content) => {
const m = content.match(/\$\{BETTERDESK_IMAGE_TAG:-([^}]+)\}/);
return m?.[1];
},
apply: (content, version) => content
.replace(/\$\{BETTERDESK_IMAGE_TAG:-[^}]+\}/g, `\${BETTERDESK_IMAGE_TAG:-${version}}`)
.replace(/(#\s+Default:\s+)[^ \t]+(\s+\|\s+Rolling:)/, `$1${version}$2`)
.replace(/(BETTERDESK_IMAGE_TAG=)[^ \s]+(\s+docker compose up -d)/, `$1${version}$2`),
},
{
id: 'docker-compose-quick-single-macvlan',
path: 'docker-compose.quick.single.macvlan.yml',
extract: (content) => {
const m = content.match(/\$\{BETTERDESK_IMAGE_TAG:-([^}]+)\}/);
return m?.[1];
},
apply: (content, version) =>
content.replace(/\$\{BETTERDESK_IMAGE_TAG:-[^}]+\}/g, `\${BETTERDESK_IMAGE_TAG:-${version}}`),
},
{
id: 'docker-compose-quick-macvlan',
path: 'docker-compose.quick.macvlan.yml',
extract: (content) => {
const m = content.match(/\$\{BETTERDESK_IMAGE_TAG:-([^}]+)\}/);
return m?.[1];
},
apply: (content, version) =>
content.replace(/\$\{BETTERDESK_IMAGE_TAG:-[^}]+\}/g, `\${BETTERDESK_IMAGE_TAG:-${version}}`),
},
{
id: 'docker-entrypoint',
path: 'docker/entrypoint.sh',
extract: (content) => content.match(/\$\{BETTERDESK_IMAGE_VERSION:-([^}]+)\}/)?.[1],
apply: (content, version) => {
const fileVersion = content.match(/\$\{BETTERDESK_IMAGE_VERSION:-([^}]+)\}/)?.[1];
if (!fileVersion || fileVersion === version) return content;
return content.replace(
`\${BETTERDESK_IMAGE_VERSION:-${fileVersion}}`,
`\${BETTERDESK_IMAGE_VERSION:-${version}}`
);
},
},
{
id: 'docker-entrypoint-root',
path: 'docker-entrypoint.sh',
extract: (content) => content.match(/\$\{BETTERDESK_IMAGE_VERSION:-([^}]+)\}/)?.[1],
apply: (content, version) => {
const fileVersion = content.match(/\$\{BETTERDESK_IMAGE_VERSION:-([^}]+)\}/)?.[1];
if (!fileVersion || fileVersion === version) return content;
return content.replace(
`\${BETTERDESK_IMAGE_VERSION:-${fileVersion}}`,
`\${BETTERDESK_IMAGE_VERSION:-${version}}`
);
},
},
{
id: 'betterdesk-server-version',
path: 'betterdesk-server/VERSION',
extract: (content) => content.trim(),
apply: (_content, version) => `${version}\n`,
},
{
id: 'betterdesk-server-productversion-embed',
path: 'betterdesk-server/internal/productversion/VERSION',
extract: (content) => content.trim(),
apply: (_content, version) => `${version}\n`,
},
{
id: 'desktop-cargo',
path: 'betterdesk-desktop/Cargo.toml',
extract: (content) => content.match(/^version\s*=\s*"([^"]+)"/m)?.[1],
apply: (content, version) =>
content.replace(/^(version\s*=\s*")[^"]+(")/m, `$1${version}$2`),
},
{
id: 'desktop-flutter-pubspec',
path: 'betterdesk-desktop/flutter/pubspec.yaml',
extract: (content) => content.match(/^version:\s*([^\s+]+)/m)?.[1],
apply: (content, version) =>
content.replace(/^(version:\s*)[^\s+]+(\+[^\s]+)?/m, `$1${version}$2`),
},
];
const CHANGELOG_PATH = 'CHANGELOG.md';
function getManagedPaths() {
const paths = FILE_RULES.map((rule) => rule.path);
if (!paths.includes(CHANGELOG_PATH)) {
paths.push(CHANGELOG_PATH);
}
return paths;
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function replaceAllLiteral(content, pairs) {
let out = content;
for (const [from, to] of pairs) {
if (from && out.includes(from)) out = out.split(from).join(to);
}
return out;
}
function readCanonicalVersion() {
if (!fs.existsSync(VERSION_FILE)) {
throw new Error('VERSION file not found');
}
const version = fs.readFileSync(VERSION_FILE, 'utf8').trim();
if (!SEMVER_RE.test(version)) {
throw new Error(`Invalid VERSION file content: ${version}`);
}
return version;
}
function parseSemver(version) {
const m = SEMVER_RE.exec(version);
if (!m) throw new Error(`Invalid semver: ${version}`);
return { major: +m[1], minor: +m[2], patch: +m[3] };
}
function formatSemver(parts) {
return `${parts.major}.${parts.minor}.${parts.patch}`;
}
function bumpPatch(version) {
const p = parseSemver(version);
p.patch += 1;
return formatSemver(p);
}
function bumpMinor(version) {
const p = parseSemver(version);
p.minor += 1;
p.patch = 0;
return formatSemver(p);
}
function readRuleFile(rule) {
const fullPath = path.join(REPO_ROOT, rule.path);
if (!fs.existsSync(fullPath)) {
return { fullPath, missing: true, content: '' };
}
return { fullPath, missing: false, content: fs.readFileSync(fullPath, 'utf8') };
}
function extractVersion(rule) {
const file = readRuleFile(rule);
if (file.missing) return { file: rule.path, missing: true, version: null };
try {
const version = rule.extract(file.content);
return { file: rule.path, missing: false, version: version || null };
} catch (err) {
return { file: rule.path, missing: false, version: null, error: err.message };
}
}
function updateChangelog(oldVersion, newVersion, dryRun) {
const changelogPath = path.join(REPO_ROOT, 'CHANGELOG.md');
if (!fs.existsSync(changelogPath)) return { changed: false, path: 'CHANGELOG.md' };
let content = fs.readFileSync(changelogPath, 'utf8');
const today = new Date().toISOString().slice(0, 10);
const unreleasedHeader = '## [Unreleased]';
const newReleaseHeader = `## [${newVersion}] — ${today}`;
if (content.includes(newReleaseHeader)) {
return { changed: false, path: 'CHANGELOG.md', skipped: true };
}
if (content.includes(unreleasedHeader)) {
content = content.replace(unreleasedHeader, newReleaseHeader);
} else {
content = `${newReleaseHeader}\n\n### Changed\n- Version bump to ${newVersion}.\n\n---\n\n${content}`;
}
const compareAnchor = `[${newVersion}]: https://github.com/UNITRONIX/BetterDesk/compare/v${oldVersion}...v${newVersion}`;
if (!content.includes(`[${newVersion}]:`)) {
content = `${content.trimEnd()}\n${compareAnchor}\n`;
}
content = `## [Unreleased]\n\n### Changed\n- _(none yet)_\n\n---\n\n${content}`;
if (!dryRun) {
fs.writeFileSync(changelogPath, content, 'utf8');
}
return { changed: true, path: 'CHANGELOG.md' };
}
function applyVersionToFiles(newVersion, { dryRun = false } = {}) {
const changed = [];
for (const rule of FILE_RULES) {
const file = readRuleFile(rule);
if (file.missing) continue;
const updated = rule.apply(file.content, newVersion);
if (updated !== file.content) {
changed.push(rule.path);
if (!dryRun) {
fs.writeFileSync(file.fullPath, updated, 'utf8');
}
}
}
return changed;
}
function applyVersion(newVersion, { dryRun = false } = {}) {
const oldVersion = readCanonicalVersion();
if (oldVersion === newVersion) {
console.log(`Version already ${newVersion}; nothing to bump.`);
return { oldVersion, newVersion, changed: [] };
}
const changed = applyVersionToFiles(newVersion, { dryRun });
const changelog = updateChangelog(oldVersion, newVersion, dryRun);
if (changelog.changed) changed.push(changelog.path);
return { oldVersion, newVersion, changed, dryRun };
}
function syncDrift({ dryRun = false } = {}) {
const canonical = readCanonicalVersion();
const changed = applyVersionToFiles(canonical, { dryRun });
return { canonical, changed, dryRun };
}
function verifyVersions() {
const canonical = readCanonicalVersion();
const mismatches = [];
for (const rule of FILE_RULES) {
const result = extractVersion(rule);
if (result.missing) {
mismatches.push({ file: result.file, expected: canonical, actual: '(missing file)' });
continue;
}
if (result.error) {
mismatches.push({ file: result.file, expected: canonical, actual: `(parse error: ${result.error})` });
continue;
}
if (result.version !== canonical) {
mismatches.push({ file: result.file, expected: canonical, actual: result.version ?? '(not found)' });
}
}
if (mismatches.length) {
console.error('Version mismatch detected (canonical VERSION = %s):', canonical);
for (const row of mismatches) {
console.error(' - %s: expected %s, got %s', row.file, row.expected, row.actual);
}
process.exit(1);
}
console.log('All Tier 1/2 version files match VERSION (%s).', canonical);
}
function printUsage() {
console.log(`Usage: node scripts/bump-version.js [--patch | --minor | --set X.Y.Z] [--dry-run] [--verify] [--list-paths]
--patch Bump patch (+0.0.1)
--minor Bump minor (+0.1.0, reset patch)
--set X.Y.Z Set explicit version
--verify Exit 1 if Tier 1/2 files disagree with VERSION
--sync Align drifted Tier 1/2 files to canonical VERSION (no bump)
--list-paths Print managed file paths (one per line) for CI git add
--dry-run Print planned version without writing files`);
}
function main() {
const args = process.argv.slice(2);
const dryRun = args.includes('--dry-run');
const verify = args.includes('--verify');
const sync = args.includes('--sync');
const listPaths = args.includes('--list-paths');
if (listPaths) {
for (const filePath of getManagedPaths()) {
console.log(filePath);
}
return;
}
if (verify) {
verifyVersions();
return;
}
if (sync) {
const result = syncDrift({ dryRun });
const prefix = dryRun ? '[dry-run] ' : '';
if (result.changed.length) {
console.log(`${prefix}Synced drifted files to VERSION (${result.canonical}):`);
for (const file of result.changed) console.log(` - ${file}`);
} else {
console.log(`${prefix}All Tier 1/2 files already match VERSION (${result.canonical}).`);
}
return;
}
let newVersion = null;
if (args.includes('--patch')) {
newVersion = bumpPatch(readCanonicalVersion());
} else if (args.includes('--minor')) {
newVersion = bumpMinor(readCanonicalVersion());
} else {
const setIdx = args.indexOf('--set');
if (setIdx !== -1 && args[setIdx + 1]) {
newVersion = args[setIdx + 1].trim();
if (!SEMVER_RE.test(newVersion)) {
console.error('Invalid --set version (expected X.Y.Z):', newVersion);
process.exit(1);
}
}
}
if (!newVersion) {
printUsage();
process.exit(1);
}
const result = applyVersion(newVersion, { dryRun });
const prefix = dryRun ? '[dry-run] ' : '';
console.log(`${prefix}Version ${result.oldVersion}${result.newVersion}`);
if (result.changed.length) {
console.log(`${prefix}Updated files:`);
for (const file of result.changed) console.log(` - ${file}`);
} else {
console.log(`${prefix}No file changes required.`);
}
}
if (require.main === module) {
main();
}
module.exports = {
FILE_RULES,
getManagedPaths,
readCanonicalVersion,
bumpPatch,
bumpMinor,
applyVersion,
syncDrift,
verifyVersions,
SEMVER_RE,
};