diff --git a/.vscodeignore b/.vscodeignore index d3a10449..0bd70402 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -21,7 +21,9 @@ !content_templates/** !mitre_attack/data/parsed/** +!client/out/ !client/out/**/*.js +!server/out/ !server/out/**/*.js !out/**/*.js @@ -30,10 +32,11 @@ !client/templates/shared/** !client/templates/styles/** +!client/webview/out/ !client/webview/out/** !client/webview/node_modules/monaco-editor/min/vs/** -!client/webview/node_modules/@vscode/codicons/dist +!client/webview/node_modules/@vscode/codicons/dist/** -!client/node_modules/prettier/package.json -!client/node_modules/prettier/*.{js,cjs,mjs} -!client/node_modules/prettier/plugins/yaml.mjs +!node_modules/ +!node_modules/prettier/ +!node_modules/prettier/** diff --git a/README.en.md b/README.en.md index 6d192e52..3fe22a2f 100644 --- a/README.en.md +++ b/README.en.md @@ -33,6 +33,46 @@ Expansion options: You can easily get a ready-made development environment for XP if you use the [VSCode XP Workspace](https://github.com/Security-Experts-Community/vscode-xp-workspace) project. Everything in it is collected in a single Docker container, and editing occurs through the web version of VSCode. Details in the project repository. +### macOS Hybrid Mode + +On macOS, the extension runs locally in normal VS Code: UI, tree views, editors, language features, webviews, commands and knowledgebase file access stay on the host filesystem. Only operations that require `xp-kbt`, `siemj`, `normalizer-cli` or build tools are executed inside a Docker container. + +The backend can be any Docker container that has XP tools installed. [vscode-xp-workspace](https://github.com/g4n8g/vscode-xp-workspace) is a ready-made preset, but it is not required. The container must bind mount the same knowledgebase directory that is opened in local VS Code, for example: + +- host: `/Users/alice/Work/knowledgebase` +- container: `/workspaces/knowledgebase` + +When the extension starts in local macOS VS Code, it offers to configure the container backend. The wizard selects the local knowledgebase path, checks Docker, selects a running container or creates a new tools container, detects the container mount path, detects or asks for the KBT path, and saves settings. If xp-kbt is not found in the selected container, the wizard can download the latest `vxcontrol/xp-kbt` release, let you choose one of the recent releases, or use a manually entered KBT path. A new container is created from `mcr.microsoft.com/dotnet/sdk:8.0`, bind mounts the selected knowledgebase at `/workspaces/knowledgebase`, and then continues through the same KBT installation step. + +If the automatic notification was dismissed or you want to rerun setup later, use the `XP: Configure macOS Container Backend` command from the Command Palette. + +Temporary extension artifacts are stored in `tmp/xp-output` inside the local knowledgebase by default. Inside the container, the matching path is `xpConfig.docker.outputDirectoryPath`, which defaults to `/workspaces/knowledgebase/tmp/xp-output`. + +Relevant settings: + +- `xpConfig.toolExecutionMode`: `auto`, `local`, or `docker` +- `xpConfig.docker.containerName` +- `xpConfig.docker.workspaceHostPath` +- `xpConfig.docker.workspaceContainerPath` +- `xpConfig.docker.kbtBaseDirectory` +- `xpConfig.docker.outputDirectoryPath` +- `xpConfig.lspServerExecutablePath` +- `xpConfig.formatterExecutablePath` +- `xpConfig.macos.showContainerSetupPrompt` + +While the container is being created and KBT is being installed, the extension writes detailed progress to the `eXtraction and Processing` output channel. The setup notification includes a direct action to open that output. + +On local macOS, the legacy in-extension LSP is disabled. If you want definition lookup, hover, diagnostics and other language-server features, set `xpConfig.lspServerExecutablePath` to a native macOS `evt-xp-language-server` binary. Formatting can also use a native macOS binary via `xpConfig.formatterExecutablePath`. If that formatter path is empty, formatting falls back to Docker in Docker tool mode. + +Troubleshooting: + +- Docker not installed: install and start Docker Desktop. +- Container not running: start a container with XP tools, choose an already running container in the wizard, or rerun the setup wizard and choose to create a new container. +- Path mapping failed: check that `workspaceHostPath` points to the local knowledgebase and `workspaceContainerPath` matches the bind mount in the container. +- Tool not found in container: run the setup wizard again and choose `Download latest xp-kbt`, `Choose xp-kbt version`, or check `xpConfig.docker.kbtBaseDirectory`. +- Native LSP starts but reports schema warnings: run a build flow that generates the table schema, for example a normalization or correlation test/build that includes schema generation, and then restart or reload the extension if needed. +- Native LSP is not required on macOS: if you do not have a native `evt-xp-language-server`, leave `xpConfig.lspServerExecutablePath` empty. Docker-backed build/test operations will continue to work without LSP features. + ## Event normalization To write correlation rules, in general, you will need event normalization formulas. In our [open expertise repository](https://github.com/Security-Experts-Community/open-xp-rules) you can find basic normalization formulas. In the future there will be other types of rules in the XP language created by the community. diff --git a/README.md b/README.md index 8590aee0..35ca0557 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,46 @@ Вы можете легко получить готовое окружение для разработки на XP, если воспользуетесь проектом [VSCode XP Workspace](https://github.com/Security-Experts-Community/vscode-xp-workspace). В нём всё собрано в единый Docker-контейнер, а редактирование происходит через веб-версию VSCode. Подробности в репозитории проекта. +### Гибридный режим macOS + +На macOS расширение запускается локально в обычном VS Code: UI, tree view, редакторы, language features, webviews, команды VS Code и чтение файлов knowledgebase остаются на файловой системе хоста. Только операции, которым нужны `xp-kbt`, `siemj`, `normalizer-cli` или build tools, выполняются внутри Docker-контейнера. + +Backend может быть любым Docker-контейнером, в котором установлены XP tools. [vscode-xp-workspace](https://github.com/g4n8g/vscode-xp-workspace) остаётся готовым preset-примером, но не является обязательной зависимостью. Контейнер должен видеть ту же knowledgebase через bind mount, например: + +- host: `/Users/alice/Work/knowledgebase` +- container: `/workspaces/knowledgebase` + +При запуске в локальном VS Code на macOS расширение предлагает настроить контейнерный backend. Wizard выбирает локальный путь к knowledgebase, проверяет Docker, выбирает запущенный контейнер или создаёт новый tools-контейнер, определяет container mount path, определяет или запрашивает путь к KBT и сохраняет настройки. Если xp-kbt не найден в выбранном контейнере, wizard может скачать latest release `vxcontrol/xp-kbt`, дать выбрать одну из доступных версий релизов или использовать вручную введённый путь к KBT. Новый контейнер создаётся на базе `mcr.microsoft.com/dotnet/sdk:8.0`, монтирует выбранную knowledgebase в `/workspaces/knowledgebase` и после создания проходит тот же шаг установки KBT. + +Если автоматическое уведомление было скрыто или настройку нужно повторить позже, используйте команду `XP: Настроить контейнерный backend для macOS` из палитры команд. + +Временные артефакты расширения по умолчанию сохраняются в `tmp/xp-output` внутри локальной knowledgebase. В контейнере этому пути соответствует `xpConfig.docker.outputDirectoryPath`, по умолчанию `/workspaces/knowledgebase/tmp/xp-output`. + +Основные настройки: + +- `xpConfig.toolExecutionMode`: `auto`, `local` или `docker` +- `xpConfig.docker.containerName` +- `xpConfig.docker.workspaceHostPath` +- `xpConfig.docker.workspaceContainerPath` +- `xpConfig.docker.kbtBaseDirectory` +- `xpConfig.docker.outputDirectoryPath` +- `xpConfig.lspServerExecutablePath` +- `xpConfig.formatterExecutablePath` +- `xpConfig.macos.showContainerSetupPrompt` + +Во время создания контейнера и установки KBT расширение пишет подробный прогресс в канал вывода `eXtraction and Processing`. Из уведомления setup wizard можно сразу открыть этот канал через ссылку на output. + +На локальной macOS legacy LSP, встроенный в расширение, отключён. Если нужны переход к определению, hover, diagnostics и другие возможности language server, укажите в `xpConfig.lspServerExecutablePath` путь до нативного macOS-бинарника `evt-xp-language-server`. Форматирование тоже может использовать нативный macOS-бинарник через `xpConfig.formatterExecutablePath`. Если путь к форматтеру пустой, то в Docker-режиме форматирование будет выполняться через контейнер. + +Troubleshooting: + +- Docker not installed: установите и запустите Docker Desktop. +- Container not running: запустите контейнер с XP tools, выберите уже запущенный контейнер в wizard или повторите setup wizard и выберите создание нового контейнера. +- Path mapping failed: проверьте, что `workspaceHostPath` указывает на локальную knowledgebase, а `workspaceContainerPath` совпадает с bind mount в контейнере. +- Tool not found in container: запустите setup wizard ещё раз и выберите `Download latest xp-kbt`, `Choose xp-kbt version`, либо проверьте `xpConfig.docker.kbtBaseDirectory`. +- Native LSP starts but reports schema warnings: запустите сценарий сборки, который генерирует table schema, например сборку/тест, включающий генерацию схемы, а затем при необходимости перезапустите или перезагрузите расширение. +- Native LSP на macOS не обязателен: если у вас нет нативного `evt-xp-language-server`, оставьте `xpConfig.lspServerExecutablePath` пустым. Docker-операции сборки и тестирования продолжат работать и без LSP-функций. + ## Нормализация событий Для написания правил корреляции, в общем случае, Вам потребуются формулы нормализации событий. В нашем [открытом репозитории с экспертизой](https://github.com/Security-Experts-Community/open-xp-rules) вы сможете найти базовые формулы нормализации. В будущем там появятся другие виды правил на языке XP, созданные сообществом. diff --git a/client/src/ext/contextMenuExtension.ts b/client/src/ext/contextMenuExtension.ts deleted file mode 100644 index a0457bb8..00000000 --- a/client/src/ext/contextMenuExtension.ts +++ /dev/null @@ -1,91 +0,0 @@ -import * as vscode from 'vscode'; -import { DialogHelper } from '../helpers/dialogHelper'; -import { TestHelper } from '../helpers/testHelper'; -import { VsCodeApiHelper } from '../helpers/vsCodeApiHelper'; -import { ExceptionHelper } from '../helpers/exceptionHelper'; - -export class TestsFormatContentMenuExtension { - public static init(context: vscode.ExtensionContext) { - // Упаковка тестов перед сохранением. - vscode.workspace.onWillSaveTextDocument(async (e: vscode.TextDocumentWillSaveEvent) => { - const document = e.document; - if (document.languageId != 'test') return; - - const activeEditor = vscode.window.activeTextEditor; - const currDocument = activeEditor?.document; - if (!currDocument) { - await DialogHelper.showInfo('Документ для форматирования не открыт'); - return; - } - - try { - const testCode = currDocument.getText(); - const compressedTestCode = TestHelper.compressTestCode(testCode); - - // Заменяем текущий код на отформатированный. - const documentRange = VsCodeApiHelper.getDocumentRange(activeEditor); - activeEditor.edit((editBuilder) => { - editBuilder.replace(documentRange, compressedTestCode); - }); - } catch (error) { - ExceptionHelper.show(error, 'Не удалось упаковать кода теста'); - } - }); - - // Форматирование кода тестов при запросе форматирования. - const formatter = vscode.languages.registerDocumentFormattingEditProvider('test', { - provideDocumentFormattingEdits(document: vscode.TextDocument): vscode.TextEdit[] { - const originalTestCode = document.getText(); - const formattedTestCode = TestHelper.formatTestCodeAndEvents(originalTestCode); - const activeEditor = vscode.window.activeTextEditor; - if (!activeEditor) { - return []; - } - - const documentRage = VsCodeApiHelper.getDocumentRange(activeEditor); - return [vscode.TextEdit.replace(documentRage, formattedTestCode)]; - } - }); - context.subscriptions.push(formatter); - - // Форматирование кода теста для выделенного участка. - const rangeFormatter = vscode.languages.registerDocumentRangeFormattingEditProvider('test', { - provideDocumentRangeFormattingEdits: (document, range, options) => { - const originalTestCode = document.getText(range); - const formattedTestCode = TestHelper.formatTestCodeAndEvents(originalTestCode); - - return [vscode.TextEdit.replace(range, formattedTestCode)]; - } - }); - context.subscriptions.push(rangeFormatter); - - // Ручное или автоматическое обновление дерева контента - const compressTest = vscode.commands.registerCommand( - 'NativeEditorContextMenu.compressTest', - async (document: vscode.TextDocument) => { - const activeEditor = vscode.window.activeTextEditor; - const currDocument = activeEditor?.document; - if (!currDocument) { - await DialogHelper.showInfo('Документ для форматирования не открыт'); - return; - } - - try { - const testCode = currDocument.getText(); - const compressedTestCode = TestHelper.compressTestCode(testCode); - - // Заменяем текущий код на отформатированный. - const documentRange = VsCodeApiHelper.getDocumentRange(activeEditor); - activeEditor.edit((editBuilder) => { - editBuilder.replace(documentRange, compressedTestCode); - }); - } catch (error) { - ExceptionHelper.show(error, 'Не удалось упаковать код теста'); - } - }, - this - ); - - context.subscriptions.push(compressTest); - } -} diff --git a/client/src/extension.ts b/client/src/extension.ts index 63bb3f34..041c57c4 100644 --- a/client/src/extension.ts +++ b/client/src/extension.ts @@ -2,14 +2,16 @@ import * as path from 'path'; import * as vscode from 'vscode'; import * as fs from 'fs'; import * as os from 'os'; -import { format } from 'prettier'; +import { spawn } from 'child_process'; import { workspace, ExtensionContext } from 'vscode'; import { + InitializeParams, LanguageClient, LanguageClientOptions, ServerOptions, + StreamInfo, TransportKind } from 'vscode-languageclient/node'; @@ -39,15 +41,59 @@ import { LocalizationEditorViewProvider } from './views/localization/localizatio import { CommonCommands } from './models/command/commonCommands'; import { ToolsManager } from './models/content/toolsManager'; import { SetKBTVersionCommand } from './models/siemj/setKBTVersionCommand'; +import { MacOSContainerSetup } from './tools/macosContainerSetup'; +import { XpDocumentFormattingProvider } from './providers/xpDocumentFormattingProvider'; export let Log: Logger; -let client: LanguageClient; +let client: LanguageClient | undefined; let siemCustomPackingTaskProvider: vscode.Disposable | undefined; +interface LspStartupResult { + client?: LanguageClient; + usesKbtFormatter: boolean; +} + +class XpLanguageClient extends LanguageClient { + public constructor( + id: string, + name: string, + serverOptions: ServerOptions, + clientOptions: LanguageClientOptions, + private readonly disableWorkspaceConfigurationCapability = false + ) { + super(id, name, serverOptions, clientOptions); + } + + protected fillInitializeParams(params: InitializeParams): void { + super.fillInitializeParams(params); + + const capabilities = (params.capabilities ??= {} as InitializeParams['capabilities']); + const workspaceCapabilities = ((capabilities as any).workspace ??= {}); + if (workspaceCapabilities.workspaceFolders === undefined) { + workspaceCapabilities.workspaceFolders = true; + } + + if (this.disableWorkspaceConfigurationCapability) { + workspaceCapabilities.configuration = false; + } + + const textDocumentCapabilities = ((capabilities as any).textDocument ??= {}); + const semanticTokens = (textDocumentCapabilities.semanticTokens ??= {}); + semanticTokens.dynamicRegistration ??= false; + semanticTokens.requests ??= { range: false, full: { delta: false } }; + semanticTokens.tokenTypes ??= []; + semanticTokens.tokenModifiers ??= []; + semanticTokens.formats ??= ['relative']; + semanticTokens.multilineTokenSupport ??= true; + semanticTokens.overlappingTokenSupport ??= true; + } +} + export async function activate(context: ExtensionContext): Promise { try { // Инициализация реестр глобальных параметров. const config = await Configuration.init(context); + Log = Logger.init(config); let extensionVersion; try { @@ -58,7 +104,6 @@ export async function activate(context: ExtensionContext): Promise { Log.warn(`Failed to get the extension version`, error); } - Log = Logger.init(config); Log.info( `Extension activation ${extensionVersion ?? ''} has started '${Configuration.getExtensionDisplayName()}'` ); @@ -71,12 +116,14 @@ export async function activate(context: ExtensionContext): Promise { await UserSettingsManager.init(config); // await ToolsManager.init(config); - // Ensure automatic KBT selection happens early - try { - // This will trigger auto-selection if needed - const kbtBaseDirectory = config.getKbtBaseDirectoryOld(); - } catch (error) { - Log.warn(`Error during KBT auto-selection: ${error.message}`); + if (!config.shouldUseDockerToolRunner()) { + // Ensure automatic KBT selection happens early for local tool execution. + try { + // This will trigger auto-selection if needed + const kbtBaseDirectory = config.getKbtBaseDirectoryOld(); + } catch (error) { + Log.warn(`Error during KBT auto-selection: ${error.message}`); + } } // Automatically set configuration options if not already set @@ -94,8 +141,13 @@ export async function activate(context: ExtensionContext): Promise { ExceptionHelper.show(error); } + await MacOSContainerSetup.maybePrompt(config); + + await clearRuntimeDirectories(config); + // Конфигурирование LSP. - await configureLSPClient(client, context, config); + const lspStartupResult = await configureLSPClient(context, config); + client = lspStartupResult.client; const rootPath = vscode.workspace.workspaceFolders && vscode.workspace.workspaceFolders.length > 0 @@ -110,12 +162,7 @@ export async function activate(context: ExtensionContext): Promise { quotingType: "'" }, undefined, - (text: string) => - format(text, { - parser: 'yaml', - singleQuote: true, - tabWidth: 2 - }) + async (text: string) => text ); ContentTreeProvider.init(config, rootPath); @@ -125,31 +172,42 @@ export async function activate(context: ExtensionContext): Promise { MetainfoViewProvider.init(config); RunningCorrelationGraphProvider.init(config); TableListsEditorViewProvider.init(config); - const kbtVersionsDirectory = config.getKbtVersionsDirectory(); - if (kbtVersionsDirectory) { + const kbtVersionsDirectory = config.shouldUseDockerToolRunner() + ? undefined + : config.getKbtVersionsDirectory(); + if (kbtVersionsDirectory && !config.shouldUseDockerToolRunner()) { await SetKBTVersionCommand.init(config); } else { - config.setSIEMJVersion(); + try { + await config.setSIEMJVersion(); + } catch (error) { + Log.warn(`Failed to determine SIEMJ version: ${error.message}`); + if (!config.isLocalMacOS()) { + throw error; + } + } } SetContentTypeCommand.init(config); InitKBRootCommand.init(config); RetroCorrelationViewController.init(config); CommonCommands.init(config); - siemCustomPackingTaskProvider = vscode.tasks.registerTaskProvider( XPPackingTaskProvider.Type, new XPPackingTaskProvider(config) ); - // Расширение нативного контекстного меню. - // TestsFormatContentMenuExtension.init(context); - // Подпись функций. await XpSignatureHelpProvider.init(context); // Автодополнение функций. await XpCompletionItemProvider.init(config); await XpEnumValuesCompletionItemProvider.init(config); + if (lspStartupResult.usesKbtFormatter) { + Log.info('Skipping fallback XP formatter registration because KBT LSP formatting is active.'); + } else { + XpDocumentFormattingProvider.init(config); + Log.info('Registered fallback XP formatter because KBT LSP formatting is not active.'); + } context.subscriptions.push( vscode.languages.registerRenameProvider( @@ -167,29 +225,6 @@ export async function activate(context: ExtensionContext): Promise { const legend = new vscode.SemanticTokensLegend(tokenTypes, tokenModifiers); await XpDocumentHighlightProvider.init(config, legend); - // Очистка директории временных файлов. - const tmpDirectory = config.getTmpDirectoryPath(); - if (fs.existsSync(tmpDirectory)) { - try { - await FileSystemHelper.deleteAllSubDirectoriesAndFiles(tmpDirectory); - Log.info(`The temporary files directory '${tmpDirectory}' was successfully cleared`); - } catch (error) { - Log.warn(`Error clearing files from temporary directory '${tmpDirectory}'`, error); - } - } - - // Очистка директории выходных файлов. Нужна для сохранения консистентности нормализаций. - const extensionSettings = config.getWorkspaceConfiguration(); - const outputDirectoryPath = extensionSettings.get('outputDirectoryPath'); - if (fs.existsSync(outputDirectoryPath)) { - try { - await FileSystemHelper.deleteAllSubDirectoriesAndFiles(outputDirectoryPath); - Log.info(`The output directory '${outputDirectoryPath}' was successfully cleared`); - } catch (error) { - Log.warn(`Error clearing files from output directory '${outputDirectoryPath}'`, error); - } - } - Log.info(`Extension '${Configuration.getExtensionDisplayName()}' is activated`); } catch (error) { ExceptionHelper.show( @@ -211,64 +246,148 @@ export async function deactivate(): Promise | undefined { return client.stop(); } +async function clearRuntimeDirectories(config: Configuration): Promise { + const tmpDirectory = config.getTmpDirectoryPath(); + if (fs.existsSync(tmpDirectory)) { + try { + await FileSystemHelper.deleteAllSubDirectoriesAndFiles(tmpDirectory); + Log.info(`The temporary files directory '${tmpDirectory}' was successfully cleared`); + } catch (error) { + Log.warn(`Error clearing files from temporary directory '${tmpDirectory}'`, error); + } + } + + const extensionSettings = config.getWorkspaceConfiguration(); + const outputDirectoryPath = extensionSettings.get('outputDirectoryPath'); + if (fs.existsSync(outputDirectoryPath)) { + try { + await FileSystemHelper.deleteAllSubDirectoriesAndFiles(outputDirectoryPath); + Log.info(`The output directory '${outputDirectoryPath}' was successfully cleared`); + } catch (error) { + Log.warn(`Error clearing files from output directory '${outputDirectoryPath}'`, error); + } + } +} + async function configureLSPClient( - client: LanguageClient, context: vscode.ExtensionContext, config: Configuration -) { +): Promise { + const documentSelector = [ + { scheme: 'file', language: 'xp' }, + { scheme: 'file', language: 'en' }, + { scheme: 'file', language: 'agr' }, + { scheme: 'file', language: 'co' }, + { scheme: 'file', language: 'flt' } + ]; + + const initializationOptions = await buildKbtLspInitializationOptions(config); + try { - const lspServerExecutablePath = config.getKBTLSPFullPath(); - - if (lspServerExecutablePath) { - Log.info(config.getMessage('LSPServer.ServerExecutableFoundAt', lspServerExecutablePath)); - const command = lspServerExecutablePath; - const args: string[] = []; - - const documentSelector = [ - { scheme: 'file', language: 'xp' }, - { scheme: 'file', language: 'en' }, - { scheme: 'file', language: 'agr' }, - { scheme: 'file', language: 'co' }, - { scheme: 'file', language: 'flt' } - ]; - - const serverOptions: ServerOptions = { - command, - args, - options: { - cwd: __dirname - } - }; + if (config.isLocalMacOS()) { + const lspServerExecutablePath = config.getResolvedLSPServerExecutablePath(); + if (lspServerExecutablePath) { + Log.info(config.getMessage('LSPServer.ServerExecutableFoundAt', lspServerExecutablePath)); + const serverOptions = createNativeMacLspServerOptions(config, lspServerExecutablePath); + + const clientOptions: LanguageClientOptions = { + documentSelector, + synchronize: { + configurationSection: ['xplang_ls'] + }, + initializationOptions, + outputChannel: config.getOutputChannel() + }; + + const externalClient = new XpLanguageClient( + lspServerExecutablePath, + 'XP Language Server', + serverOptions, + clientOptions + ); + + return externalClient + .start() + .then(() => { + notifyAboutStartedLspServer(config, externalClient, 'KBT XPLang LSP server'); + + return { + client: externalClient, + usesKbtFormatter: true + }; + }) + .catch((error) => { + vscode.window.showErrorMessage( + 'Failed to start XPLang Language Server: ' + error.message + ); + throw error; + }); + } - const clientOptions: LanguageClientOptions = { - documentSelector, - synchronize: { - configurationSection: [config.getExtensionSettingsPrefix(), 'xplang_ls'] - }, - initializationOptions: { - locale: vscode.env.language - } + Log.info( + 'Skipping XPLang LSP startup on macOS because xpConfig.lspServerExecutablePath is not configured. Legacy LSP is disabled on this platform.' + ); + return { + client: undefined, + usesKbtFormatter: false }; + } - client = new LanguageClient(command, 'XP Language Server', serverOptions, clientOptions); - - return client - .start() - .then(() => { - const serverProcess = client['_serverProcess']; - if (serverProcess) { - const pid = serverProcess.pid; - Log.info(config.getMessage('LSPServer.ServerHasStarted', pid)); - vscode.window.showInformationMessage( - config.getMessage('LSPServer.ServerHasStarted', pid) + if (config.getLSPMode() === 'legacy') { + Log.info('Skipping external KBT LSP startup because xpConfig.lspMode=legacy.'); + } else if (config.shouldUseDockerToolRunner()) { + Log.info( + 'Skipping external KBT LSP startup in Docker tool execution mode. Falling back to the legacy in-extension LSP because the containerized KBT LSP still requires additional configuration/taxonomy plumbing.' + ); + } else { + const lspServerExecutablePath = config.getResolvedLSPServerExecutablePath(); + + if (lspServerExecutablePath) { + Log.info(config.getMessage('LSPServer.ServerExecutableFoundAt', lspServerExecutablePath)); + const serverOptions = config.isLocalMacOS() + ? createNativeMacLspServerOptions(config, lspServerExecutablePath) + : { + command: lspServerExecutablePath, + args: [], + transport: TransportKind.stdio, + options: { + cwd: __dirname + } + }; + + const clientOptions: LanguageClientOptions = { + documentSelector, + synchronize: { + configurationSection: [config.getExtensionSettingsPrefix(), 'xplang_ls'] + }, + initializationOptions, + outputChannel: config.getOutputChannel() + }; + + const externalClient = new XpLanguageClient( + command, + 'XP Language Server', + serverOptions, + clientOptions + ); + + return externalClient + .start() + .then(() => { + notifyAboutStartedLspServer(config, externalClient, 'KBT XPLang LSP server'); + + return { + client: externalClient, + usesKbtFormatter: true + }; + }) + .catch((error) => { + vscode.window.showErrorMessage( + 'Failed to start XPLang Language Server: ' + error.message ); - } - }) - .catch((error) => { - vscode.window.showErrorMessage( - 'Failed to start XPLang Language Server: ' + error.message - ); - }); + throw error; + }); + } } } catch (e) { // if error just use legacy server @@ -311,15 +430,125 @@ async function configureLSPClient( { scheme: 'file', language: 'en' + }, + { + scheme: 'file', + language: 'agr' + }, + { + scheme: 'file', + language: 'flt' } ], synchronize: { // Notify the server about file changes to '.clientrc files contained in the workspace fileEvents: workspace.createFileSystemWatcher('**/.clientrc') - } + }, + outputChannel: config.getOutputChannel() }; // Создаем клиент, запускаем его и сервер. - client = new LanguageClient('languageServer', 'Language Server', serverOptions, clientOptions); - client.start(); + const legacyClient = new LanguageClient('languageServer', 'Language Server', serverOptions, clientOptions); + try { + await legacyClient.start(); + notifyAboutStartedLspServer(config, legacyClient, 'Legacy XPLang LSP server'); + Log.info(`Legacy XPLang LSP server has started from '${serverModule}'.`); + } catch (error) { + Log.error(`Failed to start legacy XPLang LSP server from '${serverModule}'.`, error); + throw error; + } + + return { + client: legacyClient, + usesKbtFormatter: false + }; +} + +async function buildKbtLspInitializationOptions(config: Configuration): Promise> { + const initializationOptions: Record = { + locale: vscode.env.language + }; + + try { + const shouldUseHostPathsForLsp = config.isLocalMacOS() && !!config.getResolvedLSPServerExecutablePath(); + const mapLspPath = (value: string) => + shouldUseHostPathsForLsp ? value : config.mapPathForExecution(value); + + const taxonomyPath = config.craftLSPTaxonomyPath(); + const taxonomyI18nPath = config.craftLSPi18nTaxonomyPath(); + + await config.updateLSPTaxonomyPath(); + await config.updateLSPi18nTaxonomyPath(); + + initializationOptions.taxonomy_path = mapLspPath(taxonomyPath); + initializationOptions.taxonomy_i18n_path = mapLspPath(taxonomyI18nPath); + + const schemaPath = await config.ensureLspSchemaPath(); + if (schemaPath) { + initializationOptions.schema_path = mapLspPath(schemaPath); + } else { + Log.info( + 'KBT LSP schema is not available yet. Starting without schema_path; build schema to enable schema-based diagnostics and completions.' + ); + } + + Log.info( + `KBT LSP initialization options: taxonomy_path='${initializationOptions.taxonomy_path}', taxonomy_i18n_path='${initializationOptions.taxonomy_i18n_path}', schema_path='${initializationOptions.schema_path ?? ''}'` + ); + } catch (error) { + Log.warn(`Failed to prepare KBT LSP initialization options: ${error.message}`); + } + + return initializationOptions; +} + +function createNativeMacLspServerOptions( + config: Configuration, + lspServerExecutablePath: string +): ServerOptions { + return async (): Promise => { + const child = spawn(lspServerExecutablePath, [], { + cwd: path.dirname(lspServerExecutablePath), + stdio: ['pipe', 'pipe', 'pipe'] + }); + + child.stderr.on('data', (chunk: Buffer | string) => { + const text = chunk.toString().trim(); + if (text) { + config.getOutputChannel().appendLine(text); + } + }); + + const reader = child.stdout; + const writer = child.stdin; + + if (!reader || !writer) { + throw new Error('Failed to initialize stdio pipes for native XPLang language server.'); + } + + return { + reader, + writer + }; + }; +} + +function notifyAboutStartedLspServer( + config: Configuration, + startedClient: LanguageClient, + serverKind: string +): void { + const serverProcess = startedClient['_serverProcess']; + const pid = serverProcess?.pid; + + if (pid) { + const message = config.getMessage('LSPServer.ServerHasStarted', pid); + Log.info(message); + void vscode.window.showInformationMessage(message); + return; + } + + const message = `${serverKind} has started.`; + Log.info(message); + void vscode.window.showInformationMessage(message); } diff --git a/client/src/helpers/exceptionHelper.ts b/client/src/helpers/exceptionHelper.ts index 24660d89..4ad44902 100644 --- a/client/src/helpers/exceptionHelper.ts +++ b/client/src/helpers/exceptionHelper.ts @@ -4,8 +4,17 @@ import { XpException } from '../models/xpException'; import { Log } from '../extension'; import { Configuration } from '../models/configuration'; import { StringHelper } from './stringHelper'; +import { CommonCommands } from '../models/command/commonCommands'; export class ExceptionHelper { + private static readonly TOOL_BACKEND_UNAVAILABLE_MESSAGES = [ + 'Docker is not installed or is not available in PATH', + 'Container not running.', + 'is not running. Start a container with XP tools, then retry.', + 'Path mapping failed.', + 'Tool not found in container' + ]; + public static async show(error: Error, defaultMessage?: string): Promise { const errorType = error.constructor.name; const configuration = Configuration.get(); @@ -24,7 +33,7 @@ export class ExceptionHelper { case 'OperationCanceledException': { const typedError = error as XpException; - Log.info(null, typedError); + ExceptionHelper.writeInfo(outputChannel, typedError.message ?? ''); vscode.window.showInformationMessage(typedError.message); break; } @@ -48,13 +57,51 @@ export class ExceptionHelper { vscode.window.showErrorMessage(userMessage); // Пишем stack в output. - Log.error(resultDefaultMessage); - Log.error(error.message, error); + ExceptionHelper.writeError(outputChannel, resultDefaultMessage); + ExceptionHelper.writeError(outputChannel, error.message, error); outputChannel.show(); } } } + public static async showToolBackendUnavailableError( + error: unknown, + configuration = Configuration.get() + ): Promise { + if (!(error instanceof XpException) || !this.isToolBackendUnavailableError(error)) { + return false; + } + + const outputChannel = configuration.getOutputChannel(); + this.recursiveWriteXpExceptionToOutput(error, outputChannel); + + const outputAction = 'Show Output'; + const configureAction = 'Configure'; + const actions = configuration.isLocalMacOS() + ? [outputAction, configureAction] + : [outputAction]; + + const selection = await vscode.window.showErrorMessage(error.message, ...actions); + + if (selection === outputAction) { + await vscode.commands.executeCommand(CommonCommands.SHOW_OUTPUT_CHANNEL_COMMAND); + } else if (selection === configureAction) { + await vscode.commands.executeCommand( + CommonCommands.CONFIGURE_MACOS_CONTAINER_BACKEND_COMMAND + ); + } + + return true; + } + + public static isToolBackendUnavailableError(error: unknown): error is XpException { + if (!(error instanceof XpException)) { + return false; + } + + return this.TOOL_BACKEND_UNAVAILABLE_MESSAGES.some((part) => error.message.includes(part)); + } + private static recursiveWriteXpExceptionToOutput( error: XpException | Error, outputChannel: vscode.OutputChannel @@ -62,12 +109,52 @@ export class ExceptionHelper { // Есть вложенные исключения. if (error instanceof XpException && error.getInnerException()) { // Пишем текущие исключение. - Log.error(error.message, error); + ExceptionHelper.writeError(outputChannel, error.message, error); // Пишем вложенное. ExceptionHelper.recursiveWriteXpExceptionToOutput(error.getInnerException(), outputChannel); } else { - Log.error(error.message, error); + ExceptionHelper.writeError(outputChannel, error.message, error); + } + } + + private static writeInfo(outputChannel: vscode.OutputChannel, message: string): void { + if (Log) { + Log.info(message); + return; } + + outputChannel.appendLine(`${ExceptionHelper.timestamp()} [Info] ${message}`); + } + + private static writeError( + outputChannel: vscode.OutputChannel, + message: string, + error?: Error | unknown + ): void { + if (Log) { + if (error) { + Log.error(message, error); + } else { + Log.error(message); + } + return; + } + + outputChannel.appendLine(`${ExceptionHelper.timestamp()} [Error] ${message ?? ''}`); + if (error) { + outputChannel.appendLine(String((error as Error)?.stack ?? error)); + } + } + + private static timestamp(): string { + const date = new Date(); + const yyyy = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + const hours = String(date.getHours()).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + const seconds = String(date.getSeconds()).padStart(2, '0'); + return `[${day}.${month}.${yyyy} ${hours}:${minutes}:${seconds}]`; } } diff --git a/client/src/helpers/processHelper.ts b/client/src/helpers/processHelper.ts index ba4c9941..4a523bf2 100644 --- a/client/src/helpers/processHelper.ts +++ b/client/src/helpers/processHelper.ts @@ -9,6 +9,7 @@ export interface ExecutionProcessOptions { encoding?: EncodingType; outputChannel?: vscode.OutputChannel; cancellationToken?: vscode.CancellationToken; + cwd?: string; /** * Проверяет выполнимость команды (например, отсутствия нужного модуля в директориях PATH). * Если команда не выполнима, будет прошено исключение. @@ -80,7 +81,10 @@ export class ProcessHelper { throw result.error; } } - child = child_process.spawn(command, params); + child = child_process.spawn(command, params, { + cwd: options.cwd, + env: process.env + }); } catch (error) { reject(error); return; @@ -106,7 +110,7 @@ export class ProcessHelper { options.outputChannel.append(encodedData); } - if (options.cancellationToken.isCancellationRequested) { + if (options.cancellationToken?.isCancellationRequested) { child.kill(); executionResult.exitCode = child.exitCode; executionResult.isInterrupted = true; @@ -122,7 +126,7 @@ export class ProcessHelper { options.outputChannel.append(encodedData); } - if (options.cancellationToken.isCancellationRequested) { + if (options.cancellationToken?.isCancellationRequested) { child.kill(); executionResult.exitCode = child.exitCode; executionResult.isInterrupted = true; @@ -138,7 +142,7 @@ export class ProcessHelper { options.outputChannel.append(encodedData); } - if (options.cancellationToken.isCancellationRequested) { + if (options.cancellationToken?.isCancellationRequested) { child.kill(); executionResult.exitCode = child.exitCode; executionResult.isInterrupted = true; diff --git a/client/src/helpers/regExpHelper.ts b/client/src/helpers/regExpHelper.ts index 98144c32..ec79d38e 100644 --- a/client/src/helpers/regExpHelper.ts +++ b/client/src/helpers/regExpHelper.ts @@ -1,4 +1,5 @@ import * as vscode from 'vscode'; +import { FileSystemHelper } from './fileSystemHelper'; export interface IResultTestFiles { detailedReportFilePath: string; @@ -70,6 +71,35 @@ export class RegExpHelper { return resulults; } + public static getEnrichedCorrTestEventsFileNameFromDirectory( + directoryPath: string + ): Map { + const results = new Map(); + const files = FileSystemHelper.getRecursiveFilesSync(directoryPath); + + for (const filePath of files) { + const detailedReportMatch = /test_conds_(\d+)_result\.txt$/i.exec(filePath); + if (detailedReportMatch) { + const testNumber = detailedReportMatch[1]; + if (!results.has(testNumber)) { + results.set(testNumber, { detailedReportFilePath: '', actualEventsFilePath: '' }); + } + results.get(testNumber).detailedReportFilePath = filePath; + } + + const actualEventsMatch = /test_conds_(\d+)_events\.txt$/i.exec(filePath); + if (actualEventsMatch) { + const testNumber = actualEventsMatch[1]; + if (!results.has(testNumber)) { + results.set(testNumber, { detailedReportFilePath: '', actualEventsFilePath: '' }); + } + results.get(testNumber).actualEventsFilePath = filePath; + } + } + + return results; + } + public static getCorrTestEventsFileName(ruleName: string, testNumber?: number): RegExp { let regExpTemplate: string; if (testNumber) { diff --git a/client/src/helpers/taxonomyHelper.ts b/client/src/helpers/taxonomyHelper.ts index 9b58de1d..264c1346 100644 --- a/client/src/helpers/taxonomyHelper.ts +++ b/client/src/helpers/taxonomyHelper.ts @@ -2,7 +2,6 @@ import * as vscode from 'vscode'; import { Configuration } from '../models/configuration'; import { TaxonomyFieldDetails } from '../providers/taxonomyFieldDetails'; -import { FileSystemHelper } from './fileSystemHelper'; import { YamlHelper } from './yamlHelper'; import { TaxonomyLocalePathLocator } from '../models/locator/taxonomyLocalePathLocator'; @@ -10,7 +9,7 @@ export class TaxonomyHelper { public static async getTaxonomySignaturesPlain(configuration: Configuration): Promise { // Считываем поля таксономии. const taxonomyFilePath = configuration.getTaxonomyFullPath(); - const taxonomyFileContent = await FileSystemHelper.readContentFile(taxonomyFilePath); + const taxonomyFileContent = await configuration.readTextFile(taxonomyFilePath); const taxonomySignaturesPlain = JSON.parse(taxonomyFileContent); return taxonomySignaturesPlain; } @@ -26,7 +25,7 @@ export class TaxonomyHelper { configuration.getTaxonomyDirPath() ); const taxonomyRuLocalizationFilePath = lfpl.getLocaleFilePath(); - const taxonomyRuLocalizationFileContent = await FileSystemHelper.readContentFile( + const taxonomyRuLocalizationFileContent = await configuration.readTextFile( taxonomyRuLocalizationFilePath ); const ruLocalizationPlain = YamlHelper.parse(taxonomyRuLocalizationFileContent); diff --git a/client/src/helpers/yamlHelper.ts b/client/src/helpers/yamlHelper.ts index bd241b1c..fd8138c6 100644 --- a/client/src/helpers/yamlHelper.ts +++ b/client/src/helpers/yamlHelper.ts @@ -23,19 +23,20 @@ export class YamlHelper { const localizationDumpOptions = { ...this.dumpOptions }; localizationDumpOptions.forceQuotes = true; - return this.formatter(jsYaml.dump(object, localizationDumpOptions)); + return this.formatter(this.dumpToText(object, localizationDumpOptions)); } public static tableStringify(object: any): Promise { - return this.formatter(jsYaml.dump(object, this.dumpOptions).replace(/\n/g, os.EOL)); + return this.formatter(this.dumpToText(object, this.dumpOptions)); } public static stringify(object: any, styles?: any): Promise { + const dumpOptions = { ...this.dumpOptions }; if (styles !== undefined) { - this.dumpOptions.styles = styles; + dumpOptions.styles = styles; } - return this.formatter(jsYaml.dump(object, this.dumpOptions).replace(/\n/g, os.EOL)); + return this.formatter(this.dumpToText(object, dumpOptions)); } public static stringifyTable(object: any): string { @@ -58,6 +59,84 @@ export class YamlHelper { return jsYaml.load(str, this.loadOptions); } + private static dumpToText(object: any, dumpOptions: jsYaml.DumpOptions): string { + const text = jsYaml + .dump(object, dumpOptions) + .replace(/[ \t]+$/gm, '') + .replace(/\n/g, os.EOL); + + return this.normalizeSequenceMappings(text, dumpOptions.indent ?? 2); + } + + private static normalizeSequenceMappings(text: string, indentSize: number): string { + const normalizedEol = text.replace(/\r\n/g, '\n'); + const lines = normalizedEol.split('\n'); + const result: string[] = []; + let activeSequence: + | { + sourceChildIndent: string; + desiredDashIndent: string; + desiredChildIndent: string; + } + | undefined; + + for (let index = 0; index < lines.length; index++) { + const currentLine = lines[index]; + const previousLine = result[result.length - 1]; + const dashOnlyMatch = /^(\s*)-\s*$/.exec(currentLine); + + if (!dashOnlyMatch) { + if (currentLine.trim() !== '') { + activeSequence = undefined; + } + result.push(currentLine); + continue; + } + + const childIndex = index + 1; + const childLine = lines[childIndex]; + const childIndentMatch = childLine?.match(/^(\s+)\S/); + if (!childIndentMatch) { + result.push(currentLine); + continue; + } + + const childIndent = childIndentMatch[1]; + + if (!activeSequence) { + if (!previousLine?.trimEnd().endsWith(':')) { + result.push(currentLine); + continue; + } + + const previousIndent = previousLine.match(/^(\s*)/)?.[1].length ?? 0; + const desiredDashIndent = ' '.repeat(previousIndent + indentSize); + activeSequence = { + sourceChildIndent: childIndent, + desiredDashIndent, + desiredChildIndent: `${desiredDashIndent} ` + }; + } + + result.push(`${activeSequence.desiredDashIndent}- ${childLine.slice(childIndent.length)}`); + index = childIndex; + + while (index + 1 < lines.length) { + const nextLine = lines[index + 1]; + if (!nextLine.startsWith(activeSequence.sourceChildIndent) || nextLine.trim() === '') { + break; + } + + result.push( + `${activeSequence.desiredChildIndent}${nextLine.slice(activeSequence.sourceChildIndent.length)}` + ); + index++; + } + } + + return result.join(os.EOL); + } + private static dumpOptions: jsYaml.DumpOptions; private static loadOptions: jsYaml.LoadOptions; private static formatter: Formatter; diff --git a/client/src/l10n/messages.ts b/client/src/l10n/messages.ts deleted file mode 100644 index d2aef5ce..00000000 --- a/client/src/l10n/messages.ts +++ /dev/null @@ -1,4 +0,0 @@ -export enum DialogMessage { - Yes = 'Yes', - No = 'No' -} diff --git a/client/src/models/command/commonCommands.ts b/client/src/models/command/commonCommands.ts index f2a97cae..28ffa892 100644 --- a/client/src/models/command/commonCommands.ts +++ b/client/src/models/command/commonCommands.ts @@ -1,5 +1,6 @@ import * as vscode from 'vscode'; +import { ConfigureMacOSContainerBackendCommand } from './configureMacOSContainerBackendCommand'; import { Configuration } from '../configuration'; import { ShowExtensionOutputChannelCommand } from './showExtensionOutputChannelCommand'; import { ShowExtensionSettingsCommand } from './showExtensionSettingsCommand'; @@ -19,8 +20,20 @@ export class CommonCommands { command.execute(); }) ); + + config.getContext().subscriptions.push( + vscode.commands.registerCommand( + CommonCommands.CONFIGURE_MACOS_CONTAINER_BACKEND_COMMAND, + async () => { + const command = new ConfigureMacOSContainerBackendCommand(config); + command.execute(); + } + ) + ); } public static SHOW_OUTPUT_CHANNEL_COMMAND = 'xp.commonCommands.showOutputChannel'; public static SHOW_EXTENSION_SETTINGS_COMMAND = 'xp.commonCommands.showExtensionSettings'; + public static CONFIGURE_MACOS_CONTAINER_BACKEND_COMMAND = + 'xp.commonCommands.configureMacOSContainerBackend'; } diff --git a/client/src/models/command/configureMacOSContainerBackendCommand.ts b/client/src/models/command/configureMacOSContainerBackendCommand.ts new file mode 100644 index 00000000..a43d82b8 --- /dev/null +++ b/client/src/models/command/configureMacOSContainerBackendCommand.ts @@ -0,0 +1,23 @@ +import * as vscode from 'vscode'; + +import { MacOSContainerSetup } from '../../tools/macosContainerSetup'; +import { Configuration } from '../configuration'; +import { Command } from './command'; + +export class ConfigureMacOSContainerBackendCommand extends Command { + constructor(private config: Configuration) { + super(); + } + + public async execute(): Promise { + if (!this.config.isLocalMacOS()) { + vscode.window.showInformationMessage( + 'XP container backend setup is intended for local macOS VS Code.' + ); + return false; + } + + await MacOSContainerSetup.configure(this.config); + return true; + } +} diff --git a/client/src/models/configuration.ts b/client/src/models/configuration.ts index c1da62a1..866dc3ac 100644 --- a/client/src/models/configuration.ts +++ b/client/src/models/configuration.ts @@ -4,7 +4,6 @@ import * as path from 'path'; import * as vscode from 'vscode'; import { Guid } from 'guid-typescript'; -import { FileSystemException } from './fileSystemException'; import { XpException as XpException } from './xpException'; import { ContentType } from '../contentType/contentType'; import { Localization } from './content/localization'; @@ -15,9 +14,15 @@ import { FileDiagnostics } from './siemj/siemJOutputParser'; import { LocalizationService } from '../l10n/localizationService'; import { Origin } from './content/userSettingsManager'; import { DialogHelper } from '../helpers/dialogHelper'; +import { FileSystemHelper } from '../helpers/fileSystemHelper'; import { LogLevel } from '../logger'; import { Log } from '../extension'; -import { ProcessHelper } from '../helpers/processHelper'; +import { + DockerToolRunner, + ToolExecutionMode, + ToolRunner, + ToolRunnerFactory +} from '../tools/toolRunner'; export type EncodingType = 'windows-1251' | 'utf-8' | 'utf-16'; @@ -71,19 +76,20 @@ export class Configuration { } public getCurrentSIEMJVersion(): string { + if (this.SIEMJVersion) { + return this.SIEMJVersion; + } + + this.SIEMJVersion = this.detectSIEMJVersionSync(); return this.SIEMJVersion; } - public setSIEMJVersion(): void { - let result = ProcessHelper.readProcessArgsOutputSync( - this.getSiemjPath(), - ['-v'], - 'utf8' - ).trim(); - if (result.match(/siemj(:?\.exe)? 1\./)) { + public async setSIEMJVersion(): Promise { + const result = (await this.getToolRunner().runSiemj(['-v'], { encoding: 'utf-8' })).output.trim(); + if (result.match(/siemj(?:\.real|\.exe)?\s+1\./)) { this.SIEMJVersion = '1'; } else { - if (result.match(/siemj(:?\.exe)? 2\./)) { + if (result.match(/siemj(?:\.real|\.exe)?\s+2\./)) { this.SIEMJVersion = '2'; } else { throw new XpException(`Unexpected SIEMJ version: ${result}`); @@ -91,13 +97,26 @@ export class Configuration { } } + public getLSPMode(): 'auto' | 'legacy' | 'kbt' { + if (this.isLocalMacOS()) { + return 'auto'; + } + + const mode = this.getWorkspaceConfiguration().get<'auto' | 'legacy' | 'kbt'>('lspMode'); + return mode ?? 'auto'; + } + public getLSPTaxonomyPath(): string { const configuration = this.getKBTLSPConfiguration(); return configuration.get('taxonomy_path'); } public craftLSPTaxonomyPath(): string { - return path.join(this.getKbtBaseDirectory(), '/knowledgebase/contracts/taxonomy/taxonomy.json'); + if (this.shouldUseNativeMacLspPaths()) { + return this.getNativeMacTaxonomyFullPath(); + } + + return this.getTaxonomyFullPath(); } public getLSPi18nTaxonomyPath(): string { @@ -106,24 +125,113 @@ export class Configuration { } public craftLSPi18nTaxonomyPath(): string { - return path.join(this.getKbtBaseDirectory(), 'knowledgebase/contracts/taxonomy/i18n/'); + if (this.shouldUseNativeMacLspPaths()) { + return this.getNativeMacTaxonomyI18nDirPath(); + } + + return path.join(this.getTaxonomyDirPath(), 'i18n'); } public async updateLSPTaxonomyPath(): Promise { - const configuration = this.getKBTLSPConfiguration(); const taxonomyPath = this.craftLSPTaxonomyPath(); - await configuration.update('taxonomy_path', taxonomyPath, true, false); + await this.updateKbtLspSetting('taxonomy_path', taxonomyPath); } public async updateLSPi18nTaxonomyPath(): Promise { - const configuration = this.getKBTLSPConfiguration(); const taxonomyPath = this.craftLSPi18nTaxonomyPath(); - await configuration.update('taxonomy_i18n_path', taxonomyPath, true, false); + await this.updateKbtLspSetting('taxonomy_i18n_path', taxonomyPath); } - public updateLSPSchemaTaxonomyPath(schemaPath: string): void { - const configuration = this.getKBTLSPConfiguration(); - configuration.update('schema_path', schemaPath); + public async updateLSPSchemaTaxonomyPath(schemaPath: string): Promise { + await this.updateKbtLspSetting('schema_path', schemaPath); + } + + public async clearLSPSchemaPath(): Promise { + await this.updateKbtLspSetting('schema_path', ''); + } + + public async ensureLspSchemaPath(): Promise { + const configuredSchemaPath = this.getKBTLSPConfiguration().get('schema_path')?.trim(); + if (configuredSchemaPath && fs.existsSync(configuredSchemaPath)) { + return configuredSchemaPath; + } + + const contentRoots = this.getContentRoots(); + for (const contentRoot of contentRoots) { + const contentRootFolder = path.basename(contentRoot); + const schemaPath = this.getSchemaFullPath(contentRootFolder); + if (fs.existsSync(schemaPath)) { + await this.updateKbtLspSetting('schema_path', schemaPath); + return schemaPath; + } + } + + const placeholderRootFolder = + contentRoots.length > 0 ? path.basename(contentRoots[0]) : 'packages'; + const placeholderSchemaPath = this.getSchemaFullPath(placeholderRootFolder); + await fs.promises.mkdir(path.dirname(placeholderSchemaPath), { recursive: true }); + if (!fs.existsSync(placeholderSchemaPath)) { + await fs.promises.writeFile(placeholderSchemaPath, '{}', 'utf-8'); + } + + await this.updateKbtLspSetting('schema_path', placeholderSchemaPath); + return placeholderSchemaPath; + } + + private shouldUseNativeMacLspPaths(): boolean { + return this.isLocalMacOS() && !!this.getResolvedLSPServerExecutablePath(); + } + + private getNativeMacKbtBaseDirectory(): string { + const configuration = this.getWorkspaceConfiguration(); + const basePath = configuration.get('kbtBaseDirectory'); + if (basePath) { + if (!fs.existsSync(basePath)) { + throw new XpException(this.getMessage('Error.KbtDirectoryPathIsNoExist', basePath)); + } + + return basePath; + } + + const lspServerExecutablePath = this.getResolvedLSPServerExecutablePath(); + if (lspServerExecutablePath) { + const inferredBasePath = path.resolve(lspServerExecutablePath, '..', '..', '..'); + if (fs.existsSync(inferredBasePath)) { + return inferredBasePath; + } + } + + throw new XpException( + 'Local KBT base directory is not configured. Set xpConfig.kbtBaseDirectory or point xpConfig.lspServerExecutablePath to a binary inside /xp-sdk/cli/.' + ); + } + + private getNativeMacContractsDirectory(): string { + return path.join( + this.getNativeMacKbtBaseDirectory(), + 'knowledgebase', + Configuration.CONTRACTS_DIR_NAME + ); + } + + private getNativeMacTaxonomyFullPath(): string { + const fullPath = path.join( + this.getNativeMacContractsDirectory(), + Configuration.TAXONOMY_DIR_NAME, + 'taxonomy.json' + ); + this.checkReadablePath(fullPath); + return fullPath; + } + + private getNativeMacTaxonomyI18nDirPath(): string { + const fullPath = path.join( + this.getNativeMacContractsDirectory(), + Configuration.TAXONOMY_DIR_NAME, + 'i18n' + ); + this.checkReadablePath(fullPath); + return fullPath; } public getFirstWorkspaceFolder(): string { @@ -217,6 +325,46 @@ export class Configuration { return this.context.extensionMode; } + public getToolExecutionMode(): ToolExecutionMode { + const mode = this.getWorkspaceConfiguration().get('toolExecutionMode'); + return mode ?? 'auto'; + } + + public isLocalMacOS(): boolean { + return process.platform === 'darwin' && !vscode.env.remoteName; + } + + public shouldUseDockerToolRunner(): boolean { + return ToolRunnerFactory.resolveMode(this.getToolExecutionMode()) === 'docker'; + } + + public getToolRunner(): ToolRunner { + return ToolRunnerFactory.create({ + mode: this.getToolExecutionMode(), + kbtBaseDirectory: this.tryGetKbtBaseDirectoryForLocalRunner(), + outputDirectoryPath: this.getBaseOutputDirectoryPath(), + docker: { + containerName: this.getWorkspaceConfiguration().get('docker.containerName'), + composeFile: this.getWorkspaceConfiguration().get('docker.composeFile'), + serviceName: this.getWorkspaceConfiguration().get('docker.serviceName'), + workspaceHostPath: this.getWorkspaceConfiguration().get('docker.workspaceHostPath'), + workspaceContainerPath: this.getWorkspaceConfiguration().get( + 'docker.workspaceContainerPath' + ), + kbtBaseDirectory: this.getWorkspaceConfiguration().get('docker.kbtBaseDirectory'), + outputDirectoryPath: this.getDockerOutputDirectoryPath() + } + }); + } + + public getMacOSShowContainerSetupPrompt(): boolean { + return this.getWorkspaceConfiguration().get('macos.showContainerSetupPrompt') ?? true; + } + + public async setMacOSShowContainerSetupPrompt(value: boolean): Promise { + await this.getWorkspaceConfiguration().update('macos.showContainerSetupPrompt', value, true); + } + public getContext(): vscode.ExtensionContext { return this.context; } @@ -269,6 +417,13 @@ export class Configuration { * @returns путь к директории со всеми SDK утилитами. */ public getKbtBaseDirectory(): string { + if (this.shouldUseDockerToolRunner()) { + return ( + this.getWorkspaceConfiguration().get('docker.kbtBaseDirectory') || + '/home/coder/xp-kbt' + ); + } + const kbtVersionsDirectory = this.getKbtVersionsDirectory(); if (!kbtVersionsDirectory) { return this.getKbtBaseDirectoryOld(); @@ -424,6 +579,10 @@ export class Configuration { } public getSiemjPath(): string { + if (this.shouldUseDockerToolRunner()) { + return this.getDockerKbtToolPath('extra-tools/siemj/siemj'); + } + let appName = ''; switch (this.getOsType()) { case OsType.Windows: @@ -446,6 +605,10 @@ export class Configuration { } public getSiemkbTestsPath(): string { + if (this.shouldUseDockerToolRunner()) { + return this.getDockerKbtToolPath('build-tools/siemkb_tests'); + } + let appName = ''; switch (this.getOsType()) { case OsType.Windows: @@ -468,6 +631,10 @@ export class Configuration { } public getRccCli(): string { + if (this.shouldUseDockerToolRunner()) { + return this.getDockerKbtToolPath('xp-sdk/cli/rcc'); + } + let appName = ''; switch (this.getOsType()) { case OsType.Windows: @@ -490,6 +657,10 @@ export class Configuration { } public getMkTablesPath(): string { + if (this.shouldUseDockerToolRunner()) { + return this.getDockerKbtToolPath('build-tools/mktables'); + } + let appName = ''; switch (this.getOsType()) { case OsType.Windows: @@ -512,6 +683,10 @@ export class Configuration { } public getFPTAFillerPath(): string { + if (this.shouldUseDockerToolRunner()) { + return this.getDockerKbtToolPath('xp-sdk/fpta_filler'); + } + let appName = ''; switch (this.getOsType()) { case OsType.Windows: @@ -534,6 +709,10 @@ export class Configuration { } public getLocalizationBuilder(): string { + if (this.shouldUseDockerToolRunner()) { + return this.getDockerKbtToolPath('build-tools/build_l10n_rules'); + } + let appName = ''; switch (this.getOsType()) { case OsType.Windows: @@ -556,6 +735,10 @@ export class Configuration { } public getSiemKBTests(): string { + if (this.shouldUseDockerToolRunner()) { + return this.getDockerKbtToolPath('build-tools/siemkb_tests'); + } + let appName = ''; switch (this.getOsType()) { case OsType.Windows: @@ -578,6 +761,10 @@ export class Configuration { } public getNormalizerCli(): string { + if (this.shouldUseDockerToolRunner()) { + return this.getDockerKbtToolPath('xp-sdk/cli/normalizer-cli'); + } + let appName = ''; switch (this.getOsType()) { case OsType.Windows: @@ -600,6 +787,10 @@ export class Configuration { } public getNormalizer(): string { + if (this.shouldUseDockerToolRunner()) { + return this.getDockerKbtToolPath('build-tools/normalize'); + } + let appName = ''; switch (this.getOsType()) { case OsType.Windows: @@ -623,6 +814,10 @@ export class Configuration { public getKbPackFullPath(): string { const appName = 'kbpack.dll'; + if (this.shouldUseDockerToolRunner()) { + return this.getDockerKbtToolPath(path.posix.join('extra-tools', 'kbpack', appName)); + } + const fullPath = path.join(this.getKbtBaseDirectory(), 'extra-tools', 'kbpack', appName); this.checkKbtSingleToolPath(fullPath); @@ -649,6 +844,10 @@ export class Configuration { } public getEcatestFullPath(): string { + if (this.shouldUseDockerToolRunner()) { + return this.getDockerKbtToolPath('build-tools/ecatest'); + } + let appName = ''; switch (this.getOsType()) { case OsType.Windows: @@ -671,6 +870,10 @@ export class Configuration { } public getEvtTestsFullPath(): string { + if (this.shouldUseDockerToolRunner()) { + return this.getDockerKbtToolPath('xp-sdk/cli/evt-tests'); + } + let appName = ''; switch (this.getOsType()) { case OsType.Windows: @@ -693,6 +896,10 @@ export class Configuration { } public getKBTLSPFullPath(): string { + if (this.shouldUseDockerToolRunner()) { + return null; + } + let appName = ''; switch (this.getOsType()) { case OsType.Windows: @@ -934,6 +1141,12 @@ export class Configuration { * @returns путь к папке с директориями контрактов. */ private getContractsDirectory(): string { + if (this.shouldUseDockerToolRunner()) { + return this.getDockerKbtToolPath( + path.posix.join('knowledgebase', Configuration.CONTRACTS_DIR_NAME) + ); + } + return path.join(this.getKbtBaseDirectory(), 'knowledgebase', Configuration.CONTRACTS_DIR_NAME); } @@ -948,7 +1161,7 @@ export class Configuration { Configuration.TAXONOMY_DIR_NAME, taxonomyFileName ); - this.checkKbtSingleToolPath(fullPath); + this.checkReadablePath(fullPath); return fullPath; } @@ -959,7 +1172,7 @@ export class Configuration { */ public getTaxonomyDirPath(): string { const fullPath = path.join(this.getContractsDirectory(), Configuration.TAXONOMY_DIR_NAME); - this.checkKbtSingleToolPath(fullPath); + this.checkReadablePath(fullPath); return fullPath; } @@ -976,7 +1189,7 @@ export class Configuration { public getAppendixFullPath(): string { const appendixFileName = 'appendix.xp'; const fullPath = path.join(this.getContractsDirectory(), 'xp_appendix', appendixFileName); - this.checkKbtSingleToolPath(fullPath); + this.checkReadablePath(fullPath); return fullPath; } @@ -992,7 +1205,7 @@ export class Configuration { 'tabular_lists', tabularContractsFileName ); - this.checkKbtSingleToolPath(fullPath); + this.checkReadablePath(fullPath); return fullPath; } @@ -1022,6 +1235,15 @@ export class Configuration { return vscode.workspace.getConfiguration(this.LSP_CONFIGURATION_PREFIX); } + private async updateKbtLspSetting( + section: 'taxonomy_path' | 'taxonomy_i18n_path' | 'schema_path', + value: string + ): Promise { + const configuration = this.getKBTLSPConfiguration(); + await configuration.update(section, value, vscode.ConfigurationTarget.Global, false); + await configuration.update(section, value, vscode.ConfigurationTarget.Workspace, false); + } + /** * Возвращает таймаут работы коррелятора. * @returns @@ -1038,10 +1260,69 @@ export class Configuration { return lspServerExecutablePath; } + public getDirectFormatterExecutablePath(): string { + const configuration = this.getWorkspaceConfiguration(); + const formatterExecutablePath = configuration.get('formatterExecutablePath'); + return formatterExecutablePath; + } + + public getResolvedLSPServerExecutablePath(): string | undefined { + if (this.getLSPMode() === 'legacy') { + return undefined; + } + + const configuredPath = this.getDirectLSPServerExecutablePath(); + if (configuredPath) { + if (!fs.existsSync(configuredPath)) { + Log.warn(`Configured LSP server executable was not found: '${configuredPath}'.`); + return undefined; + } + + return configuredPath; + } + + if (this.isLocalMacOS()) { + return undefined; + } + + if (this.shouldUseDockerToolRunner()) { + return undefined; + } + + return this.getKBTLSPFullPath() ?? undefined; + } + + public getResolvedFormatterExecutablePath(): string | undefined { + const configuredPath = this.getDirectFormatterExecutablePath(); + if (configuredPath) { + if (!fs.existsSync(configuredPath)) { + Log.warn(`Configured formatter executable was not found: '${configuredPath}'.`); + return undefined; + } + + return configuredPath; + } + + const kbtBaseDirectory = this.getKbtBaseDirectory(); + const formatterName = process.platform === 'win32' ? 'evt-xp-formatter.exe' : 'evt-xp-formatter'; + const formatterPath = path.join(kbtBaseDirectory, 'xp-sdk', 'cli', formatterName); + + if (!this.shouldUseDockerToolRunner() && !fs.existsSync(formatterPath)) { + Log.warn(`Can't find formatter executable in KBT directory: '${formatterPath}'.`); + return undefined; + } + + return formatterPath; + } + /** * Automatically sets kbtVersionsDirectory if not already set */ public autoSetKbtVersionsDirectory(): void { + if (this.shouldUseDockerToolRunner()) { + return; + } + const configuration = this.getWorkspaceConfiguration(); const kbtVersionsDirectory = configuration.get('kbtVersionsDirectory'); @@ -1063,6 +1344,10 @@ export class Configuration { * Automatically sets kbtBaseDirectory if not already set */ public autoSetKbtBaseDirectory(): void { + if (this.shouldUseDockerToolRunner()) { + return; + } + const configuration = this.getWorkspaceConfiguration(); const kbtBaseDirectory = configuration.get('kbtBaseDirectory'); @@ -1080,13 +1365,32 @@ export class Configuration { * Automatically sets lspServerExecutablePath if not already set */ public autoSetLspServerExecutablePath(): void { + if (!this.isLocalMacOS() && this.getLSPMode() === 'legacy') { + Log.info('Skipping KBT LSP auto-detection because xpConfig.lspMode=legacy.'); + return; + } + + if (this.isLocalMacOS()) { + Log.info( + 'Skipping KBT LSP auto-detection on macOS. Set xpConfig.lspServerExecutablePath to use a native XPLang language server.' + ); + return; + } + + if (this.shouldUseDockerToolRunner()) { + Log.info( + 'Skipping local KBT LSP auto-detection in Docker tool execution mode. External KBT LSP is not launched via Docker because host file URIs are not mapped into the LSP protocol yet.' + ); + return; + } + const configuration = this.getWorkspaceConfiguration(); const lspServerExecutablePath = configuration.get('lspServerExecutablePath'); if (!lspServerExecutablePath) { try { // Try to find the LSP server executable in the KBT directory - const fullPath = this.getKBTLSPFullPath(); + const fullPath = this.getResolvedLSPServerExecutablePath(); if (fullPath) { configuration.update('lspServerExecutablePath', fullPath, true, false); @@ -1128,23 +1432,10 @@ export class Configuration { public getBaseOutputDirectoryPath(): string { const extensionSettings = this.getWorkspaceConfiguration(); - const outputDirectoryPath = extensionSettings.get('outputDirectoryPath'); - - if (!outputDirectoryPath || outputDirectoryPath === '') { - throw new FileSystemException( - this.getMessage('Error.OutputDirectoryPathIsNotSet'), - outputDirectoryPath - ); - } - - if (!fs.existsSync(outputDirectoryPath)) { - throw new FileSystemException( - this.getMessage('Error.IncorrectOutputDirectoryPath', outputDirectoryPath), - outputDirectoryPath - ); - } - - return outputDirectoryPath; + const configuredPath = extensionSettings.get('outputDirectoryPath'); + return this.normalizeLegacyHostOutputDirectoryPath( + configuredPath || this.getDefaultBaseOutputDirectoryPath() + ); } /** @@ -1164,12 +1455,168 @@ export class Configuration { return ruLocalizationFilePath; } + public async readTextFile(fullPath: string): Promise { + if (this.shouldUseDockerToolRunner() && this.isDockerContainerPath(fullPath)) { + return ( + await this.getToolRunner().runTool('cat', [fullPath], { + encoding: 'utf-8' + }) + ).output; + } + + return FileSystemHelper.readContentFile(fullPath); + } + + public mapPathForExecution(fullPath: string): string { + if (!this.shouldUseDockerToolRunner()) { + return fullPath; + } + + const toolRunner = this.getToolRunner(); + if (toolRunner instanceof DockerToolRunner) { + return toolRunner.getPathMapper().mapCommandArgument(fullPath); + } + + return fullPath; + } + + public getDockerOutputDirectoryPath(): string { + const configuredPath = this.getWorkspaceConfiguration().get('docker.outputDirectoryPath'); + return this.normalizeLegacyDockerOutputDirectoryPath( + configuredPath || this.getDefaultDockerOutputDirectoryPath() + ); + } + private checkKbtSingleToolPath(fullPath: string): void { + if (this.shouldUseDockerToolRunner() && this.isDockerContainerPath(fullPath)) { + return; + } + if (!fs.existsSync(fullPath)) { throw new XpException(this.getMessage('Error.UtilityPathIsIncorrect', fullPath)); } } + private checkReadablePath(fullPath: string): void { + if (this.shouldUseDockerToolRunner() && this.isDockerContainerPath(fullPath)) { + return; + } + + if (!fs.existsSync(fullPath)) { + throw new XpException(`Required XP file was not found: ${fullPath}`); + } + } + + private getDockerKbtToolPath(relativePath: string): string { + const dockerKbtBaseDirectory = + this.getWorkspaceConfiguration().get('docker.kbtBaseDirectory') || + '/home/coder/xp-kbt'; + return path.posix.join(dockerKbtBaseDirectory, relativePath.replace(/\\/g, '/')); + } + + private getDockerWorkspaceHostPath(): string | undefined { + const configuredPath = this.getWorkspaceConfiguration().get('docker.workspaceHostPath'); + if (configuredPath) { + return configuredPath; + } + + return vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + } + + private getWorkspaceRootPath(): string | undefined { + return vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + } + + private getDefaultBaseOutputDirectoryPath(): string { + const workspaceRootPath = this.getWorkspaceRootPath(); + if (workspaceRootPath) { + return path.join(workspaceRootPath, 'tmp', 'xp-output'); + } + + return path.join(os.tmpdir(), Configuration.getExtensionDirectoryName(), 'tmp', 'xp-output'); + } + + private getDefaultDockerOutputDirectoryPath(): string { + const workspaceContainerPath = + this.getWorkspaceConfiguration().get('docker.workspaceContainerPath') || + '/workspaces/knowledgebase'; + return path.posix.join(workspaceContainerPath, 'tmp', 'xp-output'); + } + + private normalizeLegacyHostOutputDirectoryPath(outputDirectoryPath: string): string { + const workspaceRootPath = this.getWorkspaceRootPath(); + if (!workspaceRootPath) { + return outputDirectoryPath; + } + + const legacyPath = path.join(workspaceRootPath, '.xp-output'); + if (path.resolve(outputDirectoryPath) === path.resolve(legacyPath)) { + return this.getDefaultBaseOutputDirectoryPath(); + } + + return outputDirectoryPath; + } + + private normalizeLegacyDockerOutputDirectoryPath(outputDirectoryPath: string): string { + const workspaceContainerPath = + this.getWorkspaceConfiguration().get('docker.workspaceContainerPath') || + '/workspaces/knowledgebase'; + const legacyPath = path.posix.join(workspaceContainerPath, '.xp-output'); + if (outputDirectoryPath.replace(/\\/g, '/') === legacyPath) { + return this.getDefaultDockerOutputDirectoryPath(); + } + + return outputDirectoryPath; + } + + public isDockerContainerPath(fullPath: string): boolean { + const workspaceHostPath = this.getDockerWorkspaceHostPath(); + if (workspaceHostPath && fullPath.startsWith(workspaceHostPath)) { + return false; + } + + return fullPath.startsWith('/'); + } + + private tryGetKbtBaseDirectoryForLocalRunner(): string | undefined { + if (this.shouldUseDockerToolRunner()) { + return undefined; + } + + try { + return this.getKbtBaseDirectory(); + } catch (error) { + Log.warn(`Failed to resolve local KBT directory: ${error.message}`); + return undefined; + } + } + + private detectSIEMJVersionSync(): string { + if (this.shouldUseDockerToolRunner()) { + return '2'; + } + + try { + const evtTestsPath = this.getEvtTestsFullPath(); + if (evtTestsPath && fs.existsSync(evtTestsPath)) { + return '2'; + } + } catch (error) { + Log.warn(`Failed to infer SIEMJ version from evt-tests: ${error.message}`); + } + + try { + const lspPath = this.getKBTLSPFullPath(); + if (lspPath && fs.existsSync(lspPath)) { + return '2'; + } + } catch (error) { + Log.warn(`Failed to infer SIEMJ version from KBT LSP path: ${error.message}`); + } + + return '1'; + } + public async checkUserSetting(): Promise { const extensionConfig = this.getWorkspaceConfiguration(); @@ -1192,12 +1639,7 @@ export class Configuration { private async checkAndCreateOutputDirectory( extensionConfig: vscode.WorkspaceConfiguration ): Promise { - const outputDirectoryPath = extensionConfig.get('outputDirectoryPath'); - - if (!outputDirectoryPath) { - DialogHelper.showError(this.getMessage('Error.OutputDirectoryPathIsNotSet')); - return; - } + const outputDirectoryPath = this.getBaseOutputDirectoryPath(); try { await fs.promises.mkdir(outputDirectoryPath, { recursive: true }); diff --git a/client/src/models/content/ruleBaseItem.ts b/client/src/models/content/ruleBaseItem.ts index 4b42c638..b5cdfd03 100644 --- a/client/src/models/content/ruleBaseItem.ts +++ b/client/src/models/content/ruleBaseItem.ts @@ -223,7 +223,7 @@ export abstract class RuleBaseItem extends ContentTreeBaseItem { testDirectoryPath = path.join(ruleDirPath, RuleBaseItem.TESTS_DIRNAME); } - // Очищаем старые тесты, так как если их стало меньше, то будут оставаться удалённые. + // Удаляем только те файлы тестов, для которых больше нет соответствующего теста. if (fs.existsSync(testDirectoryPath)) { let oldTestFilePaths = FileSystemHelper.readFilesNameFilter( testDirectoryPath, @@ -237,8 +237,20 @@ export abstract class RuleBaseItem extends ContentTreeBaseItem { oldTestFilePaths = oldTestFilePaths.concat(rawEventFilePaths); + const actualFilePaths = new Set(); + for (const it of this.integrationTests) { + if (ruleDirPath) { + it.setRuleDirectoryPath(ruleDirPath); + } + + actualFilePaths.add(it.getTestCodeFilePath()); + actualFilePaths.add(it.getRawEventsFilePath()); + } + for (const it of oldTestFilePaths) { - await fs.promises.unlink(it); + if (!actualFilePaths.has(it)) { + await fs.promises.unlink(it); + } } } diff --git a/client/src/models/siemj/setKBTVersionCommand.ts b/client/src/models/siemj/setKBTVersionCommand.ts index b0f42fb0..1e2ac8e0 100644 --- a/client/src/models/siemj/setKBTVersionCommand.ts +++ b/client/src/models/siemj/setKBTVersionCommand.ts @@ -13,6 +13,10 @@ export class SetKBTVersionCommand { static Name = 'xpContentEditor.setKBTVersion'; static async init(config: Configuration): Promise { + if (config.shouldUseDockerToolRunner()) { + return; + } + const context = config.getContext(); const setKBTVersionCommand = new SetKBTVersionCommand(); @@ -91,9 +95,9 @@ export class SetKBTVersionCommand { // Update lspServerExecutablePath to point to the new version try { - // Use the existing getKBTLSPFullPath method to determine the LSP server path + // Use the resolved LSP path helper so an explicit setting or a discovered KBT path stay aligned. // This ensures consistency with the rest of the codebase - const lspServerPath = config.getKBTLSPFullPath(); + const lspServerPath = config.getResolvedLSPServerExecutablePath(); // Check if the path is valid and update the configuration const configuration = config.getWorkspaceConfiguration(); @@ -125,7 +129,7 @@ export class SetKBTVersionCommand { } } - config.setSIEMJVersion(); + await config.setSIEMJVersion(); item.text = kbtVersion; // Подсказка при наведении. diff --git a/client/src/models/siemj/siemjConfigBuilder.ts b/client/src/models/siemj/siemjConfigBuilder.ts index c6328784..2c7cfcca 100644 --- a/client/src/models/siemj/siemjConfigBuilder.ts +++ b/client/src/models/siemj/siemjConfigBuilder.ts @@ -600,10 +600,10 @@ out=${output} ); const test_pipeline_config = `{ "graphs": { - "normalization": ${JSON.stringify(formulas)}, - "correlation": ${JSON.stringify(corrules)}, - "aggregation": ${JSON.stringify(argrules)}, - "enrichment": ${JSON.stringify(enrules)} + "normalization": ${JSON.stringify(this.config.mapPathForExecution(formulas))}, + "correlation": ${JSON.stringify(this.config.mapPathForExecution(corrules))}, + "aggregation": ${JSON.stringify(this.config.mapPathForExecution(argrules))}, + "enrichment": ${JSON.stringify(this.config.mapPathForExecution(enrules))} }, "fields": { "exclude-non-taxonomy-fields": true, @@ -619,12 +619,12 @@ out=${output} "aggregation_name" ] }, - "root": ${JSON.stringify(testsRuleFullPath)}, - "rules-filters": ${JSON.stringify(this.config.getRulesDirFilters())}, - "fpta-defaults": ${JSON.stringify(table_list_defaults)}, - "taxonomy": ${JSON.stringify(this.config.getTaxonomyFullPath())}, - "schema": ${JSON.stringify(table_list_schema)}, - "appendix": ${JSON.stringify(this.config.getAppendixFullPath())} + "root": ${JSON.stringify(this.config.mapPathForExecution(testsRuleFullPath))}, + "rules-filters": ${JSON.stringify(this.config.mapPathForExecution(this.config.getRulesDirFilters()))}, + "fpta-defaults": ${JSON.stringify(this.config.mapPathForExecution(table_list_defaults))}, + "taxonomy": ${JSON.stringify(this.config.mapPathForExecution(this.config.getTaxonomyFullPath()))}, + "schema": ${JSON.stringify(this.config.mapPathForExecution(table_list_schema))}, + "appendix": ${JSON.stringify(this.config.mapPathForExecution(this.config.getAppendixFullPath()))} } `; diff --git a/client/src/models/siemj/siemjManager.ts b/client/src/models/siemj/siemjManager.ts index 146eb8ef..c1b335ba 100644 --- a/client/src/models/siemj/siemjManager.ts +++ b/client/src/models/siemj/siemjManager.ts @@ -4,7 +4,7 @@ import * as os from 'os'; import * as vscode from 'vscode'; import { FileSystemHelper } from '../../helpers/fileSystemHelper'; -import { ExecutionResult, ProcessHelper } from '../../helpers/processHelper'; +import { ExecutionResult } from '../../helpers/processHelper'; import { Configuration } from '../configuration'; import { XpException } from '../xpException'; import { RuleBaseItem } from '../content/ruleBaseItem'; @@ -25,7 +25,7 @@ export enum SIEMJVersion { } export function GetRawSIEMJVersion(config: Configuration): string { - return ProcessHelper.readProcessArgsOutputSync(config.getSiemjPath(), ['-v'], 'utf8').trim(); + return config.getCurrentSIEMJVersion(); } export function GetSIEMJVersion(config: Configuration): SIEMJVersion { @@ -82,6 +82,8 @@ export class SiemjManager { throw new XpException('Ошибка компиляции схемы БД. Результирующий файл не создан'); } + await this.config.updateLSPSchemaTaxonomyPath(schemaFilePath); + return schemaFilePath; } @@ -223,14 +225,13 @@ export class SiemjManager { const siemjConfigPath = this.config.getTmpSiemjConfigPath(contentRootFolder); await SiemjConfigHelper.saveSiemjConfig(siemjConfContent, siemjConfigPath); - const siemjExePath = this.config.getSiemjPath(); - // Типовая команда выглядит так: // "C:\\PTSIEMSDK_GUI.4.0.0.738\\tools\\siemj.exe" -c C:\\PTSIEMSDK_GUI.4.0.0.738\\temp\\siemj.conf main"); - const result = await ProcessHelper.execute(siemjExePath, ['-c', siemjConfigPath, 'main'], { + const result = await this.config.getToolRunner().runSiemj(['-c', siemjConfigPath, 'main'], { encoding: this.config.getSiemjOutputEncoding(), outputChannel: this.config.getOutputChannel(), - cancellationToken: this.token + cancellationToken: this.token, + allowNonZeroExitCode: true }); if (result.isInterrupted) { @@ -262,14 +263,13 @@ export class SiemjManager { const siemjConfigPath = this.config.getTmpSiemjConfigPath(contentRootFolder); await SiemjConfigHelper.saveSiemjConfig(siemjConfContent, siemjConfigPath); - const siemjExePath = this.config.getSiemjPath(); - // Типовая команда выглядит так: // "C:\\PTSIEMSDK_GUI.4.0.0.738\\tools\\siemj.exe" -c C:\\PTSIEMSDK_GUI.4.0.0.738\\temp\\siemj.conf main"); - const result = await ProcessHelper.execute(siemjExePath, ['-c', siemjConfigPath, 'main'], { + const result = await this.config.getToolRunner().runSiemj(['-c', siemjConfigPath, 'main'], { encoding: this.config.getSiemjOutputEncoding(), outputChannel: this.config.getOutputChannel(), - cancellationToken: this.token + cancellationToken: this.token, + allowNonZeroExitCode: true }); return result; } @@ -323,7 +323,7 @@ export class SiemjManager { case SIEMJVersion.Second: for (const [, resultFiles] of testResultFiles) { - let correlateEventsFileContent = await FileSystemHelper.readContentFile( + let correlateEventsFileContent = await this.config.readTextFile( resultFiles.actualEventsFilePath ); correlateEventsFileContent = correlateEventsFileContent.trimEnd(); diff --git a/client/src/models/tests/correlationUnitTestsRunner.ts b/client/src/models/tests/correlationUnitTestsRunner.ts index 6803377d..5272e35e 100644 --- a/client/src/models/tests/correlationUnitTestsRunner.ts +++ b/client/src/models/tests/correlationUnitTestsRunner.ts @@ -2,7 +2,6 @@ import * as vscode from 'vscode'; import * as fs from 'fs'; import * as path from 'path'; -import { ProcessHelper } from '../../helpers/processHelper'; import { TestHelper } from '../../helpers/testHelper'; import { DialogHelper } from '../../helpers/dialogHelper'; import { Configuration } from '../configuration'; @@ -65,7 +64,6 @@ export class CorrelationUnitTestsRunner implements UnitTestRunner { const ruleFilePath = test.getRuleFullPath(); // const ecaTestParam = `C:\\Tools\\0.22.774\\any\\any\\win\\ecatest.exe`; - const ecaTestParam = this.config.getEcatestFullPath(); const sdkDirPath = this.config.getSiemSdkDirectoryPath(); const taxonomyFilePath = this.config.getTaxonomyFullPath(); const testFilepath = test.getTestExpectationPath(); @@ -73,8 +71,8 @@ export class CorrelationUnitTestsRunner implements UnitTestRunner { const schemaFilePath = this.config.getSchemaFullPath(rootFolder); const ruleFiltersDirPath = this.config.getRulesDirFilters(); - const output = await ProcessHelper.execute( - ecaTestParam, + const output = await this.config.getToolRunner().runTool( + this.config.getEcatestFullPath(), [ '--sdk', sdkDirPath, diff --git a/client/src/models/tests/correlationUnitTestsRunnerViaEvtTests.ts b/client/src/models/tests/correlationUnitTestsRunnerViaEvtTests.ts index 679a8ce0..d9da32cc 100644 --- a/client/src/models/tests/correlationUnitTestsRunnerViaEvtTests.ts +++ b/client/src/models/tests/correlationUnitTestsRunnerViaEvtTests.ts @@ -2,7 +2,6 @@ import * as vscode from 'vscode'; import * as fs from 'fs'; import * as path from 'path'; -import { ProcessHelper } from '../../helpers/processHelper'; import { TestHelper } from '../../helpers/testHelper'; import { DialogHelper } from '../../helpers/dialogHelper'; import { Configuration } from '../configuration'; @@ -60,15 +59,14 @@ export class CorrelationUnitTestsRunnerViaEvtTests implements UnitTestRunner { // Очищаем и показываем окно Output const ruleFilePath = test.getRuleFullPath(); - const evt_tests = this.config.getEvtTestsFullPath(); const taxonomyFilePath = this.config.getTaxonomyFullPath(); const testFilepath = test.getTestExpectationPath(); const fptDefaults = this.config.getCorrelationDefaultsFilePath(rootFolder); const schemaFilePath = this.config.getSchemaFullPath(rootFolder); const ruleFiltersDirPath = this.config.getRulesDirFilters(); - const output = await ProcessHelper.execute( - evt_tests, + const output = await this.config.getToolRunner().runTool( + this.config.getEvtTestsFullPath(), [ 'run', 'correlate', @@ -86,7 +84,8 @@ export class CorrelationUnitTestsRunnerViaEvtTests implements UnitTestRunner { ], { encoding: 'utf-8', - outputChannel: this.config.getOutputChannel() + outputChannel: this.config.getOutputChannel(), + allowNonZeroExitCode: true } ); diff --git a/client/src/models/tests/normalizationUnitTestsRunnerViaEvtTests.ts b/client/src/models/tests/normalizationUnitTestsRunnerViaEvtTests.ts index af05dc17..192acff1 100644 --- a/client/src/models/tests/normalizationUnitTestsRunnerViaEvtTests.ts +++ b/client/src/models/tests/normalizationUnitTestsRunnerViaEvtTests.ts @@ -1,4 +1,3 @@ -import { ProcessHelper } from '../../helpers/processHelper'; import { Log } from '../../extension'; import { XpException } from '../xpException'; import { BaseUnitTest } from './baseUnitTest'; @@ -43,9 +42,10 @@ export class NormalizationUnitTestsRunnerViaEvtTests extends NormalizationUnitTe params = params.concat(['-x', this.config.getAppendixFullPath()]); } - const executeResult = await ProcessHelper.execute(evtTestsPath, params, { + const executeResult = await this.config.getToolRunner().runTool(evtTestsPath, params, { encoding: 'utf-8', - outputChannel: this.config.getOutputChannel() + outputChannel: this.config.getOutputChannel(), + allowNonZeroExitCode: true }); if (!executeResult.output) { diff --git a/client/src/providers/xpDocumentFormattingProvider.ts b/client/src/providers/xpDocumentFormattingProvider.ts new file mode 100644 index 00000000..a3c950f4 --- /dev/null +++ b/client/src/providers/xpDocumentFormattingProvider.ts @@ -0,0 +1,97 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as vscode from 'vscode'; + +import { Log } from '../extension'; +import { Configuration } from '../models/configuration'; +import { XpException } from '../models/xpException'; +import { LocalToolRunner, ToolRunner } from '../tools/toolRunner'; + +export class XpDocumentFormattingProvider implements vscode.DocumentFormattingEditProvider { + private static readonly SUPPORTED_LANGUAGES = ['xp', 'en', 'co', 'agr', 'flt']; + + public constructor(private readonly config: Configuration) {} + + public static init(config: Configuration): void { + const provider = new XpDocumentFormattingProvider(config); + + for (const language of this.SUPPORTED_LANGUAGES) { + config.getContext().subscriptions.push( + vscode.languages.registerDocumentFormattingEditProvider( + { scheme: 'file', language }, + provider + ) + ); + } + } + + public async provideDocumentFormattingEdits( + document: vscode.TextDocument + ): Promise { + const { formatterPath, runner } = this.getFormatterExecution(); + const tempDirectory = this.config.getRandTmpSubDirectoryPath(); + await fs.promises.mkdir(tempDirectory, { recursive: true }); + + const tempFilePath = path.join( + tempDirectory, + `${path.basename(document.fileName, path.extname(document.fileName))}.formatted${path.extname(document.fileName)}` + ); + + try { + await fs.promises.writeFile(tempFilePath, document.getText(), 'utf-8'); + await runner.runTool(formatterPath, [tempFilePath], { + encoding: 'utf-8', + cwd: path.dirname(tempFilePath) + }); + + const formattedText = await fs.promises.readFile(tempFilePath, 'utf-8'); + if (formattedText === document.getText()) { + return []; + } + + const fullRange = new vscode.Range( + document.positionAt(0), + document.positionAt(document.getText().length) + ); + + Log.info(`Formatted XP document '${document.fileName}' using '${formatterPath}'.`); + return [vscode.TextEdit.replace(fullRange, formattedText)]; + } catch (error) { + Log.warn(`Failed to format XP document '${document.fileName}': ${error.message}`); + throw error; + } finally { + await fs.promises.rm(tempDirectory, { recursive: true, force: true }); + } + } + + private getFormatterExecution(): { formatterPath: string; runner: ToolRunner } { + const configuredFormatterPath = this.config.getDirectFormatterExecutablePath(); + if (this.config.isLocalMacOS() && configuredFormatterPath) { + const resolvedFormatterPath = this.config.getResolvedFormatterExecutablePath(); + if (!resolvedFormatterPath) { + throw new XpException( + `XP formatter was not found at '${configuredFormatterPath}'. Update xpConfig.formatterExecutablePath or clear it to fall back to Docker formatting.` + ); + } + + return { + formatterPath: resolvedFormatterPath, + runner: new LocalToolRunner() + }; + } + + const resolvedFormatterPath = this.config.getResolvedFormatterExecutablePath(); + if (!resolvedFormatterPath) { + throw new XpException( + this.config.shouldUseDockerToolRunner() + ? 'XP formatter path is not configured for macOS and Docker formatter could not be resolved.' + : 'XP formatter was not found. Install a KBT version that includes evt-xp-formatter or configure xpConfig.formatterExecutablePath.' + ); + } + + return { + formatterPath: resolvedFormatterPath, + runner: this.config.getToolRunner() + }; + } +} diff --git a/client/src/providers/xpReferenceProvider.ts b/client/src/providers/xpReferenceProvider.ts deleted file mode 100644 index 1cd873c1..00000000 --- a/client/src/providers/xpReferenceProvider.ts +++ /dev/null @@ -1,21 +0,0 @@ -import * as vscode from 'vscode'; - -export class XpReferenceProvider implements vscode.ReferenceProvider { - public provideReferences( - document: vscode.TextDocument, - position: vscode.Position, - options: { includeDeclaration: boolean }, - token: vscode.CancellationToken - ): Thenable { - return Promise.resolve([ - new vscode.Location( - document.uri, - new vscode.Range(new vscode.Position(3, 1), new vscode.Position(3, 4)) - ), - new vscode.Location( - document.uri, - new vscode.Range(new vscode.Position(6, 1), new vscode.Position(6, 4)) - ) - ]); - } -} diff --git "a/client/src/test/helpers/toolsHelper.\321\201onfiguration.test.ts" "b/client/src/test/helpers/toolsHelper.\321\201onfiguration.test.ts" index 0237a94a..713d4d38 100644 --- "a/client/src/test/helpers/toolsHelper.\321\201onfiguration.test.ts" +++ "b/client/src/test/helpers/toolsHelper.\321\201onfiguration.test.ts" @@ -6,8 +6,9 @@ import { Configuration } from '../../models/configuration'; suite('Configuration', () => { test('Наличие ecatest', () => { - const fullPath = Configuration.get().getEcatestFullPath(); - assert.ok(fs.existsSync(fullPath)); + const config = Configuration.get(); + const fullPath = config.getEcatestFullPath(); + assert.ok(config.shouldUseDockerToolRunner() ? fullPath.includes('/ecatest') : fs.existsSync(fullPath)); }); test('Наличие таксономии', async () => { @@ -16,8 +17,9 @@ suite('Configuration', () => { }); test('Наличие siemj', async () => { - const fullPath = Configuration.get().getSiemjPath(); - assert.ok(fs.existsSync(fullPath)); + const config = Configuration.get(); + const fullPath = config.getSiemjPath(); + assert.ok(config.shouldUseDockerToolRunner() ? fullPath.includes('/siemj') : fs.existsSync(fullPath)); }); test('Создание временной директории', async () => { diff --git a/client/src/test/tools/pathMapper.test.ts b/client/src/test/tools/pathMapper.test.ts new file mode 100644 index 00000000..a3ca241a --- /dev/null +++ b/client/src/test/tools/pathMapper.test.ts @@ -0,0 +1,59 @@ +import * as assert from 'assert'; + +import { PathMapper } from '../../tools/pathMapper'; + +suite('PathMapper', () => { + test('maps host paths to container paths', () => { + const mapper = new PathMapper({ + workspaceHostPath: '/Users/alice/Work/knowledgebase', + workspaceContainerPath: '/workspaces/knowledgebase' + }); + + assert.strictEqual( + mapper.hostToContainer('/Users/alice/Work/knowledgebase/packages/demo/rule.co'), + '/workspaces/knowledgebase/packages/demo/rule.co' + ); + }); + + test('maps container paths to host paths', () => { + const mapper = new PathMapper({ + workspaceHostPath: '/Users/alice/Work/knowledgebase', + workspaceContainerPath: '/workspaces/knowledgebase' + }); + + assert.strictEqual( + mapper.containerToHost('/workspaces/knowledgebase/packages/demo/rule.co'), + '/Users/alice/Work/knowledgebase/packages/demo/rule.co' + ); + }); + + test('maps --name=/path arguments', () => { + const mapper = new PathMapper({ + workspaceHostPath: '/Users/alice/Work/knowledgebase', + workspaceContainerPath: '/workspaces/knowledgebase' + }); + + assert.strictEqual( + mapper.mapCommandArgument('--schema=/Users/alice/Work/knowledgebase/tmp/xp-output/schema.json'), + '--schema=/workspaces/knowledgebase/tmp/xp-output/schema.json' + ); + }); + + test('does not map non-path command arguments', () => { + const mapper = new PathMapper({ + workspaceHostPath: '/Users/alice/Work/knowledgebase', + workspaceContainerPath: '/workspaces/knowledgebase' + }); + + assert.strictEqual(mapper.mapCommandArgument('main'), 'main'); + assert.strictEqual(mapper.mapCommandArgument('--lang'), '--lang'); + }); + + test('reports missing mapping', () => { + const mapper = new PathMapper({}); + const validation = mapper.validateMapping(); + + assert.strictEqual(validation.isValid, false); + assert.ok(validation.message.includes('workspaceHostPath')); + }); +}); diff --git a/client/src/test/tools/toolRunner.test.ts b/client/src/test/tools/toolRunner.test.ts new file mode 100644 index 00000000..2197de6a --- /dev/null +++ b/client/src/test/tools/toolRunner.test.ts @@ -0,0 +1,44 @@ +import * as assert from 'assert'; + +import { DockerToolRunner, ToolRunnerFactory } from '../../tools/toolRunner'; + +suite('ToolRunnerFactory', () => { + test('uses explicit local mode', () => { + assert.strictEqual(ToolRunnerFactory.resolveMode('local'), 'local'); + }); + + test('uses explicit docker mode', () => { + assert.strictEqual(ToolRunnerFactory.resolveMode('docker'), 'docker'); + }); +}); + +suite('DockerToolRunner', () => { + test('builds docker exec arguments without shell command string', () => { + const runner = new DockerToolRunner({ + containerName: 'xp-kbt', + workspaceHostPath: '/Users/alice/Work/knowledgebase', + workspaceContainerPath: '/workspaces/knowledgebase', + kbtBaseDirectory: '/opt/xp-kbt' + }); + + assert.deepStrictEqual( + runner.buildDockerExecArgs( + 'xp-kbt', + '/opt/xp-kbt/extra-tools/siemj/siemj', + ['-c', '/workspaces/knowledgebase/tmp/xp-output/siemj.conf', 'main'], + { cwd: '/Users/alice/Work/knowledgebase' } + ), + [ + 'exec', + '-i', + '-w', + '/workspaces/knowledgebase', + 'xp-kbt', + '/opt/xp-kbt/extra-tools/siemj/siemj', + '-c', + '/workspaces/knowledgebase/tmp/xp-output/siemj.conf', + 'main' + ] + ); + }); +}); diff --git a/client/src/tools/kbtInstaller.ts b/client/src/tools/kbtInstaller.ts new file mode 100644 index 00000000..d2c4aa51 --- /dev/null +++ b/client/src/tools/kbtInstaller.ts @@ -0,0 +1,218 @@ +import * as https from 'https'; +import * as vscode from 'vscode'; + +import { ProcessHelper } from '../helpers/processHelper'; +import { Log } from '../extension'; + +interface GitHubReleaseAsset { + name: string; + browser_download_url: string; +} + +interface GitHubRelease { + tag_name: string; + assets: GitHubReleaseAsset[]; +} + +export class KbtInstaller { + private static readonly LatestReleaseUrl = + 'https://api.github.com/repos/vxcontrol/xp-kbt/releases/latest'; + private static readonly ReleasesUrl = + 'https://api.github.com/repos/vxcontrol/xp-kbt/releases?per_page=30'; + + public static async installLatestIntoContainer( + containerName: string, + targetDirectory = '/home/coder/xp-kbt', + outputChannel?: vscode.OutputChannel + ): Promise { + const release = await this.getLatestRelease(); + return this.installReleaseIntoContainer(containerName, release, targetDirectory, outputChannel); + } + + public static async listReleases(): Promise { + return this.getJson(this.ReleasesUrl); + } + + public static async installReleaseIntoContainer( + containerName: string, + release: GitHubRelease, + targetDirectory = '/home/coder/xp-kbt', + outputChannel?: vscode.OutputChannel + ): Promise { + const asset = this.pickLinuxAsset(release); + + if (!asset) { + throw new Error( + `Could not find a Linux xp-kbt asset in release ${release.tag_name}. Open https://github.com/vxcontrol/xp-kbt/releases/latest and set xpConfig.docker.kbtBaseDirectory manually.` + ); + } + + this.showOutputLinkedNotification( + `Installing xp-kbt ${release.tag_name} into container '${containerName}'. Details are available in the extension output.`, + outputChannel + ); + Log.info(`XP container setup: installing xp-kbt ${release.tag_name}`); + Log.info(`XP container setup: target ${containerName}:${targetDirectory}`); + Log.info(`XP container setup: asset ${asset.name}`); + + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + cancellable: false, + title: `Downloading xp-kbt ${release.tag_name}` + }, + async (progress) => { + Log.progress(progress, `Installing into ${containerName}:${targetDirectory}`); + + const archivePath = `/tmp/${asset.name}`; + const quotedTargetDirectory = this.shellQuote(targetDirectory); + const quotedArchivePath = this.shellQuote(archivePath); + const quotedDownloadUrl = this.shellQuote(asset.browser_download_url); + const command = [ + 'set -e', + this.echoCommand('XP :: Ensuring container prerequisites'), + this.getInstallPrerequisitesCommand(), + this.echoCommand(`XP :: Creating KBT target directory ${targetDirectory}`), + `mkdir -p ${quotedTargetDirectory}`, + this.echoCommand(`XP :: Downloading ${asset.name}`), + `curl -fL ${quotedDownloadUrl} -o ${quotedArchivePath}`, + this.echoCommand('XP :: Extracting KBT archive'), + this.getExtractCommand(archivePath, targetDirectory), + this.echoCommand('XP :: Removing temporary archive'), + `rm -f ${quotedArchivePath}`, + this.echoCommand('XP :: Verifying KBT tools'), + `test -x ${this.shellQuote( + `${targetDirectory}/extra-tools/siemj/siemj` + )} -o -x ${this.shellQuote(`${targetDirectory}/build-tools/normalize`)}` + ].join(' && '); + + const result = await ProcessHelper.execute( + 'docker', + ['exec', containerName, 'sh', '-lc', command], + { + encoding: 'utf-8', + outputChannel + } + ); + + if (result.exitCode !== 0) { + throw new Error( + `Failed to install xp-kbt in container '${containerName}'. ${result.output}` + ); + } + + Log.progress(progress, `xp-kbt ${release.tag_name} installed`); + } + ); + + return targetDirectory; + } + + private static showOutputLinkedNotification( + message: string, + outputChannel?: vscode.OutputChannel + ): void { + if (!outputChannel) { + void vscode.window.showInformationMessage(message); + return; + } + + void vscode.window.showInformationMessage(message, 'Show Output').then((answer) => { + if (answer === 'Show Output') { + outputChannel.show(); + } + }); + } + + private static getInstallPrerequisitesCommand(): string { + return [ + 'if ! command -v curl >/dev/null 2>&1 || ! command -v tar >/dev/null 2>&1 || ! command -v unzip >/dev/null 2>&1; then', + 'if command -v apt-get >/dev/null 2>&1; then', + 'apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y ca-certificates curl tar unzip;', + 'elif command -v apk >/dev/null 2>&1; then', + 'apk add --no-cache ca-certificates curl tar unzip;', + 'else', + 'echo "Container must have curl, tar and unzip, or use a Debian/Alpine-based image."; exit 127;', + 'fi;', + 'fi' + ].join(' '); + } + + private static getExtractCommand(archivePath: string, targetDirectory: string): string { + const quotedArchivePath = this.shellQuote(archivePath); + const quotedTargetDirectory = this.shellQuote(targetDirectory); + + if (archivePath.endsWith('.zip')) { + const extractDirectory = `${archivePath}.extract`; + const quotedExtractDirectory = this.shellQuote(extractDirectory); + + return [ + `rm -rf ${quotedExtractDirectory}`, + `mkdir -p ${quotedExtractDirectory}`, + `unzip -q -o ${quotedArchivePath} -d ${quotedExtractDirectory}`, + `find ${quotedTargetDirectory} -mindepth 1 -maxdepth 1 -exec rm -rf {} +`, + `ENTRY_COUNT=$(find ${quotedExtractDirectory} -mindepth 1 -maxdepth 1 | wc -l | tr -d ' ')`, + `SOURCE_DIR=${quotedExtractDirectory}`, + `if [ "$ENTRY_COUNT" = "1" ]; then FIRST_ENTRY=$(find ${quotedExtractDirectory} -mindepth 1 -maxdepth 1 | head -n 1); if [ -d "$FIRST_ENTRY" ]; then SOURCE_DIR="$FIRST_ENTRY"; fi; fi`, + `find "$SOURCE_DIR" -mindepth 1 -maxdepth 1 -exec mv {} ${quotedTargetDirectory}/ \\;`, + `rm -rf ${quotedExtractDirectory}` + ].join(' && '); + } + + return `tar -xzf ${quotedArchivePath} -C ${quotedTargetDirectory} --strip-components=1`; + } + + private static pickLinuxAsset(release: GitHubRelease): GitHubReleaseAsset | undefined { + return release.assets.find((asset) => { + const name = asset.name.toLowerCase(); + return ( + /linux|gnu|ubuntu|debian/.test(name) && + (name.endsWith('.tar.gz') || name.endsWith('.tgz') || name.endsWith('.zip')) + ); + }); + } + + private static async getLatestRelease(): Promise { + return this.getJson(this.LatestReleaseUrl); + } + + private static async getJson(url: string): Promise { + return new Promise((resolve, reject) => { + https + .get( + url, + { + headers: { + 'User-Agent': 'SecurityExpertsCommunity.xp' + } + }, + (response) => { + if (response.statusCode < 200 || response.statusCode >= 300) { + reject(new Error(`GitHub release request failed with ${response.statusCode}`)); + return; + } + + let data = ''; + response.setEncoding('utf8'); + response.on('data', (chunk) => (data += chunk)); + response.on('end', () => { + try { + resolve(JSON.parse(data) as T); + } catch (error) { + reject(error); + } + }); + } + ) + .on('error', reject); + }); + } + + private static shellQuote(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'`; + } + + private static echoCommand(message: string): string { + return `echo ${this.shellQuote(message)}`; + } +} diff --git a/client/src/tools/macosContainerSetup.ts b/client/src/tools/macosContainerSetup.ts new file mode 100644 index 00000000..c4512904 --- /dev/null +++ b/client/src/tools/macosContainerSetup.ts @@ -0,0 +1,621 @@ +import * as vscode from 'vscode'; +import * as path from 'path'; +import * as fs from 'fs'; + +import { ProcessHelper } from '../helpers/processHelper'; +import { Configuration } from '../models/configuration'; +import { KbtInstaller } from './kbtInstaller'; +import { Log } from '../extension'; + +interface ContainerSelection { + name: string; + created: boolean; + workspaceContainerPath?: string; +} + +export class MacOSContainerSetup { + private static readonly DEFAULT_TOOLS_IMAGE = 'mcr.microsoft.com/dotnet/sdk:8.0'; + private static readonly DEFAULT_KNOWLEDGEBASE_CONTAINER_PATH = '/workspaces/knowledgebase'; + private static readonly KBT_PATH_CANDIDATES = [ + '/home/coder/xp-kbt', + '/opt/xp-kbt', + '/usr/local/xp-kbt', + '/workspaces/xp-kbt' + ]; + + public static async maybePrompt(config: Configuration): Promise { + if (!config.isLocalMacOS()) { + return; + } + + const setupIssue = await this.getMacOSDockerSetupIssue(config); + if (!config.getMacOSShowContainerSetupPrompt() && !setupIssue) { + return; + } + + const buttons = setupIssue + ? ['Configure', 'Show Output', 'Open README', 'Keep current'] + : ['Configure', 'Open README', 'Do not show again']; + const message = setupIssue + ? `XP Docker backend needs configuration: ${setupIssue}. Configure it now?` + : 'XP tools are not available natively on macOS. Configure a container backend?'; + + const answer = await vscode.window.showInformationMessage( + message, + ...buttons + ); + + switch (answer) { + case 'Configure': + try { + await this.configure(config); + } catch (error) { + vscode.window.showErrorMessage(error.message); + } + break; + case 'Open README': + await vscode.env.openExternal( + vscode.Uri.parse( + 'https://github.com/Security-Experts-Community/vscode-xp#macos-hybrid-mode' + ) + ); + break; + case 'Show Output': + config.getOutputChannel().show(); + break; + case 'Do not show again': + await config.setMacOSShowContainerSetupPrompt(false); + break; + } + } + + private static async getMacOSDockerSetupIssue( + config: Configuration + ): Promise { + const xpConfig = config.getWorkspaceConfiguration(); + const containerName = xpConfig.get('docker.containerName'); + const workspaceHostPath = xpConfig.get('docker.workspaceHostPath'); + const workspaceContainerPath = xpConfig.get('docker.workspaceContainerPath'); + const kbtBaseDirectory = xpConfig.get('docker.kbtBaseDirectory'); + + if (!config.shouldUseDockerToolRunner()) { + return containerName || workspaceHostPath || workspaceContainerPath || kbtBaseDirectory + ? 'container backend is configured but not enabled' + : undefined; + } + + if (!containerName) { + return 'container is not selected'; + } + + if (!workspaceHostPath) { + return 'local knowledgebase path is not configured'; + } + + if (!fs.existsSync(workspaceHostPath)) { + return `local knowledgebase path does not exist: ${workspaceHostPath}`; + } + + if (!workspaceContainerPath) { + return 'container knowledgebase mount path is not configured'; + } + + if (!kbtBaseDirectory) { + return 'container KBT path is not configured'; + } + + if (!this.looksLikeKnowledgebaseRoot(workspaceHostPath)) { + return 'selected host path does not look like a knowledgebase root'; + } + + const dockerAvailable = await this.isDockerAvailable(); + if (!dockerAvailable) { + return 'Docker Desktop is not available'; + } + + const containerRunning = await this.isContainerRunning(containerName); + if (!containerRunning) { + return `container '${containerName}' is not running`; + } + + const kbtAvailable = await this.isKbtAvailable(containerName, kbtBaseDirectory); + if (!kbtAvailable) { + return `KBT was not found in container at '${kbtBaseDirectory}'`; + } + + return undefined; + } + + public static async configure(config: Configuration): Promise { + const knowledgebaseFolder = await vscode.window.showOpenDialog({ + canSelectFolders: true, + canSelectFiles: false, + canSelectMany: false, + title: 'Select local knowledgebase folder' + }); + + if (!knowledgebaseFolder?.[0]) { + return; + } + + const knowledgebaseHostPath = await this.resolveKnowledgebaseHostPath( + knowledgebaseFolder[0].fsPath + ); + await this.ensureDockerAvailable(); + const container = await this.pickContainer(knowledgebaseHostPath, config.getOutputChannel()); + if (!container) { + return; + } + + const containerName = container.name; + const workspaceContainerPath = + container.workspaceContainerPath ?? + (await this.detectContainerMountPath(containerName, knowledgebaseHostPath)) ?? + (await this.askContainerPath( + 'Knowledgebase container path', + this.DEFAULT_KNOWLEDGEBASE_CONTAINER_PATH + )); + + if (!workspaceContainerPath) { + return; + } + + let kbtBaseDirectory = await this.detectKbtBaseDirectory(containerName); + + if (!kbtBaseDirectory) { + kbtBaseDirectory = await this.resolveMissingKbt(containerName, config.getOutputChannel()); + } + + if (!kbtBaseDirectory) { + return; + } + + const xpConfig = config.getWorkspaceConfiguration(); + const hostOutputDirectory = path.join(knowledgebaseHostPath, 'tmp', 'xp-output'); + await fs.promises.mkdir(hostOutputDirectory, { recursive: true }); + + await xpConfig.update('toolExecutionMode', 'docker', vscode.ConfigurationTarget.Workspace); + await xpConfig.update( + 'outputDirectoryPath', + hostOutputDirectory, + vscode.ConfigurationTarget.Workspace + ); + await xpConfig.update( + 'docker.workspaceHostPath', + knowledgebaseHostPath, + vscode.ConfigurationTarget.Workspace + ); + await xpConfig.update( + 'docker.workspaceContainerPath', + workspaceContainerPath, + vscode.ConfigurationTarget.Workspace + ); + await xpConfig.update( + 'docker.kbtBaseDirectory', + kbtBaseDirectory, + vscode.ConfigurationTarget.Workspace + ); + await xpConfig.update( + 'docker.outputDirectoryPath', + path.posix.join(workspaceContainerPath, 'tmp', 'xp-output'), + vscode.ConfigurationTarget.Workspace + ); + + await xpConfig.update( + 'docker.containerName', + containerName, + vscode.ConfigurationTarget.Workspace + ); + + await config.setMacOSShowContainerSetupPrompt(false); + + vscode.window.showInformationMessage( + `XP container backend configured for '${containerName}'.` + ); + } + + private static async ensureDockerAvailable(): Promise { + const result = await ProcessHelper.execute('docker', ['version'], { + encoding: 'utf-8', + checkCommandBeforeExecution: true + }); + + if (result.exitCode !== 0) { + throw new Error('Docker is not available. Install Docker Desktop and start it first.'); + } + } + + private static async pickContainer( + knowledgebaseHostPath: string, + outputChannel: vscode.OutputChannel + ): Promise { + const result = await ProcessHelper.execute( + 'docker', + ['ps', '--format', '{{.Names}}'], + { encoding: 'utf-8' } + ); + + const containerNames = result.output + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line); + + const createContainerLabel = 'Create new XP tools container'; + const items = [ + { + label: createContainerLabel, + description: 'Bind mount the selected knowledgebase and install xp-kbt' + }, + ...containerNames.map((name) => ({ + label: name, + description: 'Use existing running container' + })) + ]; + + const selected = await vscode.window.showQuickPick(items, { + placeHolder: + containerNames.length === 0 + ? 'No running XP backend containers found' + : 'Select or create XP backend container' + }); + + if (!selected) { + return undefined; + } + + if (selected.label === createContainerLabel) { + return this.createToolsContainer(knowledgebaseHostPath, outputChannel); + } + + return { + name: selected.label, + created: false + }; + } + + private static async createToolsContainer( + knowledgebaseHostPath: string, + outputChannel: vscode.OutputChannel + ): Promise { + const defaultName = this.makeDefaultContainerName(knowledgebaseHostPath); + const containerName = await vscode.window.showInputBox({ + prompt: 'New Docker container name', + value: defaultName, + ignoreFocusOut: true, + validateInput: (input) => + /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(input) + ? undefined + : 'Use letters, numbers, dots, underscores and hyphens' + }); + + if (!containerName) { + return undefined; + } + + const workspaceContainerPath = + (await this.askContainerPath( + 'Knowledgebase container path', + this.DEFAULT_KNOWLEDGEBASE_CONTAINER_PATH + )) ?? this.DEFAULT_KNOWLEDGEBASE_CONTAINER_PATH; + + this.showOutputLinkedNotification( + `Creating XP tools container '${containerName}'. Details are available in the extension output.`, + outputChannel + ); + Log.info(`XP container setup: creating container '${containerName}'`); + Log.info(`XP container setup: image ${this.DEFAULT_TOOLS_IMAGE}`); + Log.info(`XP container setup: platform linux/amd64`); + Log.info(`XP container setup: mounting ${knowledgebaseHostPath} -> ${workspaceContainerPath}`); + + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + cancellable: false, + title: `Creating XP tools container '${containerName}'` + }, + async (progress) => { + Log.progress(progress, `Pulling/starting Docker image ${this.DEFAULT_TOOLS_IMAGE}`); + const result = await ProcessHelper.execute( + 'docker', + [ + 'run', + '-d', + '--platform', + 'linux/amd64', + '--name', + containerName, + '-v', + `${knowledgebaseHostPath}:${workspaceContainerPath}`, + '-w', + workspaceContainerPath, + this.DEFAULT_TOOLS_IMAGE, + 'sleep', + 'infinity' + ], + { + encoding: 'utf-8', + outputChannel + } + ); + + if (result.exitCode !== 0) { + throw new Error( + `Failed to create XP tools container. ${result.output || 'Check Docker Desktop and image pull permissions.'}` + ); + } + + Log.progress(progress, `XP tools container '${containerName}' is running`); + } + ); + + return { + name: containerName, + created: true, + workspaceContainerPath + }; + } + + private static makeDefaultContainerName(knowledgebaseHostPath: string): string { + const folderName = path.basename(path.resolve(knowledgebaseHostPath)) || 'knowledgebase'; + const safeFolderName = folderName.replace(/[^a-zA-Z0-9_.-]/g, '-').replace(/^-+/, ''); + const suffix = new Date().toISOString().replace(/[-:TZ.]/g, '').slice(0, 12); + return `xp-tools-${safeFolderName || 'kb'}-${suffix}`.slice(0, 64); + } + + private static showOutputLinkedNotification( + message: string, + outputChannel: vscode.OutputChannel + ): void { + void vscode.window.showInformationMessage(message, 'Show Output').then((answer) => { + if (answer === 'Show Output') { + outputChannel.show(); + } + }); + } + + private static async resolveKnowledgebaseHostPath(selectedPath: string): Promise { + const detectedPath = this.findKnowledgebaseRoot(selectedPath); + + if (!detectedPath || detectedPath === selectedPath) { + return selectedPath; + } + + const answer = await vscode.window.showInformationMessage( + `Knowledgebase root was detected at '${detectedPath}'. Use it for Docker bind mount?`, + 'Use detected root', + 'Use selected folder' + ); + + return answer === 'Use selected folder' ? selectedPath : detectedPath; + } + + private static findKnowledgebaseRoot(startPath: string): string | undefined { + let currentPath = path.resolve(startPath); + + while (true) { + if (this.looksLikeKnowledgebaseRoot(currentPath)) { + return currentPath; + } + + const parentPath = path.dirname(currentPath); + if (parentPath === currentPath) { + return undefined; + } + + currentPath = parentPath; + } + } + + private static looksLikeKnowledgebaseRoot(candidatePath: string): boolean { + const packagesPath = path.join(candidatePath, 'packages'); + const contentTypesPath = path.join(candidatePath, 'content_types'); + const rulesPath = path.join(candidatePath, 'rules'); + const metadataPath = path.join(candidatePath, 'metainfo.yaml'); + + return ( + fs.existsSync(packagesPath) || + fs.existsSync(contentTypesPath) || + fs.existsSync(rulesPath) || + fs.existsSync(metadataPath) + ); + } + + private static async detectContainerMountPath( + containerName: string, + hostPath: string + ): Promise { + const result = await ProcessHelper.execute( + 'docker', + ['inspect', '--format', '{{json .Mounts}}', containerName], + { encoding: 'utf-8' } + ); + + if (result.exitCode !== 0) { + return undefined; + } + + try { + const mounts = JSON.parse(result.output.trim()) as Array<{ + Source?: string; + Destination?: string; + }>; + + const normalizedHostPath = path.resolve(hostPath); + const matchingMount = mounts + .filter((mount) => mount.Source && mount.Destination) + .sort((a, b) => b.Source.length - a.Source.length) + .find((mount) => { + const source = path.resolve(mount.Source); + return normalizedHostPath === source || normalizedHostPath.startsWith(source + path.sep); + }); + + if (!matchingMount) { + return undefined; + } + + const relativePath = path.relative(path.resolve(matchingMount.Source), normalizedHostPath); + return relativePath + ? path.posix.join(matchingMount.Destination, relativePath.split(path.sep).join('/')) + : matchingMount.Destination; + } catch { + return undefined; + } + } + + private static async detectKbtBaseDirectory( + containerName: string + ): Promise { + for (const candidate of this.KBT_PATH_CANDIDATES) { + const result = await ProcessHelper.execute( + 'docker', + [ + 'exec', + containerName, + 'sh', + '-lc', + `test -x '${candidate}/extra-tools/siemj/siemj' -o -x '${candidate}/build-tools/normalize'` + ], + { encoding: 'utf-8' } + ); + + if (result.exitCode === 0) { + return candidate; + } + } + + return undefined; + } + + private static async isDockerAvailable(): Promise { + try { + const result = await ProcessHelper.execute('docker', ['version'], { + encoding: 'utf-8', + checkCommandBeforeExecution: true + }); + + return result.exitCode === 0; + } catch { + return false; + } + } + + private static async isContainerRunning(containerName: string): Promise { + const result = await ProcessHelper.execute( + 'docker', + ['inspect', '-f', '{{.State.Running}}', containerName], + { encoding: 'utf-8' } + ); + + return result.exitCode === 0 && result.output.trim() === 'true'; + } + + private static async isKbtAvailable( + containerName: string, + kbtBaseDirectory: string + ): Promise { + const result = await ProcessHelper.execute( + 'docker', + [ + 'exec', + containerName, + 'sh', + '-lc', + `test -x '${kbtBaseDirectory}/extra-tools/siemj/siemj' -o -x '${kbtBaseDirectory}/build-tools/normalize'` + ], + { encoding: 'utf-8' } + ); + + return result.exitCode === 0; + } + + private static async resolveMissingKbt( + containerName: string, + outputChannel: vscode.OutputChannel + ): Promise { + const action = await vscode.window.showQuickPick( + [ + { + label: 'Download latest xp-kbt', + description: 'Recommended' + }, + { + label: 'Choose xp-kbt version', + description: 'Select one of the recent GitHub releases' + }, + { + label: 'Enter path manually', + description: 'Use already installed KBT in the container' + } + ], + { + placeHolder: `xp-kbt was not found in container '${containerName}'` + } + ); + + switch (action?.label) { + case 'Download latest xp-kbt': + return KbtInstaller.installLatestIntoContainer( + containerName, + '/home/coder/xp-kbt', + outputChannel + ); + case 'Choose xp-kbt version': + return this.chooseAndInstallKbtVersion(containerName, outputChannel); + case 'Enter path manually': + return this.askContainerPath('KBT base directory in container', '/home/coder/xp-kbt'); + default: + return undefined; + } + } + + private static async chooseAndInstallKbtVersion( + containerName: string, + outputChannel: vscode.OutputChannel + ): Promise { + const releases = await KbtInstaller.listReleases(); + const selectedRelease = await vscode.window.showQuickPick( + releases.map((release) => ({ + label: release.tag_name, + description: release.assets.map((asset) => asset.name).join(', '), + release + })), + { + placeHolder: 'Select xp-kbt release' + } + ); + + if (!selectedRelease) { + return undefined; + } + + const targetDirectory = await this.askContainerPath( + 'KBT install directory in container', + `/home/coder/xp-kbt-${selectedRelease.release.tag_name}` + ); + + if (!targetDirectory) { + return undefined; + } + + return KbtInstaller.installReleaseIntoContainer( + containerName, + selectedRelease.release, + targetDirectory, + outputChannel + ); + } + + private static async askContainerPath( + prompt: string, + value: string + ): Promise { + const result = await vscode.window.showInputBox({ + prompt, + value, + ignoreFocusOut: true, + validateInput: (input) => (input.startsWith('/') ? undefined : 'Use an absolute Linux path') + }); + + return result?.replace(/\/+$/, ''); + } +} diff --git a/client/src/tools/pathMapper.ts b/client/src/tools/pathMapper.ts new file mode 100644 index 00000000..929a9316 --- /dev/null +++ b/client/src/tools/pathMapper.ts @@ -0,0 +1,157 @@ +import * as path from 'path'; + +export interface PathMapping { + hostPath: string; + containerPath: string; +} + +export interface PathMapperOptions { + workspaceHostPath?: string; + workspaceContainerPath?: string; + extraMappings?: PathMapping[]; +} + +export interface PathMappingValidationResult { + isValid: boolean; + message?: string; +} + +export class PathMapper { + private readonly mappings: PathMapping[]; + + constructor(options: PathMapperOptions) { + this.mappings = []; + + if (options.workspaceHostPath && options.workspaceContainerPath) { + this.mappings.push({ + hostPath: PathMapper.normalizeHostPath(options.workspaceHostPath), + containerPath: PathMapper.normalizeContainerPath(options.workspaceContainerPath) + }); + } + + for (const mapping of options.extraMappings ?? []) { + if (!mapping.hostPath || !mapping.containerPath) { + continue; + } + + this.mappings.push({ + hostPath: PathMapper.normalizeHostPath(mapping.hostPath), + containerPath: PathMapper.normalizeContainerPath(mapping.containerPath) + }); + } + + this.mappings.sort((a, b) => b.hostPath.length - a.hostPath.length); + } + + public hostToContainer(inputPath: string): string { + return this.mapPath(inputPath, 'hostToContainer'); + } + + public containerToHost(inputPath: string): string { + return this.mapPath(inputPath, 'containerToHost'); + } + + public resolveWorkspaceMount(): PathMapping | undefined { + return this.mappings[0]; + } + + public validateMapping(): PathMappingValidationResult { + if (this.mappings.length === 0) { + return { + isValid: false, + message: + 'Path mapping is not configured. Set xpConfig.docker.workspaceHostPath and xpConfig.docker.workspaceContainerPath.' + }; + } + + return { isValid: true }; + } + + public mapCommandArgument(argument: string): string { + if (!argument) { + return argument; + } + + const prefixedPath = /^([^=]+)=(.+)$/.exec(argument); + if (prefixedPath) { + if (!this.shouldMapInput(prefixedPath[2])) { + return argument; + } + + return `${prefixedPath[1]}=${this.hostToContainer(prefixedPath[2])}`; + } + + if (!this.shouldMapInput(argument)) { + return argument; + } + + return this.hostToContainer(argument); + } + + public mapText(text: string): string { + let result = text; + for (const mapping of this.mappings) { + result = result.split(mapping.hostPath).join(mapping.containerPath); + } + + return result; + } + + private mapPath(inputPath: string, direction: 'hostToContainer' | 'containerToHost'): string { + if (!this.shouldMapInput(inputPath)) { + return inputPath; + } + + const normalizedInput = + direction === 'hostToContainer' + ? PathMapper.normalizeHostPath(inputPath) + : PathMapper.normalizeContainerPath(inputPath); + + for (const mapping of this.mappings) { + const from = direction === 'hostToContainer' ? mapping.hostPath : mapping.containerPath; + const to = direction === 'hostToContainer' ? mapping.containerPath : mapping.hostPath; + + if (normalizedInput === from) { + return to; + } + + if (normalizedInput.startsWith(from + path.sep) || normalizedInput.startsWith(from + '/')) { + const relativePath = normalizedInput.substring(from.length).replace(/^[\\/]/, ''); + return PathMapper.joinContainerAware(to, relativePath); + } + } + + return inputPath; + } + + private static joinContainerAware(basePath: string, relativePath: string): string { + if (!relativePath) { + return basePath; + } + + if (basePath.includes('\\')) { + return path.win32.join(basePath, relativePath); + } + + return path.posix.join(basePath, relativePath.split(path.sep).join('/')); + } + + private static normalizeHostPath(inputPath: string): string { + return path.resolve(inputPath); + } + + private static normalizeContainerPath(inputPath: string): string { + return inputPath.replace(/\\/g, '/').replace(/\/+$/, ''); + } + + private shouldMapInput(inputPath: string): boolean { + if (path.isAbsolute(inputPath) || inputPath.startsWith('/')) { + return true; + } + + return this.mappings.some( + (mapping) => + inputPath.startsWith(mapping.hostPath) || inputPath.startsWith(mapping.containerPath) + ); + } +} diff --git a/client/src/tools/sdkUtilitiesWrappers.ts b/client/src/tools/sdkUtilitiesWrappers.ts index 25b06ee2..d97f968d 100644 --- a/client/src/tools/sdkUtilitiesWrappers.ts +++ b/client/src/tools/sdkUtilitiesWrappers.ts @@ -6,7 +6,6 @@ import { Configuration } from '../models/configuration'; import { Normalization } from '../models/content/normalization'; import { NormalizationUnitTest } from '../models/tests/normalizationUnitTest'; // import { RuleFileDiagnostics } from '../views/integrationTests/ruleFileDiagnostics'; -import { ProcessHelper } from '../helpers/processHelper'; import { DialogHelper } from '../helpers/dialogHelper'; import { ExceptionHelper } from '../helpers/exceptionHelper'; import { FileSystemHelper } from '../helpers/fileSystemHelper'; @@ -53,7 +52,6 @@ export class SDKUtilitiesWrappers { */ // Формируем параметры запуска утилиты - const normalizeExePath = this.config.getNormalizer(); const sdkPath = this.config.getSiemSdkDirectoryPath(); const rootPath = rule.getContentRootPath(this.config); const rootFolder = path.basename(rootPath); @@ -87,7 +85,7 @@ export class SDKUtilitiesWrappers { normalizerParams.push('-e'); - const executeResult = await ProcessHelper.execute(normalizeExePath, normalizerParams, { + const executeResult = await this.config.getToolRunner().runNormalizer(normalizerParams, { outputChannel: this.config.getOutputChannel() }); diff --git a/client/src/tools/toolRunner.ts b/client/src/tools/toolRunner.ts new file mode 100644 index 00000000..8cfd5024 --- /dev/null +++ b/client/src/tools/toolRunner.ts @@ -0,0 +1,345 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as vscode from 'vscode'; + +import { + ExecutionProcessOptions, + ExecutionResult, + ProcessHelper +} from '../helpers/processHelper'; +import { XpException } from '../models/xpException'; +import { PathMapper, PathMapping } from './pathMapper'; + +export type ToolExecutionMode = 'auto' | 'local' | 'docker'; + +export interface ToolRunOptions extends ExecutionProcessOptions { + cwd?: string; + allowNonZeroExitCode?: boolean; +} + +export interface ToolRunner { + runTool(command: string, args: string[], options?: ToolRunOptions): Promise; + runKbt(args: string[], options?: ToolRunOptions): Promise; + runSiemj(args: string[], options?: ToolRunOptions): Promise; + runNormalizer(args: string[], options?: ToolRunOptions): Promise; +} + +export interface ToolRunnerFactoryOptions { + mode: ToolExecutionMode; + kbtBaseDirectory?: string; + outputDirectoryPath?: string; + docker: DockerToolRunnerOptions; +} + +export interface DockerToolRunnerOptions { + containerName?: string; + composeFile?: string; + serviceName?: string; + workspaceHostPath?: string; + workspaceContainerPath?: string; + kbtBaseDirectory?: string; + outputDirectoryPath?: string; + extraMappings?: PathMapping[]; +} + +export class LocalToolRunner implements ToolRunner { + constructor(private readonly kbtBaseDirectory?: string) {} + + public runTool( + command: string, + args: string[], + options: ToolRunOptions = {} + ): Promise { + return ProcessHelper.execute(command, args, options); + } + + public runKbt(args: string[], options?: ToolRunOptions): Promise { + return this.runTool(this.resolveKbtTool('kbtools'), args, options); + } + + public runSiemj(args: string[], options?: ToolRunOptions): Promise { + return this.runTool(this.resolveKbtTool(path.join('extra-tools', 'siemj', this.exe('siemj'))), args, options); + } + + public runNormalizer(args: string[], options?: ToolRunOptions): Promise { + return this.runTool(this.resolveKbtTool(path.join('build-tools', this.exe('normalize'))), args, options); + } + + private resolveKbtTool(relativePath: string): string { + if (!this.kbtBaseDirectory) { + throw new XpException('KBT base directory is not configured.'); + } + + return path.join(this.kbtBaseDirectory, relativePath); + } + + private exe(command: string): string { + return process.platform === 'win32' ? `${command}.exe` : command; + } +} + +export class DockerToolRunner implements ToolRunner { + private readonly pathMapper: PathMapper; + + constructor(private readonly options: DockerToolRunnerOptions) { + this.pathMapper = new PathMapper({ + workspaceHostPath: options.workspaceHostPath, + workspaceContainerPath: options.workspaceContainerPath, + extraMappings: options.extraMappings + }); + } + + public async runTool( + command: string, + args: string[], + options: ToolRunOptions = {} + ): Promise { + await this.ensureDockerAvailable(); + const containerName = await this.getContainerName(); + await this.ensureContainerRunning(containerName); + + const mappingValidation = this.pathMapper.validateMapping(); + if (!mappingValidation.isValid) { + throw new XpException(`Path mapping failed. ${mappingValidation.message}`); + } + + const mappedCommand = this.mapCommand(command); + const mappedArgs = await this.mapArgs(args); + const dockerArgs = this.buildDockerExecArgs(containerName, mappedCommand, mappedArgs, options); + + const result = await ProcessHelper.execute('docker', dockerArgs, { + ...options, + encoding: options.encoding ?? 'utf-8' + }); + + if (result.exitCode === 127 || result.output.includes('executable file not found')) { + throw new XpException( + `Tool not found in container '${containerName}'. Check xpConfig.docker.kbtBaseDirectory and make sure the container has XP tools installed. Missing command: '${mappedCommand}'.` + ); + } + + if (result.exitCode !== 0 && !result.isInterrupted && !options.allowNonZeroExitCode) { + if ( + process.platform === 'darwin' && + (result.exitCode === 132 || result.exitCode === 133) + ) { + throw new XpException( + `Command exited with code ${result.exitCode}. The XP tool binary is likely incompatible with the container CPU architecture. Re-run the macOS setup wizard and create a new XP tools container; it will use linux/amd64 for xp-kbt compatibility.` + ); + } + + throw new XpException( + `Command exited with non-zero code ${result.exitCode}. Check XP output for details.` + ); + } + + return result; + } + + public runKbt(args: string[], options?: ToolRunOptions): Promise { + return this.runTool(this.getContainerKbtTool('kbtools'), args, options); + } + + public runSiemj(args: string[], options?: ToolRunOptions): Promise { + return this.runTool(this.getContainerKbtTool('extra-tools/siemj/siemj'), args, options); + } + + public runNormalizer(args: string[], options?: ToolRunOptions): Promise { + return this.runTool(this.getContainerKbtTool('build-tools/normalize'), args, options); + } + + public getPathMapper(): PathMapper { + return this.pathMapper; + } + + public buildDockerExecArgs( + containerName: string, + command: string, + args: string[], + options: ToolRunOptions = {} + ): string[] { + const dockerArgs = ['exec', '-i']; + + if (options.cwd) { + dockerArgs.push('-w', this.pathMapper.hostToContainer(options.cwd)); + } + + dockerArgs.push(containerName, command, ...args); + return dockerArgs; + } + + private async ensureDockerAvailable(): Promise { + const result = await ProcessHelper.execute('docker', ['version'], { + encoding: 'utf-8', + checkCommandBeforeExecution: true + }); + + if (result.exitCode !== 0) { + throw new XpException( + 'Docker is not installed or is not available in PATH. Install Docker Desktop and start it, then retry the XP command.' + ); + } + } + + private async ensureContainerRunning(containerName: string): Promise { + const result = await ProcessHelper.execute( + 'docker', + ['inspect', '-f', '{{.State.Running}}', containerName], + { encoding: 'utf-8' } + ); + + if (result.exitCode !== 0 || !result.output.trim().includes('true')) { + throw new XpException( + `Container '${containerName}' is not running. Start a container with XP tools, then retry.` + ); + } + } + + private async getContainerName(): Promise { + if (this.options.containerName) { + return this.options.containerName; + } + + const discovered = await this.discoverContainer(); + if (discovered) { + return discovered; + } + + throw new XpException( + 'Container not running. Set xpConfig.docker.containerName or start/select a container with XP tools.' + ); + } + + private async discoverContainer(): Promise { + const containers = await ProcessHelper.execute( + 'docker', + ['ps', '--format', '{{.Names}}'], + { encoding: 'utf-8' } + ); + + if (containers.exitCode !== 0) { + return undefined; + } + + const names = containers.output + .split(/\r?\n/) + .map((name) => name.trim()) + .filter((name) => name); + + const xpNamed = names.find((name) => /vscode.*xp|xp.*workspace|knowledgebase|kbt/i.test(name)); + if (xpNamed) { + return xpNamed; + } + + const mounted = await this.discoverContainerByInspect(names); + if (mounted) { + return mounted; + } + + return undefined; + } + + private async discoverContainerByInspect(names: string[]): Promise { + for (const name of names) { + const result = await ProcessHelper.execute( + 'docker', + [ + 'inspect', + '--format', + '{{json .Config.Labels}} {{json .Mounts}}', + name + ], + { encoding: 'utf-8' } + ); + + if (result.exitCode !== 0) { + continue; + } + + if (this.containerMatchesConfiguration(result.output)) { + return name; + } + } + + return undefined; + } + + private containerMatchesConfiguration(inspectOutput: string): boolean { + const workspaceHostPath = this.options.workspaceHostPath; + const workspaceContainerPath = this.options.workspaceContainerPath; + + if (!workspaceHostPath && !workspaceContainerPath) { + return false; + } + + return [workspaceHostPath, workspaceContainerPath] + .filter((value) => value) + .some((value) => inspectOutput.includes(value)); + } + + private mapCommand(command: string): string { + return this.pathMapper.hostToContainer(command); + } + + private async mapArgs(args: string[]): Promise { + const mappedArgs: string[] = []; + + for (const arg of args) { + if (arg.endsWith('.conf') && fs.existsSync(arg)) { + mappedArgs.push(await this.createMappedConfig(arg)); + } else { + mappedArgs.push(this.pathMapper.mapCommandArgument(arg)); + } + } + + return mappedArgs; + } + + private async createMappedConfig(configPath: string): Promise { + const content = await fs.promises.readFile(configPath, 'utf-8'); + const mappedContent = this.pathMapper.mapText(content); + const mappedConfigPath = `${configPath}.container`; + await fs.promises.writeFile(mappedConfigPath, mappedContent, 'utf-8'); + return this.pathMapper.hostToContainer(mappedConfigPath); + } + + private getContainerKbtTool(relativePath: string): string { + const baseDirectory = this.options.kbtBaseDirectory || '/home/coder/xp-kbt'; + return path.posix.join(baseDirectory, relativePath); + } +} + +export class ToolRunnerFactory { + public static create(options: ToolRunnerFactoryOptions): ToolRunner { + const mode = this.resolveMode(options.mode); + + if (mode === 'docker') { + const extraMappings: PathMapping[] = [...(options.docker.extraMappings ?? [])]; + if (options.outputDirectoryPath && options.docker.outputDirectoryPath) { + extraMappings.push({ + hostPath: options.outputDirectoryPath, + containerPath: options.docker.outputDirectoryPath + }); + } + + return new DockerToolRunner({ + ...options.docker, + extraMappings + }); + } + + return new LocalToolRunner(options.kbtBaseDirectory); + } + + public static resolveMode(mode: ToolExecutionMode): 'local' | 'docker' { + if (mode === 'docker') { + return 'docker'; + } + + if (mode === 'local') { + return 'local'; + } + + return process.platform === 'darwin' && !vscode.env.remoteName ? 'docker' : 'local'; + } +} diff --git a/client/src/views/contentTree/actions/packSIEMAllPackagesAction.ts b/client/src/views/contentTree/actions/packSIEMAllPackagesAction.ts index 96f6d1c0..401e8801 100644 --- a/client/src/views/contentTree/actions/packSIEMAllPackagesAction.ts +++ b/client/src/views/contentTree/actions/packSIEMAllPackagesAction.ts @@ -7,7 +7,6 @@ import * as vscode from 'vscode'; import { DialogHelper } from '../../../helpers/dialogHelper'; import { FileSystemHelper } from '../../../helpers/fileSystemHelper'; import { KbHelper } from '../../../helpers/kbHelper'; -import { ProcessHelper } from '../../../helpers/processHelper'; import { Configuration } from '../../../models/configuration'; import { ExceptionHelper } from '../../../helpers/exceptionHelper'; import { ContentTreeBaseItem } from '../../../models/content/contentTreeBaseItem'; @@ -52,7 +51,7 @@ export class PackSIEMAllPackagesAction { // Проверка наличия утилиты сборки kb-файлов. const knowledgeBasePackagerCli = this.config.getKbPackFullPath(); - if (!fs.existsSync(knowledgeBasePackagerCli)) { + if (!this.config.shouldUseDockerToolRunner() && !fs.existsSync(knowledgeBasePackagerCli)) { DialogHelper.showError( 'Путь к утилите сборки пакетов экспертизы задан не верно. Измените его [в настройках расширения](command:workbench.action.openSettings?["xpConfig.kbtBaseDirectory"]) и повторите попытку' ); @@ -104,11 +103,25 @@ export class PackSIEMAllPackagesAction { emitter.fire( `\n\nXP:: Промежуточный статус: Запущена команда архивации файлов, это может занимать длительное время!\n\n` ); - const output = await ProcessHelper.executeWithArgsWithRealtimeEmmiterOutput( - 'dotnet', - [knowledgeBasePackagerCli, 'pack', '-s', tmpSubDirectoryPath, '-o', unpackKbFilePath], - emitter + emitter.fire( + `\n\nXP :: Run command: dotnet ${[ + knowledgeBasePackagerCli, + 'pack', + '-s', + tmpSubDirectoryPath, + '-o', + unpackKbFilePath + ].join(' ')}\n` ); + const result = await this.config + .getToolRunner() + .runTool( + 'dotnet', + [knowledgeBasePackagerCli, 'pack', '-s', tmpSubDirectoryPath, '-o', unpackKbFilePath], + { encoding: 'utf-8' } + ); + const output = result.output; + emitter.fire(output); emitter.fire(`\n\nXP:: Промежуточный статус: Команда выполнена!\n\n`); if (output.includes('Knowledge base package creation completed successfully')) { DialogHelper.showInfo(`Пакет '${packageName}' успешно собран.`); diff --git a/client/src/views/contentTree/commands/buildLocalizationsCommand.ts b/client/src/views/contentTree/commands/buildLocalizationsCommand.ts index 4a3e3e71..a607e3e6 100644 --- a/client/src/views/contentTree/commands/buildLocalizationsCommand.ts +++ b/client/src/views/contentTree/commands/buildLocalizationsCommand.ts @@ -2,7 +2,6 @@ import * as fs from 'fs'; import * as path from 'path'; import * as vscode from 'vscode'; -import { ProcessHelper } from '../../../helpers/processHelper'; import { SiemjConfigHelper } from '../../../models/siemj/siemjConfigHelper'; import { SiemJOutputParser } from '../../../models/siemj/siemJOutputParser'; import { Configuration } from '../../../models/configuration'; @@ -68,9 +67,7 @@ export class BuildLocalizationsCommand extends ViewCommand { // Типовая команда выглядит так: // "C:\\PTSIEMSDK_GUI.4.0.0.738\\tools\\siemj.exe" -c C:\\PTSIEMSDK_GUI.4.0.0.738\\temp\\siemj.conf main - const siemjExePath = this.config.getSiemjPath(); - const siemJOutput = await ProcessHelper.execute( - siemjExePath, + const siemJOutput = await this.config.getToolRunner().runSiemj( ['-c', siemjConfigPath, 'main'], { encoding: this.config.getSiemjOutputEncoding(), diff --git a/client/src/views/contentTree/commands/buildWldCommand.ts b/client/src/views/contentTree/commands/buildWldCommand.ts index 41e64d35..6306602d 100644 --- a/client/src/views/contentTree/commands/buildWldCommand.ts +++ b/client/src/views/contentTree/commands/buildWldCommand.ts @@ -2,7 +2,6 @@ import * as fs from 'fs'; import * as path from 'path'; import * as vscode from 'vscode'; -import { ProcessHelper } from '../../../helpers/processHelper'; import { SiemjConfigHelper } from '../../../models/siemj/siemjConfigHelper'; import { SiemJOutputParser } from '../../../models/siemj/siemJOutputParser'; import { Configuration } from '../../../models/configuration'; @@ -66,13 +65,12 @@ export class BuildWldCommand extends ViewCommand { // Типовая команда выглядит так: // .\ptsiem-sdk\release-26.0\26.0.11839\vc150\x86_64\win\cli/rcc.exe --lang w --taxonomy=.\taxonomy\release-26.0\26.0.215\any\any\any/taxonomy.json --schema=.\gui_output/schema.json -o c:\tmp\whitelisting_graph.json .\knowledgebase\packages - const rccCli = this.config.getRccCli(); const taxonomyPath = this.config.getTaxonomyFullPath(); const schemaPath = this.config.getSchemaFullPath(rootFolder); const whitelistingPath = this.config.getWhitelistingPath(rootFolder); const rootPath = siemjConfContentEntity['rootPath']; - const executionResult = await ProcessHelper.execute( - rccCli, + const executionResult = await this.config.getToolRunner().runTool( + this.config.getRccCli(), [ // --lang w '--lang', diff --git a/client/src/views/contentTree/commands/packKbCommand.ts b/client/src/views/contentTree/commands/packKbCommand.ts index f77d4f9f..c9da0f6a 100644 --- a/client/src/views/contentTree/commands/packKbCommand.ts +++ b/client/src/views/contentTree/commands/packKbCommand.ts @@ -7,7 +7,6 @@ import * as vscode from 'vscode'; import { DialogHelper } from '../../../helpers/dialogHelper'; import { FileSystemHelper } from '../../../helpers/fileSystemHelper'; import { KbHelper } from '../../../helpers/kbHelper'; -import { ProcessHelper } from '../../../helpers/processHelper'; import { Configuration } from '../../../models/configuration'; import { ExceptionHelper } from '../../../helpers/exceptionHelper'; import { ContentTreeBaseItem } from '../../../models/content/contentTreeBaseItem'; @@ -40,7 +39,7 @@ export class PackKbCommand extends ViewCommand { // Проверка наличия утилиты сборки kb-файлов. const knowledgeBasePackagerCli = this.config.getKbPackFullPath(); - if (!fs.existsSync(knowledgeBasePackagerCli)) { + if (!this.config.shouldUseDockerToolRunner() && !fs.existsSync(knowledgeBasePackagerCli)) { DialogHelper.showError( `Путь к утилите сборки kb-файла задан не верно. Проверьте корректность [пути к KBT](command:workbench.action.openSettings?["xpConfig.kbtBaseDirectory"]) или загрузите актуальную версию [отсюда](https://github.com/vxcontrol/xp-kbt/releases), распакуйте и задайте путь к директории [в настройках](command:workbench.action.openSettings?["xpConfig.kbtBaseDirectory"])` ); @@ -130,7 +129,7 @@ export class PackKbCommand extends ViewCommand { // Типовая команда выглядит так: // dotnet kbpack.dll pack -s "c:\tmp\pack" -o "c:\tmp\pack\Esc.kb" Log.info('TmpPackageDirectoryPath: ', tmpPackageDirectoryPath); - const output = await ProcessHelper.execute( + const output = await this.config.getToolRunner().runTool( 'dotnet', [ knowledgeBasePackagerCli, diff --git a/client/src/views/contentTree/commands/unpackKbCommand.ts b/client/src/views/contentTree/commands/unpackKbCommand.ts index 244a85da..c131acd2 100644 --- a/client/src/views/contentTree/commands/unpackKbCommand.ts +++ b/client/src/views/contentTree/commands/unpackKbCommand.ts @@ -5,7 +5,7 @@ import * as os from 'os'; import * as vscode from 'vscode'; import { DialogHelper } from '../../../helpers/dialogHelper'; -import { ExecutionResult, ProcessHelper } from '../../../helpers/processHelper'; +import { ExecutionResult } from '../../../helpers/processHelper'; import { VsCodeApiHelper } from '../../../helpers/vsCodeApiHelper'; import { Configuration } from '../../../models/configuration'; import { ContentTreeProvider } from '../contentTreeProvider'; @@ -31,7 +31,7 @@ export class UnpackKbCommand extends ViewCommand { public async execute(): Promise { // Проверка наличия утилиты сборки kb-файлов. const knowledgeBasePackagerCli = this.config.getKbPackFullPath(); - if (!fs.existsSync(knowledgeBasePackagerCli)) { + if (!this.config.shouldUseDockerToolRunner() && !fs.existsSync(knowledgeBasePackagerCli)) { DialogHelper.showError( 'Путь к утилите сборке kb-файла задан не верно. Измените его в настройках и повторите попытку' ); @@ -95,7 +95,7 @@ export class UnpackKbCommand extends ViewCommand { const cmd = 'dotnet'; let executeResult: ExecutionResult; try { - executeResult = await ProcessHelper.execute(cmd, params, { + executeResult = await this.config.getToolRunner().runTool(cmd, params, { encoding: 'utf-8', outputChannel: this.config.getOutputChannel(), checkCommandBeforeExecution: true, diff --git a/client/src/views/corrGraphRunner.ts b/client/src/views/corrGraphRunner.ts index 21e61ec4..d999f97b 100644 --- a/client/src/views/corrGraphRunner.ts +++ b/client/src/views/corrGraphRunner.ts @@ -3,7 +3,6 @@ import * as fs from 'fs'; import * as path from 'path'; import { FileSystemHelper } from '../helpers/fileSystemHelper'; -import { ProcessHelper } from '../helpers/processHelper'; import { Configuration } from '../models/configuration'; import { XpException } from '../models/xpException'; import { Log } from '../extension'; @@ -76,7 +75,6 @@ export class CorrGraphRunner { // Сохраняем конфигурационный файл для siemj. const siemjConfigPath = path.join(randTmpDir, Configuration.SIEMJ_CONFIG_FILENAME); - const siemjExePath = this.options.config.getSiemjPath(); await FileSystemHelper.writeContentFile(siemjConfigPath, siemjConfContent); // Без удаления базы возникали странные ошибки filler-а, но это не точно. @@ -93,7 +91,7 @@ export class CorrGraphRunner { // Типовая команда выглядит так: // "C:\\PTSIEMSDK_GUI.4.0.0.738\\tools\\siemj.exe" -c C:\\PTSIEMSDK_GUI.4.0.0.738\\temp\\siemj.conf main"); - await ProcessHelper.execute(siemjExePath, ['-c', siemjConfigPath, 'main'], { + await this.options.config.getToolRunner().runSiemj(['-c', siemjConfigPath, 'main'], { encoding: this.options.config.getSiemjOutputEncoding(), outputChannel: this.options.config.getOutputChannel(), cancellationToken: this.options.cancellationToken diff --git a/client/src/views/integrationTests/command/getExpectEventCommand.ts b/client/src/views/integrationTests/command/getExpectEventCommand.ts index cff31168..467755f6 100644 --- a/client/src/views/integrationTests/command/getExpectEventCommand.ts +++ b/client/src/views/integrationTests/command/getExpectEventCommand.ts @@ -112,7 +112,18 @@ export class GetExpectedEventCommand { title: `Получение ожидаемого события для теста №${this.params.test.getNumber()}` }, async (progress) => { - const modularTestContent = `${integrationTestSimplifiedContent}\n\n${normalizedEvents}`; + const expectSectionMatch = + RegExpHelper.getExpectSectionRegExp().exec(integrationTestSimplifiedContent); + if (!expectSectionMatch) { + throw new XpException('Не удалось выделить секцию expect из интеграционного теста'); + } + + const expectSection = expectSectionMatch[0]; + const inputSection = integrationTestSimplifiedContent + .slice(0, expectSectionMatch.index) + .trimEnd(); + const modularTestContent = `${inputSection}\n${normalizedEvents}\n\n${expectSection}`; + const modularTestContentForRunner = TestHelper.compressTestCode(modularTestContent); // Сохраняем модульный тест во временный файл. const rootPath = this.params.config.getRootByPath(this.params.test.getRuleDirectoryPath()); @@ -124,7 +135,7 @@ export class GetExpectedEventCommand { randTmpPath, GetExpectedEventCommand.EXPECT_EVENT_FILENAME ); - await FileSystemHelper.writeContentFile(fastTestFilePath, modularTestContent); + await FileSystemHelper.writeContentFile(fastTestFilePath, modularTestContentForRunner); // Создаем временный модульный тест для быстрого тестирования. const fastTest = new FastTest(this.params.test.getNumber()); diff --git a/client/src/views/integrationTests/command/normalizeRawEventsCommand.ts b/client/src/views/integrationTests/command/normalizeRawEventsCommand.ts index e58ba2e0..3604b370 100644 --- a/client/src/views/integrationTests/command/normalizeRawEventsCommand.ts +++ b/client/src/views/integrationTests/command/normalizeRawEventsCommand.ts @@ -48,12 +48,18 @@ export class NormalizeRawEventsCommand extends Command { this.params.test.setNormalizedEvents(normEvents); } catch (error) { - ExceptionHelper.show( + const handled = await ExceptionHelper.showToolBackendUnavailableError( error, - this.params.config.getMessage( - 'View.IntegrationTests.Message.DefaultErrorEventsNormalization' - ) + this.params.config ); + if (!handled) { + ExceptionHelper.show( + error, + this.params.config.getMessage( + 'View.IntegrationTests.Message.DefaultErrorEventsNormalization' + ) + ); + } return; } diff --git a/client/src/views/integrationTests/command/saveAllCommand.ts b/client/src/views/integrationTests/command/saveAllCommand.ts index e6513185..b6408f77 100644 --- a/client/src/views/integrationTests/command/saveAllCommand.ts +++ b/client/src/views/integrationTests/command/saveAllCommand.ts @@ -76,12 +76,16 @@ export class SaveAllCommand extends Command { const newTests = plainTests.map((plainTest, index) => { const number = index + 1; const newTest = IntegrationTest.create(number); + const oldTest = oldTests?.[index]; // Сырые события. let rawEvents = plainTest?.rawEvents; // Из textarea новые строки только \n, поэтому надо их поправить под систему. rawEvents = rawEvents.replace(/(? ".*?"\s|\s+[\w.]+: \d+ => \d+\s)+)/s; - const diffMatch = correlateEventsFileContent.match(diffRegex); - if (diffMatch && diffMatch.length === 3) { - diff = diffMatch[1]; - } - } + const missingSection = this.extractDetailedReportSection( + correlateEventsFileContent, + 'Missing:' + ); + const differentSection = this.extractDetailedReportSection( + correlateEventsFileContent, + 'Different:' + ); + diff = differentSection || missingSection; } } catch (e) {} // TODO: extend logic for Enrichment rules tests const actualEventFound = await this.searchForActualEvent(it); + const hasFailureDetails = Boolean(diff || events || tlState); plain['IntegrationTests'].push({ TestNumber: it.getNumber(), RawEvents: rawEvents, @@ -328,7 +323,9 @@ export class IntegrationTestEditorViewProvider { NormState: events, TLState: tlState, IsFailed: it.getStatus() === TestStatus.Failed, - CanGetExpectedEvent: this.canGetExpectedEvent(it, actualEventFound) + CanGetExpectedEvent: this.canGetExpectedEvent(it, actualEventFound || hasFailureDetails), + CanShowActualEvent: actualEventFound, + CanCompareResults: it.getStatus() === TestStatus.Failed && (actualEventFound || hasFailureDetails) }); } } @@ -347,38 +344,51 @@ export class IntegrationTestEditorViewProvider { private async searchForActualEvent(it: IntegrationTest): Promise { const ruleName = this.rule.getName(); - if (!fs.existsSync(this.testsTmpFilesPath)) { - return false; + + const resultFiles = it.getResultFiles(); + let actualEventsFilePath = resultFiles?.actualEventsFilePath; + if (!actualEventsFilePath) { + actualEventsFilePath = resultFiles?.detailedReportFilePath; } + try { - // Получаем фактическое событие. - var actualEventsFilePath = TestHelper.getEnrichedCorrEventFilePath( - this.config, - this.testsTmpFilesPath, - ruleName, - it.getNumber() - ); - } catch (e) { - return false; - } + if (!actualEventsFilePath) { + if (!fs.existsSync(this.testsTmpFilesPath)) { + return false; + } - if (!actualEventsFilePath || !fs.existsSync(actualEventsFilePath)) { + actualEventsFilePath = TestHelper.getEnrichedCorrEventFilePath( + this.config, + this.testsTmpFilesPath, + ruleName, + it.getNumber() + ); + } + } catch (e) { return false; } - const actualEventsString = await FileSystemHelper.readContentFile(actualEventsFilePath); - if (!actualEventsString) { + if (!actualEventsFilePath) { return false; } - const siemjVersion = GetSIEMJVersion(this.config); - if (siemjVersion == SIEMJVersion.Second) { - if (!actualEventsString.match(/\[FromCorrelator\]/)) { + try { + const actualEventsString = await this.config.readTextFile(actualEventsFilePath); + if (!actualEventsString) { return false; } - return true; + + const actualEvents = TestHelper.extractEventsFromResultString( + this.config, + actualEventsString, + ruleName, + it.getNumber() + ); + + return actualEvents.length > 0; + } catch (e) { + return false; } - return true; } /** @@ -403,6 +413,20 @@ export class IntegrationTestEditorViewProvider { } } + private extractDetailedReportSection(reportContent: string, sectionHeader: string): string { + const escapedHeader = sectionHeader.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const sectionRegex = new RegExp( + `${escapedHeader}\\s*([\\s\\S]*?)(?:Event conditions:|Contents of the table lists \\(|\\[FromCorrelator\\]|$)`, + 's' + ); + const match = reportContent.match(sectionRegex); + if (!match || match.length < 2) { + return ''; + } + + return match[1].trim(); + } + private testStatusToUiStyle(it: IntegrationTest): string { const testStatus = it.getStatus(); switch (testStatus) { @@ -652,10 +676,16 @@ export class IntegrationTestEditorViewProvider { await this.updateView(this.getSelectedTestNumber(message)); } } catch (error) { - ExceptionHelper.show( + const handled = await ExceptionHelper.showToolBackendUnavailableError( error, - this.config.getMessage('View.IntegrationTests.Message.FailedToExecutionTests') + this.config ); + if (!handled) { + ExceptionHelper.show( + error, + this.config.getMessage('View.IntegrationTests.Message.FailedToExecutionTests') + ); + } } finally { IntegrationTestEditorViewProvider.SAVING_IN_PROGRESS = false; } diff --git a/client/src/views/localization/checkLocalizationsCommand.ts b/client/src/views/localization/checkLocalizationsCommand.ts index 13b8b824..c47bf085 100644 --- a/client/src/views/localization/checkLocalizationsCommand.ts +++ b/client/src/views/localization/checkLocalizationsCommand.ts @@ -144,6 +144,7 @@ export class CheckLocalizationCommand extends ViewCommand { }, async (progress, token) => { let userResponse: string; + let testRuleFiles: Map = new Map(); if (fs.existsSync(this.params.tmpDirPath)) { const subDirItems = await fs.promises.readdir(this.params.tmpDirPath, { @@ -193,7 +194,7 @@ export class CheckLocalizationCommand extends ViewCommand { const testRunner = new IntegrationTestRunner(this.params.config, configBuilder); const siemjResult = await testRunner.runOnce(this.params.rule, options); - var testRuleFiles = RegExpHelper.getEnrichedCorrTestEventsFileNameNew( + testRuleFiles = RegExpHelper.getEnrichedCorrTestEventsFileNameNew( siemjResult.rawOutput ); @@ -202,6 +203,16 @@ export class CheckLocalizationCommand extends ViewCommand { 'Не все интеграционные тесты прошли. Для получения тестовых локализации необходимо чтобы успешно проходили все интеграционные тесты' ); } + } else { + testRuleFiles = RegExpHelper.getEnrichedCorrTestEventsFileNameFromDirectory( + this.params.tmpDirPath + ); + + if (testRuleFiles.size === 0) { + throw new XpException( + 'Не удалось найти результаты предыдущего запуска интеграционных тестов. Повторите запуск интеграционных тестов и после этого снова проверьте локализации.' + ); + } } const locExamples = await this.getLocalization(progress, testRuleFiles); diff --git a/client/src/views/unitTestEditor/unitTestEditorViewProvider.ts b/client/src/views/unitTestEditor/unitTestEditorViewProvider.ts index 2f1ed76b..3f881e08 100644 --- a/client/src/views/unitTestEditor/unitTestEditorViewProvider.ts +++ b/client/src/views/unitTestEditor/unitTestEditorViewProvider.ts @@ -27,6 +27,7 @@ export class UnitTestContentEditorViewProvider extends WebViewProviderBase { 'ModularTestEditorView.onTestSelectionChange'; private rule: RuleBaseItem; + private toolBackendErrorShown = false; public constructor(private readonly config: Configuration) { super(); @@ -317,13 +318,15 @@ export class UnitTestContentEditorViewProvider extends WebViewProviderBase { private async runTest(testOrTestNumber: number | BaseUnitTest) { let test; let testNumber; + const isDirectInvocation = !(testOrTestNumber instanceof BaseUnitTest); - if (testOrTestNumber instanceof BaseUnitTest) { + if (!isDirectInvocation) { test = testOrTestNumber; testNumber = test.getNumber(); } else { test = this.rule.getUnitTestByNumber(testOrTestNumber); testNumber = testOrTestNumber; + this.toolBackendErrorShown = false; } if (!test) { @@ -368,7 +371,15 @@ export class UnitTestContentEditorViewProvider extends WebViewProviderBase { actualData: outputData }); - ExceptionHelper.show(error, 'Unexpected error while executing the modular test'); + const handled = + !this.toolBackendErrorShown && + (await ExceptionHelper.showToolBackendUnavailableError(error, this.config)); + if (handled) { + this.toolBackendErrorShown = true; + } + if (!handled) { + ExceptionHelper.show(error, 'Unexpected error while executing the modular test'); + } } finally { this._updateTestInWebview({ testNumber, @@ -381,6 +392,7 @@ export class UnitTestContentEditorViewProvider extends WebViewProviderBase { private async runAllTests() { const tests = this.rule.getUnitTests(); + this.toolBackendErrorShown = false; return vscode.window.withProgress( { diff --git a/client/templates/IntegrationTestEditor.html b/client/templates/IntegrationTestEditor.html index 16dfb8c6..b1bd3ff6 100644 --- a/client/templates/IntegrationTestEditor.html +++ b/client/templates/IntegrationTestEditor.html @@ -16,7 +16,7 @@
- {{#CanGetExpectedEvent}} + {{/CanGetExpectedEvent}} + {{#CanShowActualEvent}} - - {{#IsFailed}} + {{/CanShowActualEvent}} + {{#CanCompareResults}} - {{/IsFailed}} - {{/CanGetExpectedEvent}} + {{/CanCompareResults}}
@@ -221,6 +221,27 @@ // Глобальное определение переменных. const redos = []; const undos = []; + const isMacOS = navigator.platform.toUpperCase().indexOf('MAC') >= 0; + + function hasPrimaryModifier(event) { + return isMacOS ? event.metaKey : event.ctrlKey; + } + + function isUndoShortcut(event) { + return hasPrimaryModifier(event) && !event.shiftKey && event.code == 'KeyZ'; + } + + function isRedoShortcut(event) { + if (isMacOS) { + return hasPrimaryModifier(event) && event.shiftKey && event.code == 'KeyZ'; + } + + return hasPrimaryModifier(event) && event.code == 'KeyY'; + } + + function isSelectAllShortcut(event) { + return hasPrimaryModifier(event) && event.code == 'KeyA'; + } function normalizeTestCode (testCode) { if (!testCode || typeof testCode !== 'string') return testCode; @@ -342,7 +363,7 @@ setCaret(pos); e.preventDefault(); return; - } else if (e.ctrlKey && e.code == 'KeyZ') { + } else if (isUndoShortcut(e)) { const prevHTML = undo(redos, undos, el.innerHTML) if (prevHTML) { const difference = (el.innerHTML.length - prevHTML.length) || 0; @@ -350,7 +371,7 @@ el.innerHTML = prevHTML; setCaret(pos - difference < 0 ? pos : pos - difference); } - } else if (e.ctrlKey && e.code == 'KeyY') { + } else if (isRedoShortcut(e)) { const prevHTML = redo(redos, undos, el.innerHTML) if (prevHTML) { const difference = (prevHTML.length - el.innerHTML.length) || 0; @@ -373,7 +394,7 @@ // Обработка вставки символа с поддержкой выделения на Ctrl+A. el.addEventListener('keyup', e => { - if ((e.keyCode >= 0x30 || e.keyCode == 0x20 || e.code === 'Delete' || e.code === 'Backspace') && !(e.ctrlKey && e.code == 'KeyA') && !(e.ctrlKey && e.code == 'KeyZ') && !(e.ctrlKey && e.code == 'KeyY')) { + if ((e.keyCode >= 0x30 || e.keyCode == 0x20 || e.code === 'Delete' || e.code === 'Backspace') && !isSelectAllShortcut(e) && !isUndoShortcut(e) && !isRedoShortcut(e)) { const pos = caret(); el.innerHTML = hljs.highlight(el.innerText ? el.innerText : '', {language: language}).value setCaret(pos); @@ -753,9 +774,9 @@ saveAllTests(); }); - // Сохраняем все тесты по хот кею Ctrl+S + // Сохраняем все тесты по хот кею Cmd+S на macOS и Ctrl+S на остальных платформах $(document).on("keydown", e => { - if (e.ctrlKey && e.code == 'KeyS') { + if (hasPrimaryModifier(e) && e.code == 'KeyS') { e.preventDefault(); saveAllTests(); } @@ -842,4 +863,4 @@ } - \ No newline at end of file + diff --git a/client/templates/LocalizationEditor.html b/client/templates/LocalizationEditor.html index 08bd7452..82e520aa 100644 --- a/client/templates/LocalizationEditor.html +++ b/client/templates/LocalizationEditor.html @@ -179,10 +179,16 @@

{{Locale.LocalizationCriteria}}:

}); } + const isMacOS = navigator.platform.toUpperCase().indexOf('MAC') >= 0; - // Сохраняем все тесты по хот кею Ctrl+S + function hasPrimaryModifier(event) { + return isMacOS ? event.metaKey : event.ctrlKey; + } + + + // Сохраняем данные по хот кею Cmd+S на macOS и Ctrl+S на остальных платформах $(document).on("keydown", e => { - if (e.ctrlKey && e.code == 'KeyS') { + if (hasPrimaryModifier(e) && e.code == 'KeyS') { console.log(e.keyCode); e.preventDefault(); saveLocalizations(); @@ -221,4 +227,4 @@

{{Locale.LocalizationCriteria}}:

} - \ No newline at end of file + diff --git a/client/templates/js/meta.js b/client/templates/js/meta.js index 6e4f47e7..3c7c6330 100644 --- a/client/templates/js/meta.js +++ b/client/templates/js/meta.js @@ -129,9 +129,15 @@ function saveMetaInfo() { }); } +const isMacOS = navigator.platform.toUpperCase().indexOf('MAC') >= 0; + +function hasPrimaryModifier(event) { + return isMacOS ? event.metaKey : event.ctrlKey; +} + // Сохраняем все тесты по хот кею Ctrl+S $(document).on("keydown", e => { - if (e.ctrlKey && e.code == 'KeyS') { + if (hasPrimaryModifier(e) && e.code == 'KeyS') { e.preventDefault(); saveMetaInfo(); } diff --git a/client/templates/js/unittest/code.js b/client/templates/js/unittest/code.js index fc8f0803..f97243c3 100644 --- a/client/templates/js/unittest/code.js +++ b/client/templates/js/unittest/code.js @@ -161,9 +161,15 @@ $(document).ready(function() { }); }); +const isMacOS = navigator.platform.toUpperCase().indexOf('MAC') >= 0; + +function hasPrimaryModifier(event) { + return isMacOS ? event.metaKey : event.ctrlKey; +} + // Сохраняем все тесты по хот кею Ctrl+S $(document).on("keydown", e => { - if (e.ctrlKey && e.code == 'KeyS') { + if (hasPrimaryModifier(e) && e.code == 'KeyS') { e.preventDefault(); saveTest(); } diff --git a/client/templates/styles/main.css b/client/templates/styles/main.css index 9a2938d1..1f348575 100644 --- a/client/templates/styles/main.css +++ b/client/templates/styles/main.css @@ -61,6 +61,18 @@ body { background-color: var(--vscode-button-secondaryHoverBackground); } +#run_tests { + background-color: var(--vscode-button-background); + color: var(--vscode-button-foreground); + font-weight: normal; + padding: 0; + text-align: center; +} + +#run_tests:hover { + background-color: var(--vscode-button-hoverBackground); +} + /* ------ COMMON ELEMENTS ------ */ @@ -71,4 +83,4 @@ body { p { margin: 0; -} \ No newline at end of file +} diff --git a/esbuild.js b/esbuild.js index 6ffb7fec..75ad34d4 100644 --- a/esbuild.js +++ b/esbuild.js @@ -15,7 +15,7 @@ const clientConfig = { format: 'cjs', entryPoints: ['./client/src/extension.ts'], outfile: `${clientOutDirectoryPath}/extension.js`, - external: ['vscode', 'prettier'], + external: ['vscode'], plugins: [ clean({ patterns: [clientOutDirectoryPath] diff --git a/package.json b/package.json index f5ab2052..9eeeacbb 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "author": "Dmitry Fedosov (@DmitryOffsec)", "icon": "resources/xp.png", "license": "MIT", - "version": "3.21.4", + "version": "3.21.5", "repository": { "type": "git", "url": "https://github.com/Security-Experts-Community/vscode-xp" @@ -39,7 +39,7 @@ "engines": { "vscode": "^1.75.0" }, - "main": "./client/out/extension", + "main": "./client/out/extension.js", "capabilities": { "untrustedWorkspaces": { "supported": true @@ -211,13 +211,6 @@ ] }, "menus": { - "editor/context": [ - { - "when": "resourceLangId == test", - "command": "NativeEditorContextMenu.compressTest", - "group": "test@1" - } - ], "view/title": [ { "command": "xp.contentTree.openKnowledgebaseCommand", @@ -443,6 +436,11 @@ "title": "%xp.commonCommands.showOutputChannel%", "category": "%xp.commonCommandCategory%" }, + { + "command": "xp.commonCommands.configureMacOSContainerBackend", + "title": "%xp.commonCommands.configureMacOSContainerBackend%", + "category": "%xp.commonCommandCategory%" + }, { "command": "xp.retroCorrelationShow", "title": "%xp.views.knowledgebaseTree.showRetroCorrelationCommand%", @@ -605,23 +603,12 @@ "title": "Show integration tests", "category": "%xp.commonCommandCategory%" }, - { - "command": "NativeEditorContextMenu.compressTest", - "title": "Сжать документ", - "category": "%xp.commonCommandCategory%" - }, { "command": "xp.contentTree.verifyFolder", "title": "%xp.views.knowledgebaseTree.verifyFolderCommand%", "category": "%xp.commonCommandCategory%" } ], - "keybindings": [ - { - "command": "NativeEditorContextMenu.compressTest", - "key": "shift+alt+c" - } - ], "configuration": { "type": "object", "title": "xp", @@ -655,12 +642,89 @@ "scope": "window", "type": "string", "markdownDescription": "%xp.xpConfig.outputDirectoryPathDescription%" + }, + "xpConfig.toolExecutionMode": { + "scope": "window", + "type": "string", + "enum": [ + "auto", + "local", + "docker" + ], + "default": "auto", + "markdownDescription": "%xp.xpConfig.toolExecutionModeDescription%" + }, + "xpConfig.lspMode": { + "scope": "window", + "type": "string", + "enum": [ + "auto", + "legacy", + "kbt" + ], + "default": "auto", + "markdownDescription": "%xp.xpConfig.lspModeDescription%" + }, + "xpConfig.docker.containerName": { + "scope": "window", + "type": "string", + "default": "", + "markdownDescription": "%xp.xpConfig.docker.containerNameDescription%" + }, + "xpConfig.docker.composeFile": { + "scope": "window", + "type": "string", + "default": "", + "markdownDescription": "%xp.xpConfig.docker.composeFileDescription%" + }, + "xpConfig.docker.serviceName": { + "scope": "window", + "type": "string", + "default": "", + "markdownDescription": "%xp.xpConfig.docker.serviceNameDescription%" + }, + "xpConfig.docker.workspaceHostPath": { + "scope": "window", + "type": "string", + "default": "", + "markdownDescription": "%xp.xpConfig.docker.workspaceHostPathDescription%" + }, + "xpConfig.docker.workspaceContainerPath": { + "scope": "window", + "type": "string", + "default": "/workspaces/knowledgebase", + "markdownDescription": "%xp.xpConfig.docker.workspaceContainerPathDescription%" + }, + "xpConfig.docker.kbtBaseDirectory": { + "scope": "window", + "type": "string", + "default": "/home/coder/xp-kbt", + "markdownDescription": "%xp.xpConfig.docker.kbtBaseDirectoryDescription%" + }, + "xpConfig.docker.outputDirectoryPath": { + "scope": "window", + "type": "string", + "default": "/workspaces/knowledgebase/tmp/xp-output", + "markdownDescription": "%xp.xpConfig.docker.outputDirectoryPathDescription%" + }, + "xpConfig.macos.showContainerSetupPrompt": { + "scope": "window", + "type": "boolean", + "default": true, + "markdownDescription": "%xp.xpConfig.macos.showContainerSetupPromptDescription%" }, "xpConfig.lspServerExecutablePath": { + "scope": "window", "type": "string", "default": "", "description": "%xp.xpConfig.lspServerExecutablePathDescription%" }, + "xpConfig.formatterExecutablePath": { + "scope": "window", + "type": "string", + "default": "", + "markdownDescription": "%xp.xpConfig.formatterExecutablePathDescription%" + }, "xpConfig.correlatorTimeout": { "scope": "window", "type": "integer", diff --git a/package.nls.json b/package.nls.json index ed059254..303d77ce 100644 --- a/package.nls.json +++ b/package.nls.json @@ -3,6 +3,8 @@ "xp.commonCommands.showOutputChannel": "Show Output Data", + "xp.commonCommands.configureMacOSContainerBackend": "Configure macOS Container Backend", + "xp.commonCommands.showExtensionSettings" : "Extension Settings", "xp.views.knowledgebaseTree.title": "Object Tree", @@ -44,8 +46,19 @@ "xp.xpConfig.kbtBaseDirectoryDescription": "Path of the folder with the [Knowledge Base Toolkit](https://github.com/vxcontrol/xp-kbt/releases)", "xp.xpConfig.outputDirectoryPathDescription": "Path of the folder where temporary objects are saved. The folder is emptied automatically every time you start the extension.", + "xp.xpConfig.toolExecutionModeDescription": "How XP external tools are executed. Auto keeps local execution on Linux/Windows and uses Docker for local macOS VS Code.", + "xp.xpConfig.lspModeDescription": "Which XP language server to use on Linux and Windows. On local macOS this setting is ignored; configure xpConfig.lspServerExecutablePath to use a native LSP binary, otherwise no LSP will be started.", + "xp.xpConfig.docker.containerNameDescription": "Name of the running Docker container with xp-kbt and related XP tools.", + "xp.xpConfig.docker.composeFileDescription": "Optional docker-compose.yml path for a container backend preset.", + "xp.xpConfig.docker.serviceNameDescription": "Optional Docker Compose service name for the XP backend.", + "xp.xpConfig.docker.workspaceHostPathDescription": "Host path to the mounted knowledgebase.", + "xp.xpConfig.docker.workspaceContainerPathDescription": "Container path where the knowledgebase is mounted.", + "xp.xpConfig.docker.kbtBaseDirectoryDescription": "Container path to the xp-kbt base directory.", + "xp.xpConfig.docker.outputDirectoryPathDescription": "Container path corresponding to xpConfig.outputDirectoryPath.", + "xp.xpConfig.macos.showContainerSetupPromptDescription": "Show the macOS container backend setup prompt on extension activation.", "xp.xpConfig.correlatorTimeoutDescription": "Max correlation time in seconds. If the time is exceeded, the correlation will be canceled.", - "xp.xpConfig.lspServerExecutablePathDescription": "Path of the LSP server executable", + "xp.xpConfig.lspServerExecutablePathDescription": "Path of the LSP server executable. On local macOS this must point to a native evt-xp-language-server binary if you want LSP features.", + "xp.xpConfig.formatterExecutablePathDescription": "Optional path of the formatter executable. On local macOS this can point to a native evt-xp-formatter binary; if empty, Docker formatting will be used in Docker tool mode.", "xp.xpConfig.originDescription": "Vendor information. Used to export objects to a KB file.", "xp.xpConfig.origin.contentPrefixDescription": "Object vendor prefix. Must match ObjectId. By default, custom rules have the LOC prefix, and system rules have the PT prefix. If no prefix is specified, the rules you create will not have ObjectId either.", @@ -56,4 +69,3 @@ "xp.xpConfig.logLevelDescription": "Log level", "xp.xpConfig.defaultAuthor": "Default author name" } - diff --git a/package.nls.ru.json b/package.nls.ru.json index 181f6ac9..0aadb476 100644 --- a/package.nls.ru.json +++ b/package.nls.ru.json @@ -3,6 +3,8 @@ "xp.commonCommands.showOutputChannel": "Показать выходные данные", + "xp.commonCommands.configureMacOSContainerBackend": "Настроить контейнерный backend для macOS", + "xp.commonCommands.showExtensionSettings" : "Параметры расширения", "xp.views.knowledgebaseTree.title": "Дерево объектов", @@ -44,8 +46,10 @@ "xp.xpConfig.kbtBaseDirectoryDescription": "Путь к папке c [Knowledge Base Toolkit](https://github.com/vxcontrol/xp-kbt/releases)", "xp.xpConfig.outputDirectoryPathDescription": "Путь к папке, в которой сохраняются временные объекты. Эта папка очищается автоматически при каждом запуске расширения", + "xp.xpConfig.lspModeDescription": "Какой XP language server использовать на Linux и Windows. На локальной macOS эта настройка игнорируется: укажите xpConfig.lspServerExecutablePath для нативного LSP-бинарника, а если путь не задан, LSP не будет запущен.", "xp.xpConfig.correlatorTimeoutDescription": "Макс. время корреляции в секундах. Если оно будет превышено, корреляция будет прервана", - "xp.xpConfig.lspServerExecutablePathDescription": "Путь до исполняемого файла LSP сервера", + "xp.xpConfig.lspServerExecutablePathDescription": "Путь до исполняемого файла LSP сервера. На локальной macOS здесь должен быть путь до нативного evt-xp-language-server, если нужны LSP-возможности.", + "xp.xpConfig.formatterExecutablePathDescription": "Необязательный путь до исполняемого файла форматтера. На локальной macOS здесь можно указать нативный evt-xp-formatter; если путь пустой, в Docker-режиме будет использоваться форматтер из контейнера.", "xp.xpConfig.originDescription": "Информация о поставщике. Используется при экспорте объектов в KB-файл", "xp.xpConfig.origin.contentPrefixDescription": "Префикс поставщика объектов. Должен совпадать с ObjectId. По умолчанию, пользовательским правилам соответствует префикс LOC, системным — PT. Если не указать префикс, у создаваемых правил не будет ObjectId", @@ -56,4 +60,3 @@ "xp.xpConfig.logLevelDescription": "Уровень журналирования", "xp.xpConfig.defaultAuthor": "Имя автора по умолчанию" } -