diff --git a/.codex/skills/manage-harbor-versions/SKILL.md b/.codex/skills/manage-harbor-versions/SKILL.md
new file mode 100644
index 0000000..0aa4ab0
--- /dev/null
+++ b/.codex/skills/manage-harbor-versions/SKILL.md
@@ -0,0 +1,26 @@
+---
+name: manage-harbor-versions
+description: Check and bump Harbor component versions for every runtime, UI, API, dependency, packaging, or deployment-config change in this repository. Use before completing implementation work, release preparation, or any change that can alter the shipped Mac client, Gateway client, or Gateway backend.
+---
+
+# Manage Harbor Versions
+
+Treat `src/shared/versions.js` as the only component-version source. Do not use the root package version as a release version.
+
+## Required workflow
+
+1. Inspect the complete diff and choose the comparison base, normally `HEAD` for working-tree changes or the target branch for a review.
+2. Run `npm run version:harbor -- affected `.
+3. Classify the highest compatibility impact:
+ - `major`: changes an ecosystem contract or requires all cooperating components and clients to update;
+ - `minor`: changes one component and its tightly linked components while remaining compatible with other clients on the same major;
+ - `hotfix`: changes only the affected component without requiring linked components or other clients to update.
+4. Run one explicit bump command:
+ - `npm run version:harbor -- bump major`
+ - `npm run version:harbor -- bump minor `
+ - `npm run version:harbor -- bump hotfix `
+5. Run `npm run version:harbor -- check ` and the repository tests before completion.
+
+Valid component names are `mac`, `gateway-client`, and `gateway-backend`. A major bump always updates all three components. A minor bump for either Gateway component automatically updates both Gateway client and Gateway backend. A hotfix updates only the named component.
+
+Do not bump documentation- or test-only changes. If the version contract is new and the base has no `src/shared/versions.js`, keep the initial versions and let the checker report that no baseline exists.
diff --git a/.codex/skills/manage-harbor-versions/agents/openai.yaml b/.codex/skills/manage-harbor-versions/agents/openai.yaml
new file mode 100644
index 0000000..56401a8
--- /dev/null
+++ b/.codex/skills/manage-harbor-versions/agents/openai.yaml
@@ -0,0 +1,4 @@
+interface:
+ display_name: "Manage Harbor Versions"
+ short_description: "Check and bump Harbor component versions."
+ default_prompt: "Use $manage-harbor-versions to classify changes and update the required Harbor component versions."
diff --git a/AGENTS.md b/AGENTS.md
index 3e67d9a..866bb7e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -3,3 +3,5 @@
Use the checked-in `workpack/` directory as the only roadmap source. Do not require or read the original archive.
Follow `workpack/AGENTS.md` for every roadmap task, including status updates. Completed tasks must not be selected or implemented again unless the user explicitly asks to reopen one.
+
+For every runtime, UI, API, dependency or deployment-config change, use `.codex/skills/manage-harbor-versions/SKILL.md`. Before completion, classify the affected components, bump the required version level and run `npm run version:harbor -- check `. Documentation- and test-only changes do not require a bump.
diff --git a/README.md b/README.md
index 5890714..7e02c0b 100644
--- a/README.md
+++ b/README.md
@@ -219,6 +219,8 @@ cd ~/.vpn-proxy-client
Компонентные версии меняются в `src/shared/versions.js`. У всех компонентов должен совпадать `major`, у Gateway client и backend — `major.minor`; `hotfix` может отличаться. Runtime-значения доступны через `GET /api/version`.
+Для изменения версии используйте `npm run version:harbor -- affected HEAD`, затем `npm run version:harbor -- bump [компонент]` и `npm run version:harbor -- check HEAD`. Правила выбора уровня закреплены в обязательном repo skill `manage-harbor-versions`.
+
## Настройки `.env`
Для большинства установок достаточно стандартных значений.
diff --git a/package.json b/package.json
index a2d1008..aed71f5 100644
--- a/package.json
+++ b/package.json
@@ -8,6 +8,7 @@
"dev": "vite --host 0.0.0.0",
"build": "vite build",
"test": "node --test",
+ "version:harbor": "node scripts/harbor-version.mjs",
"start": "node src/server/index.js"
},
"dependencies": {
diff --git a/scripts/harbor-version.mjs b/scripts/harbor-version.mjs
new file mode 100644
index 0000000..6403636
--- /dev/null
+++ b/scripts/harbor-version.mjs
@@ -0,0 +1,177 @@
+#!/usr/bin/env node
+import { execFileSync } from 'node:child_process';
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+import { parseVersion, versionCompatibility } from '../src/shared/versions.js';
+
+const COMPONENTS = ['macClient', 'gatewayClient', 'gatewayBackend'];
+const VERSION_FILE = 'src/shared/versions.js';
+const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+const aliases = {
+ mac: 'macClient',
+ 'mac-client': 'macClient',
+ client: 'gatewayClient',
+ 'gateway-client': 'gatewayClient',
+ backend: 'gatewayBackend',
+ 'gateway-backend': 'gatewayBackend',
+};
+
+export function versionsFromSource(source) {
+ return Object.fromEntries(COMPONENTS.map((component) => {
+ const match = new RegExp(`${component}:\\s*'(\\d+\\.\\d+\\.\\d+)'`).exec(source);
+ if (!match) throw new Error(`Не найдена версия ${component}`);
+ return [component, match[1]];
+ }));
+}
+
+export function affectedComponents(files) {
+ const affected = new Set();
+ const add = (...components) => components.forEach((component) => affected.add(component));
+ for (const file of files) {
+ if (file === VERSION_FILE) continue;
+ if (/^(package-lock\.json|src\/shared\/)/.test(file)) add(...COMPONENTS);
+ else if (/^(src\/web\/|public\/|index\.html$|vite\.config\.js$)/.test(file)) {
+ add('macClient', 'gatewayClient');
+ } else if (/^src\/server\//.test(file)) add('macClient', 'gatewayBackend');
+ else if (/^(Dockerfile\.client|docker-compose\.client\.yml|entrypoint\.client\.sh|scripts\/(install-macos-client|harbor-network-monitor)\.sh)$/.test(file)) {
+ add('macClient');
+ } else if (/^(Dockerfile|Dockerfile\.runtime-base|docker-compose\.gateway\.yml|entrypoint\.sh|scripts\/(deploy-gateway|build-runtime-base|build-on-107-deploy-111)\.sh)$/.test(file)) {
+ add('gatewayBackend');
+ }
+ }
+ return COMPONENTS.filter((component) => affected.has(component));
+}
+
+function formatVersion({ major, minor, hotfix }) {
+ return `${major}.${minor}.${hotfix}`;
+}
+
+export function bumpVersions(versions, level, requested = []) {
+ const current = Object.fromEntries(COMPONENTS.map((component) => {
+ const parsed = parseVersion(versions[component]);
+ if (!parsed) throw new Error(`Некорректная версия ${component}: ${versions[component]}`);
+ return [component, parsed];
+ }));
+ if (level === 'major') {
+ const major = Math.max(...COMPONENTS.map((component) => current[component].major)) + 1;
+ return Object.fromEntries(COMPONENTS.map((component) => [component, `${major}.0.0`]));
+ }
+
+ const targets = new Set(requested.map((target) => aliases[target] || target));
+ if (!targets.size) throw new Error(`${level} требует хотя бы один компонент`);
+ for (const target of targets) {
+ if (!COMPONENTS.includes(target)) throw new Error(`Неизвестный компонент: ${target}`);
+ }
+ if (level === 'minor' && (targets.has('gatewayClient') || targets.has('gatewayBackend'))) {
+ targets.add('gatewayClient');
+ targets.add('gatewayBackend');
+ }
+ if (!['minor', 'hotfix'].includes(level)) throw new Error(`Неизвестный уровень: ${level}`);
+
+ const next = { ...versions };
+ if (level === 'minor' && targets.has('gatewayClient')) {
+ const minor = Math.max(current.gatewayClient.minor, current.gatewayBackend.minor) + 1;
+ next.gatewayClient = `${current.gatewayClient.major}.${minor}.0`;
+ next.gatewayBackend = `${current.gatewayBackend.major}.${minor}.0`;
+ targets.delete('gatewayClient');
+ targets.delete('gatewayBackend');
+ }
+ for (const target of targets) {
+ const value = current[target];
+ next[target] = level === 'minor'
+ ? `${value.major}.${value.minor + 1}.0`
+ : formatVersion({ ...value, hotfix: value.hotfix + 1 });
+ }
+ return next;
+}
+
+function git(args) {
+ return execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
+}
+
+function changedFiles(base) {
+ const tracked = git(['diff', '--name-only', base, '--']).split('\n');
+ const untracked = git(['ls-files', '--others', '--exclude-standard']).split('\n');
+ return [...new Set([...tracked, ...untracked].filter(Boolean))];
+}
+
+function baselineVersions(base) {
+ try {
+ return versionsFromSource(git(['show', `${base}:${VERSION_FILE}`]));
+ } catch {
+ return null;
+ }
+}
+
+function compareVersions(before, after) {
+ const left = parseVersion(before);
+ const right = parseVersion(after);
+ if (!left || !right) return -1;
+ for (const key of ['major', 'minor', 'hotfix']) {
+ if (right[key] !== left[key]) return right[key] > left[key] ? 1 : -1;
+ }
+ return 0;
+}
+
+function validateCompatibility(versions) {
+ const compatibility = versionCompatibility(versions);
+ if (!compatibility.major) throw new Error('У всех компонентов должен совпадать major');
+ if (!compatibility.gateway) throw new Error('Gateway client и backend должны совпадать по major.minor');
+}
+
+function writeVersions(versions) {
+ const file = path.join(root, VERSION_FILE);
+ let source = fs.readFileSync(file, 'utf8');
+ for (const component of COMPONENTS) {
+ source = source.replace(
+ new RegExp(`(${component}:\\s*')\\d+\\.\\d+\\.\\d+(')`),
+ `$1${versions[component]}$2`,
+ );
+ }
+ fs.writeFileSync(file, source);
+}
+
+function printVersions(versions) {
+ for (const component of COMPONENTS) console.log(`${component}: ${versions[component]}`);
+}
+
+function main([command = 'check', ...args]) {
+ const source = fs.readFileSync(path.join(root, VERSION_FILE), 'utf8');
+ const current = versionsFromSource(source);
+ validateCompatibility(current);
+
+ if (command === 'bump') {
+ const next = bumpVersions(current, args[0], args.slice(1));
+ validateCompatibility(next);
+ writeVersions(next);
+ printVersions(next);
+ return;
+ }
+
+ const base = args[0] || 'HEAD';
+ const affected = affectedComponents(changedFiles(base));
+ if (command === 'affected') {
+ console.log(affected.length ? affected.join('\n') : 'Нет изменений, требующих bump.');
+ return;
+ }
+ if (command !== 'check') throw new Error(`Неизвестная команда: ${command}`);
+
+ const baseline = baselineVersions(base);
+ if (!baseline) {
+ console.log('Version contract создаётся впервые; baseline для bump отсутствует.');
+ return;
+ }
+ const missing = affected.filter((component) => compareVersions(baseline[component], current[component]) <= 0);
+ if (missing.length) throw new Error(`Не повышена версия: ${missing.join(', ')}`);
+ console.log(affected.length ? `Version check: ${affected.join(', ')}` : 'Version check: bump не требуется.');
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
+ try {
+ main(process.argv.slice(2));
+ } catch (error) {
+ console.error(`[harbor-version] ${error.message}`);
+ process.exitCode = 1;
+ }
+}
diff --git a/src/server/singbox.js b/src/server/singbox.js
index 9ef1360..c3eb8ae 100644
--- a/src/server/singbox.js
+++ b/src/server/singbox.js
@@ -48,9 +48,11 @@ export function buildGatewayConfig(subscriptionConfig, selectedTag, { clientDire
set_system_proxy: false,
},
];
+ const directRules = [{ domain_suffix: ['ru'], outbound: 'direct' }];
const rules = clientMode
- ? [{ inbound: [MIXED_INBOUND], outbound: outboundTag }]
+ ? [...directRules, { inbound: [MIXED_INBOUND], outbound: outboundTag }]
: [
+ ...directRules,
{ inbound: [TPROXY_INBOUND], outbound: outboundTag },
{ inbound: [MIXED_INBOUND], outbound: outboundTag },
];
diff --git a/src/shared/versions.js b/src/shared/versions.js
index e2870df..195baa5 100644
--- a/src/shared/versions.js
+++ b/src/shared/versions.js
@@ -1,7 +1,7 @@
export const HARBOR_VERSIONS = Object.freeze({
- macClient: '0.1.0',
- gatewayClient: '0.1.0',
- gatewayBackend: '0.1.0',
+ macClient: '0.2.0',
+ gatewayClient: '0.2.0',
+ gatewayBackend: '0.2.0',
});
export function parseVersion(value) {
diff --git a/src/web/components/ClientOverviewPage.jsx b/src/web/components/ClientOverviewPage.jsx
index 486458e..94b28c7 100644
--- a/src/web/components/ClientOverviewPage.jsx
+++ b/src/web/components/ClientOverviewPage.jsx
@@ -38,15 +38,12 @@ function VersionBadge({ code, component, componentKey, version, singBox, incompa
function description(key) {
if (key === 'major') {
- return 'Общий уровень совместимости Harbor. При его изменении обновляются Mac и вся Gateway-инфраструктура.';
- }
- if (key === 'minor' && componentKey === 'macClient') {
- return 'Линия Mac-клиента. Gateway может менять minor без обязательного обновления Mac.';
+ return 'Уровень совместимости всей экосистемы. Все совместно работающие компоненты и клиенты должны иметь одинаковый major.';
}
if (key === 'minor') {
- return 'Линия Gateway. Gateway client и backend должны совпадать по major.minor.';
+ return 'Уровень конкретного компонента и тесно связанной с ним части системы. Остальные клиенты с тем же major могут обновляться отдельно.';
}
- return `Совместимое исправление только компонента ${component}; hotfix может обновляться независимо.`;
+ return 'Локальное совместимое исправление этой версии. Не требует обновлять связанные компоненты или другие клиенты.';
}
return (
diff --git a/src/web/styles.css b/src/web/styles.css
index 37bfb89..8cb3a19 100644
--- a/src/web/styles.css
+++ b/src/web/styles.css
@@ -199,16 +199,16 @@ p {
z-index: 40;
display: grid;
justify-items: end;
- gap: 2px;
+ gap: 4px;
color: var(--client-muted);
- font: 600 9px/1 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
+ font: 650 12px/1 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
font-variant-numeric: tabular-nums;
}
.harbor-version {
display: flex;
align-items: center;
- gap: 4px;
+ gap: 6px;
opacity: 0.56;
transition: color 240ms ease, opacity 240ms ease;
}
@@ -227,7 +227,7 @@ p {
.harbor-version-code {
width: 1.3ch;
color: var(--client-accent);
- font-size: 8px;
+ font-size: 10px;
text-align: center;
}
@@ -239,8 +239,8 @@ p {
.harbor-version-part {
position: relative;
min-width: 1ch;
- padding: 3px 1px;
- border-radius: 4px;
+ padding: 5px 2px;
+ border-radius: 5px;
cursor: help;
text-align: center;
}
@@ -260,16 +260,16 @@ p {
position: absolute;
right: 0;
bottom: calc(100% + 7px);
- width: min(250px, calc(100vw - 28px));
+ width: min(300px, calc(100vw - 28px));
display: grid;
- gap: 5px;
- padding: 9px 10px;
- border-radius: 10px;
+ gap: 7px;
+ padding: 12px 13px;
+ border-radius: 12px;
background: color-mix(in oklch, var(--client-panel) 84%, transparent);
box-shadow: 0 9px 30px oklch(0.08 0.015 145 / 0.16);
backdrop-filter: blur(12px) saturate(0.9);
color: var(--client-muted);
- font-size: 9px;
+ font-size: 11px;
font-weight: 500;
line-height: 1.5;
text-align: left;
@@ -283,12 +283,12 @@ p {
.harbor-version-tooltip strong {
color: var(--client-text);
- font-size: 9px;
+ font-size: 11px;
}
.harbor-version-tooltip small {
color: var(--client-accent);
- font-size: 8px;
+ font-size: 10px;
}
.harbor-version-tooltip .is-warning {
diff --git a/test/server/singbox-client-mode.test.js b/test/server/singbox-client-mode.test.js
index 141f62b..6b8fa14 100644
--- a/test/server/singbox-client-mode.test.js
+++ b/test/server/singbox-client-mode.test.js
@@ -27,6 +27,7 @@ test('client exposes one local proxy and routes it through the selected VPN', ()
assert.deepEqual(config.inbounds.map((inbound) => inbound.tag), ['mixed-in']);
assert.equal(config.inbounds[0].listen_port, 8082);
assert.deepEqual(config.route.rules, [
+ { domain_suffix: ['ru'], outbound: 'direct' },
{ inbound: ['mixed-in'], outbound: 'test-vpn' },
]);
assert.equal(config.route.final, 'test-vpn');
@@ -37,6 +38,7 @@ test('client keeps its local proxy but routes directly when Harbor Gateway is ah
const config = buildGatewayConfig(subscriptionConfig, 'test-vpn', { clientDirect: true });
assert.deepEqual(config.route.rules, [
+ { domain_suffix: ['ru'], outbound: 'direct' },
{ inbound: ['mixed-in'], outbound: 'direct' },
]);
assert.equal(config.route.final, 'direct');
diff --git a/test/server/singbox-gateway-mode.test.js b/test/server/singbox-gateway-mode.test.js
index ca6d409..0d7fa78 100644
--- a/test/server/singbox-gateway-mode.test.js
+++ b/test/server/singbox-gateway-mode.test.js
@@ -21,11 +21,12 @@ const subscriptionConfig = {
}],
};
-test('gateway routes transparent and proxy traffic only through the selected VPN', () => {
+test('gateway routes .ru domains directly and other traffic through the selected VPN', () => {
const config = buildGatewayConfig(subscriptionConfig, 'test-vpn');
assert.deepEqual(config.route.rule_set, []);
assert.deepEqual(config.route.rules, [
+ { domain_suffix: ['ru'], outbound: 'direct' },
{ inbound: ['tproxy-in'], outbound: 'test-vpn' },
{ inbound: ['mixed-in'], outbound: 'test-vpn' },
]);
diff --git a/test/server/state-contract.test.js b/test/server/state-contract.test.js
index 7c46930..4a9eb66 100644
--- a/test/server/state-contract.test.js
+++ b/test/server/state-contract.test.js
@@ -11,6 +11,7 @@ import {
createStateSnapshot,
normalizeStoredState,
} from '../../src/shared/contracts/state.js';
+import { HARBOR_VERSIONS } from '../../src/shared/versions.js';
const root = path.resolve(import.meta.dirname, '../..');
@@ -163,7 +164,7 @@ setInterval(() => {}, 60_000);
assert.deepEqual(version, {
apiVersion: 1,
location: 'mac',
- components: { macClient: '0.1.0' },
+ components: { macClient: HARBOR_VERSIONS.macClient },
runtime: { singBox: '1.12.13' },
});
assertStateSnapshot(initial);
diff --git a/test/version-script.test.js b/test/version-script.test.js
new file mode 100644
index 0000000..1cd09b0
--- /dev/null
+++ b/test/version-script.test.js
@@ -0,0 +1,39 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+
+import { affectedComponents, bumpVersions } from '../scripts/harbor-version.mjs';
+
+const versions = {
+ macClient: '2.4.3',
+ gatewayClient: '2.7.1',
+ gatewayBackend: '2.7.8',
+};
+
+test('version paths map to the components actually shipped by this repository', () => {
+ assert.deepEqual(affectedComponents(['src/web/App.jsx']), ['macClient', 'gatewayClient']);
+ assert.deepEqual(affectedComponents(['src/server/index.js']), ['macClient', 'gatewayBackend']);
+ assert.deepEqual(affectedComponents(['package-lock.json']), [
+ 'macClient',
+ 'gatewayClient',
+ 'gatewayBackend',
+ ]);
+ assert.deepEqual(affectedComponents(['README.md', 'test/server/version.test.js']), []);
+});
+
+test('version bumps follow ecosystem, linked-component and local scopes', () => {
+ assert.deepEqual(bumpVersions(versions, 'major'), {
+ macClient: '3.0.0',
+ gatewayClient: '3.0.0',
+ gatewayBackend: '3.0.0',
+ });
+ assert.deepEqual(bumpVersions(versions, 'minor', ['gateway-backend']), {
+ macClient: '2.4.3',
+ gatewayClient: '2.8.0',
+ gatewayBackend: '2.8.0',
+ });
+ assert.deepEqual(bumpVersions(versions, 'hotfix', ['mac']), {
+ macClient: '2.4.4',
+ gatewayClient: '2.7.1',
+ gatewayBackend: '2.7.8',
+ });
+});