Bump Harbor versions and add direct .ru routing
This commit is contained in:
26
.codex/skills/manage-harbor-versions/SKILL.md
Normal file
26
.codex/skills/manage-harbor-versions/SKILL.md
Normal file
@@ -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 <base>`.
|
||||
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 <components...>`
|
||||
- `npm run version:harbor -- bump hotfix <components...>`
|
||||
5. Run `npm run version:harbor -- check <base>` 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.
|
||||
4
.codex/skills/manage-harbor-versions/agents/openai.yaml
Normal file
4
.codex/skills/manage-harbor-versions/agents/openai.yaml
Normal file
@@ -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."
|
||||
@@ -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 <base>`. Documentation- and test-only changes do not require a bump.
|
||||
|
||||
@@ -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 <major|minor|hotfix> [компонент]` и `npm run version:harbor -- check HEAD`. Правила выбора уровня закреплены в обязательном repo skill `manage-harbor-versions`.
|
||||
|
||||
## Настройки `.env`
|
||||
|
||||
Для большинства установок достаточно стандартных значений.
|
||||
|
||||
@@ -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": {
|
||||
|
||||
177
scripts/harbor-version.mjs
Normal file
177
scripts/harbor-version.mjs
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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' },
|
||||
]);
|
||||
|
||||
@@ -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);
|
||||
|
||||
39
test/version-script.test.js
Normal file
39
test/version-script.test.js
Normal file
@@ -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',
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user