207 lines
15 KiB
JavaScript
207 lines
15 KiB
JavaScript
const fs = require("fs");
|
||
const path = require("path");
|
||
|
||
const root = process.cwd();
|
||
const inter = path.join(root, ".understand-anything", "intermediate");
|
||
const tmp = path.join(root, ".understand-anything", "tmp");
|
||
const scan = JSON.parse(fs.readFileSync(path.join(inter, "scan-result.json"), "utf8"));
|
||
const batches = JSON.parse(fs.readFileSync(path.join(inter, "batches.json"), "utf8")).batches;
|
||
|
||
const fileInfo = new Map(scan.files.map((f) => [f.path, f]));
|
||
const fileNodeId = (file) => {
|
||
if (file.fileCategory === "docs") return `document:${file.path}`;
|
||
if (file.fileCategory === "config") return `config:${file.path}`;
|
||
return `file:${file.path}`;
|
||
};
|
||
const edge = (source, target, type, weight) => ({ source, target, type, direction: "forward", weight });
|
||
const complexity = (lines) => (lines > 200 ? "complex" : lines > 50 ? "moderate" : "simple");
|
||
const nameOf = (p) => p.split("/").pop();
|
||
|
||
const fileMeta = {
|
||
".agents/skills/lemana-vpn-operations/SKILL.md": ["Lemana VPN Operations skill", "Инструкции для операционных задач: установка, обновление, статусы, безопасные ответы пользователю и live-connection правила.", ["skill", "operations", "documentation"]],
|
||
".agents/skills/lemana-vpn-sso-autofill/SKILL.md": ["Lemana VPN SSO skill", "Инструкции по Bitwarden, Keychain, TOTP, Keycloak SSO, autofill и runtime-патчам openconnect-lite.", ["skill", "sso", "credentials"]],
|
||
".agents/skills/lemana-vpn-testing/SKILL.md": ["Lemana VPN Testing skill", "Инструкции по non-live проверкам, status checks, live VPN safety и финальному отчету для изменений в репозитории.", ["skill", "testing", "verification"]],
|
||
"AGENTS.md": ["AGENTS.md", "Репозиторные правила для агентов: модель системы, live VPN safety, тестовая лестница и контракт финального ответа.", ["agent-rules", "documentation", "safety"]],
|
||
"README.md": ["README.md", "Основная пользовательская документация Lemana VPN: установка, credential sources, модули, использование, диагностика и удаление.", ["documentation", "overview", "user-guide"]],
|
||
"docs/goals/manual-first-auth-flow/GOAL.md": ["Manual-first auth goal", "Краткое описание цели manual-first authentication flow и ожидаемого пользовательского результата.", ["goal", "auth-flow", "planning"]],
|
||
"docs/goals/manual-first-auth-flow/PLAN.md": ["Manual-first auth plan", "Подробный план изменения installer UX, CLI auth profiles, Swift menu-bar surface и smoke coverage для manual-first сценария.", ["plan", "auth-flow", "implementation"]],
|
||
"docs/superpowers/plans/2026-05-24-keychain-totp-provider.md": ["Keychain TOTP provider plan", "Исторический implementation plan для разделения credential sources, нормализации TOTP и расширения smoke-проверок.", ["plan", "keychain", "totp"]],
|
||
"app/Package.swift": ["Package.swift", "Swift Package manifest для сборки executable target LemanaVPN под macOS 13+.", ["swift", "build-system", "menu-bar-app"]],
|
||
"app/Sources/LemanaVPN/LemanaVPNApp.swift": ["LemanaVPNApp.swift", "SwiftUI/AppKit menu bar surface: отображает состояние VPN, кнопки подключения, логи и действия пользователя.", ["swiftui", "menu-bar", "ui"]],
|
||
"app/Sources/LemanaVPN/VPNManager.swift": ["VPNManager.swift", "Основной state manager Swift-приложения: запускает CLI в JSON mode, декодирует события, отслеживает health и управляет reconnect/disconnect.", ["swift", "state-manager", "cli-bridge"]],
|
||
"bin/vpn-lemanapro.sh": ["vpn-lemanapro.sh", "Главный runtime CLI: status JSON, sync credentials, Keychain/TOTP, runtime patches, SSO запуск, reconnect loop, DNS cleanup и фильтрация openconnect-lite output.", ["cli", "runtime", "sso"]],
|
||
"install.sh": ["install.sh", "Installer wizard для Homebrew/pipx/openconnect-lite, CLI, config, DNS wrapper, sudoers, Touch ID helper, Swift app, launch agent и shell aliases.", ["installer", "wizard", "macos"]],
|
||
"uninstall.sh": ["uninstall.sh", "Uninstaller для восстановления runtime patch backups, удаления CLI/app/config/sudoers/aliases и опциональной очистки Keychain/Touch ID state.", ["uninstaller", "cleanup", "macos"]],
|
||
"libexec/lemana-vpn-dns-cleanup": ["lemana-vpn-dns-cleanup", "Узкий root-owned wrapper, который сбрасывает только корпоративные DNS и обновляет macOS resolver cache.", ["dns", "sudo-wrapper", "cleanup"]],
|
||
"templates/openconnect-lite-config.toml": ["openconnect-lite-config.toml", "Шаблон профиля openconnect-lite: gateway, credentials mode и Keycloak auto-fill selectors для username, password и TOTP.", ["configuration", "openconnect-lite", "autofill"]],
|
||
"tests/smoke.sh": ["smoke.sh", "Non-live smoke suite: shell syntax, installer/uninstaller checks, Swift wiring, SSO patch snippets, config selectors и regression assertions.", ["test", "smoke", "verification"]],
|
||
};
|
||
|
||
const functionSummaries = {
|
||
choose_credential_source_interactive: "Показывает интерактивный выбор credential source и нормализует ответ пользователя.",
|
||
choose_modules: "Определяет набор устанавливаемых модулей с учетом интерактивного режима и флагов installer.",
|
||
install_openconnect_lite: "Устанавливает или проверяет openconnect-lite runtime, необходимый для SAML SSO.",
|
||
install_cli: "Копирует основной CLI и uninstall helper в пользовательский bin.",
|
||
install_config: "Устанавливает шаблон openconnect-lite config и сохраняет локальную конфигурацию модулей.",
|
||
install_dns_cleanup: "Устанавливает root-owned DNS cleanup wrapper для безопасного сброса корпоративных DNS.",
|
||
install_sudoers: "Пишет ограниченные sudoers правила для openconnect и DNS cleanup wrapper.",
|
||
install_touchid_helper: "Собирает и устанавливает Touch ID helper для Bitwarden master password.",
|
||
install_menu_bar_app: "Собирает release-версию Swift menu bar app и устанавливает LemanaVPN.app.",
|
||
install_launch_agent: "Создает LaunchAgent для автозапуска menu bar app при логине.",
|
||
restart_running_menu_bar_app: "Перезапускает уже открытое menu bar приложение после обновления bundle.",
|
||
install_shell_aliases: "Добавляет idempotent zsh aliases для CLI режимов подключения и диагностики.",
|
||
maybe_login_bitwarden: "Проверяет Bitwarden session и при необходимости запускает login/unlock flow.",
|
||
restore_openconnect_lite_patch: "Возвращает backup исходника openconnect-lite, если runtime patch был установлен.",
|
||
remove_zshrc_block: "Удаляет idempotent блок Lemana VPN aliases из zshrc.",
|
||
remove_keychain_entries: "Опционально удаляет password, TOTP seed и username из macOS Keychain.",
|
||
_module_status_json: "Собирает машинно-читаемый JSON со статусом модулей, credentials, app, DNS и patches.",
|
||
_module_status_human: "Печатает человекочитаемую строку статуса модулей перед подключением.",
|
||
_check_status: "Проверяет PID/status file, running process, DNS target и возвращает disconnected/connected JSON.",
|
||
_patch_oc: "Применяет runtime patches к openconnect-lite для Keycloak autofill/manual/debug сценариев.",
|
||
_normalize_totp_secret: "Нормализует TOTP secret из raw secret или otpauth URI перед сохранением.",
|
||
_configure_keychain: "Интерактивно сохраняет LDAP password и TOTP seed в macOS Keychain.",
|
||
_ensure_keychain_credentials: "Проверяет наличие обязательных Keychain credentials перед auto/manual подключением.",
|
||
_bw_unlock: "Открывает Bitwarden session с учетом Touch ID helper и fallback prompts.",
|
||
_sync_bitwarden: "Синхронизирует LDAP password и TOTP seed из Bitwarden в macOS Keychain.",
|
||
_sync_credentials: "Выбирает credential source и запускает соответствующий sync/preflight путь.",
|
||
_dns_cleanup: "Вызывает narrow sudo wrapper для очистки VPN DNS при завершении сессии.",
|
||
_filter_output: "Фильтрует поток openconnect-lite, извлекает IP/expiry, пишет status JSON и события для app.",
|
||
refreshStatus: "Запускает CLI status JSON и обновляет module summary в menu bar app.",
|
||
connect: "Запускает CLI в JSON mode с выбранным режимом подключения и подписывается на stdout/stderr.",
|
||
disconnect: "Отправляет SIGINT текущему CLI process и останавливает reconnect state.",
|
||
parseEvent: "Декодирует JSON events из CLI и переводит их в Swift state updates.",
|
||
handleTermination: "Обрабатывает завершение CLI process и решает, нужен ли автоматический reconnect.",
|
||
startHealthCheck: "Периодически проверяет tunnel health и запускает reconnect при повторных failures.",
|
||
};
|
||
|
||
const swiftTypeSummaries = {
|
||
LemanaVPNApp: "Точка входа SwiftUI menu bar app, связывает VPNManager с MenuBarExtra.",
|
||
StatusBarLabel: "Компактный status bar label с иконкой и оставшимся временем сессии.",
|
||
VPNMenuView: "Основной menu UI с командами auto/manual/manual-full connect, disconnect, logs и quit.",
|
||
VPNEvent: "Decodable-модель JSON events, которые runtime CLI отправляет в Swift app.",
|
||
VPNStatusResponse: "Decodable envelope для ответа `vpn --status --json`.",
|
||
ModuleStatus: "Модель статуса модулей и вычисление summary/systemImage для menu bar app.",
|
||
VPNState: "Finite state model для disconnected/connecting/connected/reconnecting/error состояний.",
|
||
VPNLaunchMode: "Маппинг app launch modes на CLI flags `--auto`, `--manual`, `--manual-full`.",
|
||
VPNManager: "ObservableObject, который мостит Swift menu bar app с shell CLI, JSON events и health checks.",
|
||
};
|
||
|
||
function makeFileNode(file) {
|
||
const meta = fileMeta[file.path] || [nameOf(file.path), `Файл проекта ${file.path}.`, ["project-file", file.language, file.fileCategory]];
|
||
const type = file.fileCategory === "docs" ? "document" : file.fileCategory === "config" ? "config" : "file";
|
||
return {
|
||
id: fileNodeId(file),
|
||
type,
|
||
name: meta[0],
|
||
filePath: file.path,
|
||
summary: meta[1],
|
||
tags: meta[2],
|
||
complexity: complexity(file.sizeLines),
|
||
};
|
||
}
|
||
|
||
function shouldIncludeFunction(filePath, fn) {
|
||
if (fn.name === "autoFill" || fn.name === "vpn") return false;
|
||
const span = fn.endLine - fn.startLine + 1;
|
||
if (functionSummaries[fn.name]) return true;
|
||
if (fn.name === "usage" || fn.name === "main") return true;
|
||
if (/^(install_|remove_|restore_|choose_|_sync|_ensure|_configure|_patch|_check|_filter|_module|_dns)/.test(fn.name)) return true;
|
||
return span >= 18 && !/^log|color_line|die|run|need_cmd|has_tty|bool_word|_emit|_json_get|_can_prompt|_prompt_read/.test(fn.name);
|
||
}
|
||
|
||
function makeFunctionNode(filePath, fn) {
|
||
const id = `function:${filePath}:${fn.name}`;
|
||
return {
|
||
id,
|
||
type: "function",
|
||
name: fn.name,
|
||
filePath,
|
||
lineRange: [fn.startLine, fn.endLine],
|
||
summary: functionSummaries[fn.name] || `Значимая функция ${fn.name} в ${filePath}.`,
|
||
tags: ["function", filePath.includes("test") ? "verification" : "runtime", fn.name.replace(/^_+/, "").replaceAll("_", "-")],
|
||
complexity: complexity(fn.endLine - fn.startLine + 1),
|
||
};
|
||
}
|
||
|
||
function swiftDecls(filePath) {
|
||
if (!filePath.endsWith(".swift")) return [];
|
||
const text = fs.readFileSync(path.join(root, filePath), "utf8");
|
||
const lines = text.split(/\n/);
|
||
const decls = [];
|
||
lines.forEach((line, index) => {
|
||
const m = line.match(/^\s*(?:@main\s+)?(?:struct|class|enum)\s+([A-Za-z_][A-Za-z0-9_]*)/);
|
||
if (m && swiftTypeSummaries[m[1]]) {
|
||
decls.push({ name: m[1], startLine: index + 1 });
|
||
}
|
||
});
|
||
for (let i = 0; i < decls.length; i += 1) {
|
||
decls[i].endLine = (decls[i + 1]?.startLine || lines.length + 1) - 1;
|
||
}
|
||
return decls;
|
||
}
|
||
|
||
function swiftMethods(filePath) {
|
||
if (!filePath.endsWith("VPNManager.swift")) return [];
|
||
const text = fs.readFileSync(path.join(root, filePath), "utf8");
|
||
const lines = text.split(/\n/);
|
||
const wanted = new Set(["refreshStatus", "connect", "disconnect", "parseEvent", "handleTermination", "startHealthCheck"]);
|
||
const methods = [];
|
||
lines.forEach((line, index) => {
|
||
const m = line.match(/^\s*(?:private\s+)?func\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(/);
|
||
if (m && wanted.has(m[1])) methods.push({ name: m[1], startLine: index + 1 });
|
||
});
|
||
for (let i = 0; i < methods.length; i += 1) {
|
||
methods[i].endLine = (methods[i + 1]?.startLine || Math.min(lines.length + 1, methods[i].startLine + 90)) - 1;
|
||
}
|
||
return methods;
|
||
}
|
||
|
||
function graphForBatch(batch) {
|
||
const extract = JSON.parse(fs.readFileSync(path.join(tmp, `ua-file-extract-results-${batch.batchIndex}.json`), "utf8"));
|
||
const nodes = [];
|
||
const edges = [];
|
||
const resultByPath = new Map(extract.results.map((r) => [r.path, r]));
|
||
|
||
for (const file of batch.files) {
|
||
const fileNode = makeFileNode(file);
|
||
nodes.push(fileNode);
|
||
const result = resultByPath.get(file.path);
|
||
|
||
for (const fn of result?.functions || []) {
|
||
if (!shouldIncludeFunction(file.path, fn)) continue;
|
||
const node = makeFunctionNode(file.path, fn);
|
||
nodes.push(node);
|
||
edges.push(edge(fileNode.id, node.id, "contains", 1.0));
|
||
}
|
||
|
||
for (const decl of swiftDecls(file.path)) {
|
||
const node = {
|
||
id: `class:${file.path}:${decl.name}`,
|
||
type: "class",
|
||
name: decl.name,
|
||
filePath: file.path,
|
||
lineRange: [decl.startLine, decl.endLine],
|
||
summary: swiftTypeSummaries[decl.name],
|
||
tags: ["swift", "type", decl.name === "VPNManager" ? "state-manager" : "menu-bar-app"],
|
||
complexity: complexity(decl.endLine - decl.startLine + 1),
|
||
};
|
||
nodes.push(node);
|
||
edges.push(edge(fileNode.id, node.id, "contains", 1.0));
|
||
}
|
||
|
||
for (const fn of swiftMethods(file.path)) {
|
||
const node = makeFunctionNode(file.path, fn);
|
||
nodes.push(node);
|
||
edges.push(edge(fileNode.id, node.id, "contains", 1.0));
|
||
}
|
||
}
|
||
|
||
return { nodes, edges };
|
||
}
|
||
|
||
for (const batch of batches) {
|
||
const graph = graphForBatch(batch);
|
||
fs.writeFileSync(path.join(inter, `batch-${batch.batchIndex}.json`), JSON.stringify(graph, null, 2));
|
||
}
|
||
|