diff --git a/Resources/zh-Hans.lproj/Localizable.strings b/Resources/zh-Hans.lproj/Localizable.strings index 11986ab4e..c1fb47ecd 100644 --- a/Resources/zh-Hans.lproj/Localizable.strings +++ b/Resources/zh-Hans.lproj/Localizable.strings @@ -604,6 +604,8 @@ "Program arguments" = "程序参数"; "Active Maven Profiles" = "启用的 Maven Profiles"; "Reset" = "重置"; +"Project paths must stay inside the current project." = "项目路径必须位于当前项目内。"; +"Open a project before choosing project paths." = "请先打开项目,再选择项目路径。"; "Local History" = "本地历史"; "Project Local History" = "项目本地历史"; "Restore" = "恢复"; diff --git a/Sources/Lithe/Core/Rust/RustCoreBridge.swift b/Sources/Lithe/Core/Rust/RustCoreBridge.swift index 0eb627541..4b912198b 100644 --- a/Sources/Lithe/Core/Rust/RustCoreBridge.swift +++ b/Sources/Lithe/Core/Rust/RustCoreBridge.swift @@ -2232,9 +2232,9 @@ struct RustCoreBridge: Sendable { arguments: options.arguments, environment: options.environment, mavenProfiles: options.activeProfiles.sorted(), - javaHomePath: scope == .local ? options.javaHomePath : "", - mavenExecutablePath: scope == .local ? options.mavenExecutablePath : "", - mavenJavaHomePath: scope == .local ? options.mavenJavaHomePath : "" + javaHomePath: options.javaHomePath, + mavenExecutablePath: options.mavenExecutablePath, + mavenJavaHomePath: options.mavenJavaHomePath ) ) } diff --git a/Sources/Lithe/Services/Java/ProjectRuntimeService.swift b/Sources/Lithe/Services/Java/ProjectRuntimeService.swift index 81da7e31e..598cf31ac 100644 --- a/Sources/Lithe/Services/Java/ProjectRuntimeService.swift +++ b/Sources/Lithe/Services/Java/ProjectRuntimeService.swift @@ -114,9 +114,11 @@ final class ProjectRuntimeService: ObservableObject { } func javaHomeURL(overridePath: String? = nil) -> URL? { - if let overridePath, - !normalizedPath(overridePath).isEmpty { - return runtimeLocator.validJavaHome(path: normalizedPath(overridePath)) + if let overridePath { + let normalizedPath = normalizedOverridePath(overridePath) + if !normalizedPath.isEmpty { + return runtimeLocator.validJavaHome(path: normalizedPath) + } } let paths = [runtimeLocator.environment()["JAVA_HOME"]] for path in paths.compactMap({ $0 }).map(normalizedPath).filter({ !$0.isEmpty }) { @@ -137,8 +139,9 @@ final class ProjectRuntimeService: ObservableObject { /// probes `java -version`, so capability checks can remain inert. func configuredJavaExecutableURL(overridePath: String? = nil) -> URL? { let paths: [String?] - if let overridePath, !normalizedPath(overridePath).isEmpty { - paths = [overridePath] + if let overridePath { + let normalizedPath = normalizedOverridePath(overridePath) + paths = normalizedPath.isEmpty ? [runtimeLocator.environment()["JAVA_HOME"]] : [normalizedPath] } else { paths = [runtimeLocator.environment()["JAVA_HOME"]] } @@ -167,9 +170,11 @@ final class ProjectRuntimeService: ObservableObject { } func mavenJavaHomeURL(overridePath: String? = nil) -> URL? { - if let overridePath, - !normalizedPath(overridePath).isEmpty { - return runtimeLocator.validJavaHome(path: normalizedPath(overridePath)) + if let overridePath { + let normalizedPath = normalizedOverridePath(overridePath) + if !normalizedPath.isEmpty { + return runtimeLocator.validJavaHome(path: normalizedPath) + } } let paths = [runtimeLocator.environment()["JAVA_HOME"]] for path in paths.compactMap({ $0 }).map(normalizedPath).filter({ !$0.isEmpty }) { @@ -387,6 +392,15 @@ final class ProjectRuntimeService: ObservableObject { return result } + private func normalizedOverridePath(_ path: String) -> String { + let trimmedPath = path.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedPath.isEmpty else { return "" } + let normalized = normalizedPath(trimmedPath) + guard !(normalized as NSString).isAbsolutePath, + let projectURL else { return normalized } + return projectURL.appendingPathComponent(normalized).standardizedFileURL.path + } + private func normalizedPath(_ path: String) -> String { ((path as NSString).expandingTildeInPath as NSString).standardizingPath } diff --git a/Sources/Lithe/Views/Run/JavaRunConfigurationEditorView.swift b/Sources/Lithe/Views/Run/JavaRunConfigurationEditorView.swift index b031f2c7d..e47d68637 100644 --- a/Sources/Lithe/Views/Run/JavaRunConfigurationEditorView.swift +++ b/Sources/Lithe/Views/Run/JavaRunConfigurationEditorView.swift @@ -1,4 +1,5 @@ -import AppKit +import Foundation +import UniformTypeIdentifiers import SwiftUI struct RunConfigurationEditorView: View { @@ -10,6 +11,8 @@ struct RunConfigurationEditorView: View { @State private var environmentText: String @State private var saveScope: RunConfigurationSaveScope = .local @State private var saveError: String? + @State private var activePathPicker: PathPicker? + @State private var isPathPickerPresented = false init(feature: RunFeatureModel, configuration: RunConfiguration) { self.feature = feature @@ -59,7 +62,8 @@ struct RunConfigurationEditorView: View { } Button("Done") { options.environment = Self.environment(from: environmentText) - if feature.updateOptions(options, for: configuration, scope: saveScope) { + guard let scopedOptions = scopedOptionsForSave() else { return } + if feature.updateOptions(scopedOptions, for: configuration, scope: saveScope) { dismiss() } else { saveError = feature.configurationSaveError @@ -75,6 +79,12 @@ struct RunConfigurationEditorView: View { .frame(width: 520, height: 470) .background(LitheTheme.window) .preferredColorScheme(.dark) + .fileImporter( + isPresented: $isPathPickerPresented, + allowedContentTypes: activePathPicker?.allowedContentTypes ?? [.folder] + ) { result in + selectPath(result) + } } private var effectiveCapabilities: RunConfigurationCapabilities { @@ -161,23 +171,21 @@ struct RunConfigurationEditorView: View { text: stringBinding(\.javaHomePath), chooseDirectory: { chooseDirectory(for: \.javaHomePath) } ) - .disabled(saveScope == .project) } if configuration.kind.isMavenBacked { pathRow( title: "Maven executable", placeholder: "Use mvnw or detected Maven", text: stringBinding(\.mavenExecutablePath), - chooseDirectory: { chooseFileOrDirectory(for: \.mavenExecutablePath) } + chooseDirectory: { chooseFileOrDirectory(for: \.mavenExecutablePath) }, + chooseHelp: "Choose Maven executable or home" ) - .disabled(saveScope == .project) pathRow( title: "Maven JDK Home", placeholder: "Use service JDK", text: stringBinding(\.mavenJavaHomePath), chooseDirectory: { chooseDirectory(for: \.mavenJavaHomePath) } ) - .disabled(saveScope == .project) } pathRow( title: "Working directory", @@ -274,7 +282,8 @@ struct RunConfigurationEditorView: View { title: String, placeholder: String, text: Binding, - chooseDirectory: @escaping () -> Void + chooseDirectory: @escaping () -> Void, + chooseHelp: String = "Choose directory" ) -> some View { HStack(spacing: 8) { Text(LocalizedStringKey(title)) @@ -286,7 +295,7 @@ struct RunConfigurationEditorView: View { LitheSystemIcon(systemImage: "folder") } .litheIconButton() - .help("Choose directory") + .help(chooseHelp) } .font(.system(size: 12)) } @@ -326,26 +335,91 @@ struct RunConfigurationEditorView: View { } private func chooseDirectory(for keyPath: WritableKeyPath) { - let panel = NSOpenPanel() - panel.title = "Choose Directory" - panel.prompt = "Choose" - panel.canChooseFiles = false - panel.canChooseDirectories = true - panel.allowsMultipleSelection = false - if panel.runModal() == .OK, let url = panel.url { - options[keyPath: keyPath] = url.path - } + presentPathPicker(.directory(keyPath)) } private func chooseFileOrDirectory(for keyPath: WritableKeyPath) { - let panel = NSOpenPanel() - panel.title = "Choose Maven Executable or Home" - panel.prompt = "Choose" - panel.canChooseFiles = true - panel.canChooseDirectories = true - panel.allowsMultipleSelection = false - if panel.runModal() == .OK, let url = panel.url { - options[keyPath: keyPath] = url.path + presentPathPicker(.fileOrDirectory(keyPath)) + } + + private func presentPathPicker(_ picker: PathPicker) { + activePathPicker = picker + isPathPickerPresented = true + } + + private func selectPath(_ result: Result) { + defer { activePathPicker = nil } + switch result { + case .success(let url): + guard let activePathPicker else { return } + if saveScope == .project { + guard let projectURL = model.workspaceURL, + let path = projectRelativePath(url.path, root: projectURL) else { + saveError = String(localized: "Project paths must stay inside the current project.") + return + } + options[keyPath: activePathPicker.keyPath] = path + } else { + options[keyPath: activePathPicker.keyPath] = url.path + } + saveError = nil + case .failure(let error): + let cocoaError = error as NSError + guard !(cocoaError.domain == NSCocoaErrorDomain && cocoaError.code == NSUserCancelledError) else { return } + saveError = error.localizedDescription + } + } + + private func scopedOptionsForSave() -> RunOptions? { + guard saveScope == .project else { return options } + guard let projectURL = model.workspaceURL else { + saveError = String(localized: "Open a project before choosing project paths.") + return nil + } + var scopedOptions = options + for keyPath in [ + \.javaHomePath, + \.mavenExecutablePath, + \.mavenJavaHomePath, + \.workingDirectoryPath + ] as [WritableKeyPath] { + let value = scopedOptions[keyPath: keyPath].trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty else { continue } + guard let relativePath = projectRelativePath(value, root: projectURL) else { + saveError = String(localized: "Project paths must stay inside the current project.") + return nil + } + scopedOptions[keyPath: keyPath] = relativePath + } + return scopedOptions + } + + private func projectRelativePath(_ path: String, root: URL) -> String? { + let expandedPath = (path as NSString).expandingTildeInPath + guard (expandedPath as NSString).isAbsolutePath else { return path } + let rootPath = root.standardizedFileURL.path + let selectedPath = URL(fileURLWithPath: expandedPath).standardizedFileURL.path + if selectedPath == rootPath { return "." } + let prefix = rootPath.hasSuffix("/") ? rootPath : rootPath + "/" + guard selectedPath.hasPrefix(prefix) else { return nil } + return String(selectedPath.dropFirst(prefix.count)) + } + + private enum PathPicker { + case directory(WritableKeyPath) + case fileOrDirectory(WritableKeyPath) + + var keyPath: WritableKeyPath { + switch self { + case .directory(let keyPath), .fileOrDirectory(let keyPath): keyPath + } + } + + var allowedContentTypes: [UTType] { + switch self { + case .directory: [.folder] + case .fileOrDirectory: [.item] + } } } diff --git a/Sources/LitheExecutionModule/Services/RunService.swift b/Sources/LitheExecutionModule/Services/RunService.swift index e39407bfd..16cc663fb 100644 --- a/Sources/LitheExecutionModule/Services/RunService.swift +++ b/Sources/LitheExecutionModule/Services/RunService.swift @@ -300,12 +300,6 @@ package final class RunService: ObservableObject { scope: RunConfigurationSaveScope = .local ) -> Bool { configurationSaveError = nil - var options = options - if scope == .project { - options.javaHomePath = "" - options.mavenExecutablePath = "" - options.mavenJavaHomePath = "" - } if configurationStatus == .ready, let projectURL { do { try runConfigurationOperations.saveOptions( diff --git a/Tests/LitheTests/MavenRuntimeTests.swift b/Tests/LitheTests/MavenRuntimeTests.swift index e99e3eec2..9b6c4cdbd 100644 --- a/Tests/LitheTests/MavenRuntimeTests.swift +++ b/Tests/LitheTests/MavenRuntimeTests.swift @@ -88,6 +88,24 @@ struct MavenRuntimeTests { #expect(modules.map { $0.module.relativePath } == ["service"]) } + @Test + @MainActor + func projectRelativeJavaOverridesResolveAgainstProjectRoot() { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("lithe-project-relative-runtime", isDirectory: true) + .standardizedFileURL + let javaHome = root.appendingPathComponent("toolchains/jdk", isDirectory: true).standardizedFileURL + let mavenJavaHome = root.appendingPathComponent("toolchains/maven-jdk", isDirectory: true).standardizedFileURL + let service = ProjectRuntimeService( + runtimeLocator: ProjectRelativeRuntimeLocator(validJavaHomes: [javaHome.path, mavenJavaHome.path]), + store: EmptyKeyValueStore() + ) + service.openProject(at: root) + + #expect(service.javaHomeURL(overridePath: "toolchains/jdk") == javaHome) + #expect(service.mavenJavaHomeURL(overridePath: "toolchains/maven-jdk") == mavenJavaHome) + } + @Test @MainActor func canceledRuntimeDiscoveryClearsDiscoveringState() async throws { @@ -111,6 +129,25 @@ struct MavenRuntimeTests { } } +private struct ProjectRelativeRuntimeLocator: RuntimeLocator { + let validJavaHomes: Set + + func environment() -> [String: String] { [:] } + func discover() -> RuntimeDiscoveryResult { + RuntimeDiscoveryResult(javaRuntimes: [], mavenRuntimes: []) + } + func validJavaHome(path: String) -> URL? { + validJavaHomes.contains(path) ? URL(fileURLWithPath: path, isDirectory: true) : nil + } + func javaRuntime(at homeURL: URL) -> JavaRuntimeCandidate? { nil } + func isExecutable(at url: URL) -> Bool { false } + func systemMavenExecutable() -> URL? { nil } + func mavenExecutable(forHomePath path: String) -> URL? { nil } + func mavenRuntime(at executableURL: URL) -> MavenRuntimeCandidate? { nil } + func systemJDBExecutable() -> URL? { nil } + func javaLanguageServerExecutable() -> URL? { nil } +} + private final class BlockingRuntimeLocator: RuntimeLocator, @unchecked Sendable { private let lock = NSLock() private let releaseSemaphore = DispatchSemaphore(value: 0) diff --git a/Tests/LitheTests/RunConfigurationIntegrationTests.swift b/Tests/LitheTests/RunConfigurationIntegrationTests.swift index b22a2a149..3aacf6c28 100644 --- a/Tests/LitheTests/RunConfigurationIntegrationTests.swift +++ b/Tests/LitheTests/RunConfigurationIntegrationTests.swift @@ -2987,6 +2987,47 @@ struct RunConfigurationIntegrationTests { #expect(options.mavenJavaHomePath == "/test/maven-jdk") } + @Test + func projectToolchainSelectionsPersistAsProjectRelativePaths() throws { + let core = RustCoreBridge() + guard core.isAvailable else { return } + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("lithe-project-toolchains-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + for path in [".lithe/run", "toolchains/jdk", "toolchains/maven/bin", "toolchains/maven-jdk"] { + try FileManager.default.createDirectory( + at: root.appendingPathComponent(path, isDirectory: true), + withIntermediateDirectories: true + ) + } + try Data("#!/bin/sh\n".utf8) + .write(to: root.appendingPathComponent("toolchains/maven/bin/mvn")) + try Data(#"{"version":2,"configurations":[{"id":"spring","name":"Spring","provider":"spring-boot.maven","execution":"service","toolchains":{"java":"project-jdk","maven":"project-maven"},"extensions":{"maven":{"module":"."}}}]}"#.utf8) + .write(to: root.appendingPathComponent(".lithe/run/generated.json")) + let store = MacRunConfigurationStore( + core: core, + storage: MacFileStorage(), + preferences: RunTestKeyValueStore() + ) + + try store.saveOptions( + RunOptions( + javaHomePath: root.appendingPathComponent("toolchains/jdk").path, + mavenExecutablePath: root.appendingPathComponent("toolchains/maven/bin/mvn").path, + mavenJavaHomePath: root.appendingPathComponent("toolchains/maven-jdk").path + ), + configurationID: "spring", + scope: .project, + at: root + ) + + let resolved = try store.resolve(at: root, toolchainCandidates: []) + let options = try #require(resolved.configurations.first { $0.configuration.id == "spring" }?.options) + #expect(options.javaHomePath == "toolchains/jdk") + #expect(options.mavenExecutablePath == "toolchains/maven/bin/mvn") + #expect(options.mavenJavaHomePath == "toolchains/maven-jdk") + } + @Test func goRuntimeToolchainFlowsFromSharedJSONIntoProcessRequest() async throws { let core = RustCoreBridge() @@ -3391,6 +3432,39 @@ struct RunConfigurationIntegrationTests { ) == nil) } + @Test + func projectOptionUpdateForwardsSelectedToolchainPaths() async { + let configuration = JavaRunConfiguration( + id: "spring", + name: "Spring", + kind: .springBoot, + modulePath: ".", + mainClass: nil + ) + let fixture = makeFixture( + status: .ready, + effective: [EffectiveRunConfiguration( + configuration: configuration, + options: RunOptions(), + source: .generated + )] + ) + await fixture.service.loadProject( + at: fixture.root, + files: [], + mavenProject: fixture.mavenProject + ) + let options = RunOptions( + javaHomePath: "toolchains/jdk", + mavenExecutablePath: "toolchains/maven/bin/mvn", + mavenJavaHomePath: "toolchains/maven-jdk" + ) + + #expect(fixture.service.updateOptions(options, for: configuration, scope: .project)) + #expect(fixture.operations.savedOptions == [options]) + #expect(fixture.operations.savedScopes == [.project]) + } + @Test func appOwnedFileEventSuppressionIsOneShotAndExpires() { let first = URL(fileURLWithPath: "/tmp/lithe-self-write-one.json") @@ -3570,6 +3644,8 @@ private final class RecordingRunConfigurationOperations: RunConfigurationOperati private(set) var debugPorts: [Int?] = [] private(set) var createdDrafts: [RunConfigurationDraft] = [] private(set) var lastToolchainCandidates: [ProjectToolchainCandidate] = [] + private(set) var savedOptions: [RunOptions] = [] + private(set) var savedScopes: [RunConfigurationSaveScope] = [] init( status: ProjectRunConfigurationStatus, @@ -3622,7 +3698,10 @@ private final class RecordingRunConfigurationOperations: RunConfigurationOperati configurationID: String, scope: RunConfigurationSaveScope, at projectURL: URL - ) throws {} + ) throws { + savedOptions.append(options) + savedScopes.append(scope) + } func createConfiguration(_ draft: RunConfigurationDraft, at projectURL: URL) throws -> String { createdDrafts.append(draft) let slug = draft.name.lowercased().replacingOccurrences(of: " ", with: "-") diff --git a/rust/lithe-core/src/execution/configuration.rs b/rust/lithe-core/src/execution/configuration.rs index 7e71ebe8b..e22eac633 100644 --- a/rust/lithe-core/src/execution/configuration.rs +++ b/rust/lithe-core/src/execution/configuration.rs @@ -877,6 +877,12 @@ pub fn update_options(request: UpdateOptionsRequest) -> Result CoreError::new(ErrorCode::InvalidRequest, "Run configuration was not found") })?; let uses_maven_capability = is_maven_backed(provider); + let java_home_path = + normalize_scoped_toolchain_path(&root, &request.scope, &request.java_home_path)?; + let maven_executable_path = + normalize_scoped_toolchain_path(&root, &request.scope, &request.maven_executable_path)?; + let maven_java_home_path = + normalize_scoped_toolchain_path(&root, &request.scope, &request.maven_java_home_path)?; let working_directory = normalize_project_directory( &root, if request.working_directory.trim().is_empty() { @@ -905,14 +911,14 @@ pub fn update_options(request: UpdateOptionsRequest) -> Result "profiles": request.maven_profiles.into_iter().collect::>() }, "java": { - "homePath": request.java_home_path, - "mavenExecutablePath": request.maven_executable_path, - "mavenJavaHomePath": request.maven_java_home_path + "homePath": java_home_path, + "mavenExecutablePath": maven_executable_path, + "mavenJavaHomePath": maven_java_home_path } }); - } else if !request.java_home_path.is_empty() { + } else if !java_home_path.is_empty() { patch["extensions"] = json!({ - "java": { "homePath": request.java_home_path } + "java": { "homePath": java_home_path } }); } else { patch["args"] = json!(split_arguments(&request.arguments)); @@ -1612,6 +1618,22 @@ fn scope_document(scope: &str) -> Result<&'static str, CoreError> { } } +fn normalize_scoped_toolchain_path( + root: &Path, + scope: &str, + value: &str, +) -> Result { + let value = value.trim(); + if value.is_empty() { + return Ok(String::new()); + } + if scope == "project" { + normalize_project_directory(root, value, true) + } else { + Ok(value.to_string()) + } +} + fn normalize_project_directory( root: &Path, value: &str, diff --git a/rust/lithe-core/src/tests/run_configuration.rs b/rust/lithe-core/src/tests/run_configuration.rs index 2ccaf38ae..6b56d7668 100644 --- a/rust/lithe-core/src/tests/run_configuration.rs +++ b/rust/lithe-core/src/tests/run_configuration.rs @@ -557,6 +557,66 @@ fn run_configuration_mutations_are_shared_and_validated() { fs::remove_dir_all(root).unwrap(); } +#[test] +fn project_scoped_toolchain_paths_are_relative_and_stay_inside_the_project() { + let root = temporary_root("run-config-project-toolchains"); + let outside = temporary_root("run-config-outside-toolchain"); + fs::create_dir_all(root.join(".lithe/run")).unwrap(); + fs::create_dir_all(root.join("toolchains/jdk")).unwrap(); + fs::create_dir_all(root.join("toolchains/maven/bin")).unwrap(); + fs::create_dir_all(root.join("toolchains/maven-jdk")).unwrap(); + fs::create_dir_all(&outside).unwrap(); + fs::write(root.join("toolchains/maven/bin/mvn"), "#!/bin/sh\n").unwrap(); + fs::write( + root.join(".lithe/run/generated.json"), + r#"{"version":2,"configurations":[{"id":"spring","name":"Spring","provider":"spring-boot.maven","execution":"service","toolchains":{"java":"project-jdk","maven":"project-maven"},"extensions":{"maven":{"module":"."}}}]}"#, + ) + .unwrap(); + + let updated: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "project-toolchains", + "command": "runConfig.updateOptions", + "payload": { + "root": root, + "scope": "project", + "configurationId": "spring", + "javaHomePath": root.join("toolchains/jdk"), + "mavenExecutablePath": root.join("toolchains/maven/bin/mvn"), + "mavenJavaHomePath": root.join("toolchains/maven-jdk") + } + }) + .to_string(), + )) + .unwrap(); + assert_eq!(updated["ok"], true, "{updated}"); + let document: Value = + serde_json::from_str(updated["data"]["document"].as_str().unwrap()).unwrap(); + let java = &document["configurations"][0]["extensions"]["java"]; + assert_eq!(java["homePath"], "toolchains/jdk"); + assert_eq!(java["mavenExecutablePath"], "toolchains/maven/bin/mvn"); + assert_eq!(java["mavenJavaHomePath"], "toolchains/maven-jdk"); + + let rejected: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "outside-project-toolchain", + "command": "runConfig.updateOptions", + "payload": { + "root": root, + "scope": "project", + "configurationId": "spring", + "javaHomePath": outside + } + }) + .to_string(), + )) + .unwrap(); + assert_eq!(rejected["ok"], false, "{rejected}"); + + fs::remove_dir_all(root).unwrap(); + fs::remove_dir_all(outside).unwrap(); +} + #[test] fn run_configuration_generation_detects_declared_toolchain_versions() { let root = temporary_root("run-config-toolchains"); diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index c5e2e170a..9d67fcb20 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -406,6 +406,9 @@ document transformations. They validate scope, paths, supported types, stable IDs, main classes, modules, and argument parsing, then return UTF-8 JSON in the `document` field. The platform adapter selects the target project or local file and performs the atomic write. These commands never write files. +For project-scoped option updates, selected toolchain paths must resolve inside +`root` and are persisted with `/`-separated project-relative paths. Local-scoped +updates may carry host absolute paths. `runConfig.createLaunchPlan` accepts `root`, `configurationId`, optional `currentFile` and `classPath`, and optional `debugPort`. It returns a toolchain