diff --git a/.tmp-docs-dom.html b/.tmp-docs-dom.html new file mode 100644 index 00000000..e69de29b diff --git a/.tmp-docs-header-after-mobile.png b/.tmp-docs-header-after-mobile.png new file mode 100644 index 00000000..4a4acf59 Binary files /dev/null and b/.tmp-docs-header-after-mobile.png differ diff --git a/.tmp-docs-header-after-v2.png b/.tmp-docs-header-after-v2.png new file mode 100644 index 00000000..bbd21e98 Binary files /dev/null and b/.tmp-docs-header-after-v2.png differ diff --git a/.tmp-docs-header-after-v3.png b/.tmp-docs-header-after-v3.png new file mode 100644 index 00000000..5245e113 Binary files /dev/null and b/.tmp-docs-header-after-v3.png differ diff --git a/.tmp-docs-header-after.png b/.tmp-docs-header-after.png new file mode 100644 index 00000000..b0a0ac44 Binary files /dev/null and b/.tmp-docs-header-after.png differ diff --git a/.tmp-docs-header-before-mobile.png b/.tmp-docs-header-before-mobile.png new file mode 100644 index 00000000..09b3a17d Binary files /dev/null and b/.tmp-docs-header-before-mobile.png differ diff --git a/.tmp-docs-header-before.png b/.tmp-docs-header-before.png new file mode 100644 index 00000000..a71a47f6 Binary files /dev/null and b/.tmp-docs-header-before.png differ diff --git a/Cargo.lock b/Cargo.lock index abf2f734..6ca279cf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2071,7 +2071,7 @@ dependencies = [ [[package]] name = "memory-sync-gui" -version = "2026.10323.100" +version = "2026.10323.10738" dependencies = [ "dirs", "proptest", @@ -4349,7 +4349,7 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tnmsc" -version = "2026.10323.100" +version = "2026.10323.10738" dependencies = [ "clap", "dirs", @@ -4369,7 +4369,7 @@ dependencies = [ [[package]] name = "tnmsc-logger" -version = "2026.10323.100" +version = "2026.10323.10738" dependencies = [ "chrono", "napi", @@ -4381,7 +4381,7 @@ dependencies = [ [[package]] name = "tnmsc-md-compiler" -version = "2026.10323.100" +version = "2026.10323.10738" dependencies = [ "markdown", "napi", @@ -4396,7 +4396,7 @@ dependencies = [ [[package]] name = "tnmsc-script-runtime" -version = "2026.10323.100" +version = "2026.10323.10738" dependencies = [ "napi", "napi-build", diff --git a/Cargo.toml b/Cargo.toml index 09c77fb6..cdcef094 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ members = [ ] [workspace.package] -version = "2026.10323.10152" +version = "2026.10323.10738" edition = "2024" license = "AGPL-3.0-only" authors = ["TrueNine"] diff --git a/cli/npm/darwin-arm64/package.json b/cli/npm/darwin-arm64/package.json index ddabdfb4..89971e2b 100644 --- a/cli/npm/darwin-arm64/package.json +++ b/cli/npm/darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@truenine/memory-sync-cli-darwin-arm64", - "version": "2026.10323.10152", + "version": "2026.10323.10738", "os": [ "darwin" ], diff --git a/cli/npm/darwin-x64/package.json b/cli/npm/darwin-x64/package.json index 8757b295..8a52645f 100644 --- a/cli/npm/darwin-x64/package.json +++ b/cli/npm/darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "@truenine/memory-sync-cli-darwin-x64", - "version": "2026.10323.10152", + "version": "2026.10323.10738", "os": [ "darwin" ], diff --git a/cli/npm/linux-arm64-gnu/package.json b/cli/npm/linux-arm64-gnu/package.json index 475b2924..d8339c98 100644 --- a/cli/npm/linux-arm64-gnu/package.json +++ b/cli/npm/linux-arm64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@truenine/memory-sync-cli-linux-arm64-gnu", - "version": "2026.10323.10152", + "version": "2026.10323.10738", "os": [ "linux" ], diff --git a/cli/npm/linux-x64-gnu/package.json b/cli/npm/linux-x64-gnu/package.json index 07f594a1..4331a516 100644 --- a/cli/npm/linux-x64-gnu/package.json +++ b/cli/npm/linux-x64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@truenine/memory-sync-cli-linux-x64-gnu", - "version": "2026.10323.10152", + "version": "2026.10323.10738", "os": [ "linux" ], diff --git a/cli/npm/win32-x64-msvc/package.json b/cli/npm/win32-x64-msvc/package.json index f4494719..ce8538e8 100644 --- a/cli/npm/win32-x64-msvc/package.json +++ b/cli/npm/win32-x64-msvc/package.json @@ -1,6 +1,6 @@ { "name": "@truenine/memory-sync-cli-win32-x64-msvc", - "version": "2026.10323.10152", + "version": "2026.10323.10738", "os": [ "win32" ], diff --git a/cli/package.json b/cli/package.json index 7073be67..d0f40f1f 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,7 +1,7 @@ { "name": "@truenine/memory-sync-cli", "type": "module", - "version": "2026.10323.10152", + "version": "2026.10323.10738", "description": "TrueNine Memory Synchronization CLI", "author": "TrueNine", "license": "AGPL-3.0-only", diff --git a/cli/src/ConfigLoader.ts b/cli/src/ConfigLoader.ts index 0a12f99f..fbb1a9f4 100644 --- a/cli/src/ConfigLoader.ts +++ b/cli/src/ConfigLoader.ts @@ -7,11 +7,10 @@ import type { ILogger, OutputScopeOptions, PluginOutputScopeTopics, - UserConfigFile + UserConfigFile, + WindowsOptions } from './plugins/plugin-core' import * as fs from 'node:fs' -import * as os from 'node:os' -import * as path from 'node:path' import process from 'node:process' import { buildConfigDiagnostic, @@ -20,6 +19,13 @@ import { splitDiagnosticText } from './diagnostics' import {createLogger, ZUserConfigFile} from './plugins/plugin-core' +import { + getRequiredGlobalConfigPath, + resolveRuntimeEnvironment, + resolveUserPath, + DEFAULT_GLOBAL_CONFIG_FILE_NAME as RUNTIME_DEFAULT_CONFIG_FILE_NAME, + DEFAULT_GLOBAL_CONFIG_DIR as RUNTIME_DEFAULT_GLOBAL_CONFIG_DIR +} from './runtime-environment' /** * Default config file name @@ -35,7 +41,7 @@ export const DEFAULT_GLOBAL_CONFIG_DIR = '.aindex' * Get global config file path */ export function getGlobalConfigPath(): string { - return path.join(os.homedir(), DEFAULT_GLOBAL_CONFIG_DIR, DEFAULT_CONFIG_FILE_NAME) + return getRequiredGlobalConfigPath() } /** @@ -67,7 +73,22 @@ export class ConfigLoader { getSearchPaths(cwd: string = process.cwd()): string[] { void cwd - return [getGlobalConfigPath()] + const runtimeEnvironment = resolveRuntimeEnvironment() + + if (!runtimeEnvironment.isWsl) return [getRequiredGlobalConfigPath()] + + this.logger.info('wsl environment detected', { + effectiveHomeDir: runtimeEnvironment.effectiveHomeDir + }) + if (runtimeEnvironment.selectedGlobalConfigPath == null) { + throw new Error( + `WSL host config file not found under "${runtimeEnvironment.windowsUsersRoot}/*/${DEFAULT_GLOBAL_CONFIG_DIR}/${DEFAULT_CONFIG_FILE_NAME}".` + ) + } + this.logger.info('using wsl host global config', { + path: runtimeEnvironment.selectedGlobalConfigPath + }) + return [getRequiredGlobalConfigPath()] } loadFromFile(filePath: string): ConfigLoadResult { @@ -147,6 +168,7 @@ export class ConfigLoader { acc.cleanupProtection, config.cleanupProtection ) + const mergedWindows = this.mergeWindowsOptions(acc.windows, config.windows) return { ...acc, @@ -154,7 +176,8 @@ export class ConfigLoader { ...mergedAindex != null ? {aindex: mergedAindex} : {}, ...mergedOutputScopes != null ? {outputScopes: mergedOutputScopes} : {}, ...mergedFrontMatter != null ? {frontMatter: mergedFrontMatter} : {}, - ...mergedCleanupProtection != null ? {cleanupProtection: mergedCleanupProtection} : {} + ...mergedCleanupProtection != null ? {cleanupProtection: mergedCleanupProtection} : {}, + ...mergedWindows != null ? {windows: mergedWindows} : {} } }, {}) } @@ -237,9 +260,30 @@ export class ConfigLoader { } } + private mergeWindowsOptions( + a?: WindowsOptions, + b?: WindowsOptions + ): WindowsOptions | undefined { + if (a == null && b == null) return void 0 + if (a == null) return b + if (b == null) return a + + return { + ...a, + ...b, + ...a.wsl2 != null || b.wsl2 != null + ? { + wsl2: { + ...a.wsl2, + ...b.wsl2 + } + } + : {} + } + } + private resolveTilde(p: string): string { - if (p.startsWith('~')) return path.join(os.homedir(), p.slice(1)) - return p + return p.startsWith('~') ? resolveUserPath(p) : p } } @@ -283,7 +327,29 @@ export function loadUserConfig(cwd?: string): MergedConfigResult { */ export function validateGlobalConfig(): GlobalConfigValidationResult { const logger = createLogger('ConfigLoader') - const configPath = getGlobalConfigPath() + let configPath: string + + try { + configPath = getRequiredGlobalConfigPath() + } + catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + logger.error(buildConfigDiagnostic({ + code: 'GLOBAL_CONFIG_PATH_RESOLUTION_FAILED', + title: 'Failed to resolve global config path', + reason: diagnosticLines(errorMessage), + configPath: `${RUNTIME_DEFAULT_GLOBAL_CONFIG_DIR}/${RUNTIME_DEFAULT_CONFIG_FILE_NAME}`, + exactFix: diagnosticLines( + 'Ensure the required global config exists in the expected runtime-specific location before running tnmsc again.' + ) + })) + return { + valid: false, + exists: false, + errors: [errorMessage], + shouldExit: true + } + } if (!fs.existsSync(configPath)) { // Check if config file exists - do not auto-create const error = `Global config not found at ${configPath}. Please create it manually.` diff --git a/cli/src/ProtectedDeletionGuard.ts b/cli/src/ProtectedDeletionGuard.ts index b2c49b97..dc2e48bf 100644 --- a/cli/src/ProtectedDeletionGuard.ts +++ b/cli/src/ProtectedDeletionGuard.ts @@ -1,12 +1,12 @@ import type {ILogger, OutputCollectedContext, PluginOptions} from './plugins/plugin-core' import type {PublicDefinitionResolveOptions} from './public-config-paths' import * as fs from 'node:fs' -import * as os from 'node:os' import * as path from 'node:path' import process from 'node:process' import glob from 'fast-glob' import {buildProtectedDeletionDiagnostic} from './diagnostics' import {collectKnownPublicConfigDefinitionPaths} from './public-config-paths' +import {getEffectiveHomeDir, resolveUserPath} from './runtime-environment' interface DirPathLike { readonly path: string @@ -126,8 +126,7 @@ function resolveAbsolutePathFromDir(dir: DirPathLike | undefined): string | unde } export function expandHomePath(rawPath: string): string { - if (rawPath === '~') return os.homedir() - if (rawPath.startsWith('~/') || rawPath.startsWith('~\\')) return path.resolve(os.homedir(), rawPath.slice(2)) + if (rawPath === '~' || rawPath.startsWith('~/') || rawPath.startsWith('~\\')) return resolveUserPath(rawPath) return rawPath } @@ -256,7 +255,7 @@ function detectPathProtectionMode(rawPath: string, fallback: ProtectionMode): Pr } function collectBuiltInDangerousPathRules(): ProtectedPathRule[] { - const homeDir = os.homedir() + const homeDir = getEffectiveHomeDir() return [ createProtectedPathRule(path.parse(homeDir).root, 'direct', 'built-in dangerous root path', 'built-in-dangerous-root'), diff --git a/cli/src/commands/ConfigCommand.ts b/cli/src/commands/ConfigCommand.ts index b1a2934a..3ac6f245 100644 --- a/cli/src/commands/ConfigCommand.ts +++ b/cli/src/commands/ConfigCommand.ts @@ -1,9 +1,8 @@ import type {Command, CommandContext, CommandResult} from './Command' import * as fs from 'node:fs' -import * as os from 'node:os' import * as path from 'node:path' -import {DEFAULT_CONFIG_FILE_NAME, DEFAULT_GLOBAL_CONFIG_DIR} from '@/ConfigLoader' import {buildUsageDiagnostic, diagnosticLines} from '@/diagnostics' +import {getRequiredGlobalConfigPath} from '@/runtime-environment' /** * Valid configuration keys that can be set via `tnmsc config key=value`. @@ -53,7 +52,7 @@ function isValidLogLevel(value: string): boolean { * Get global config file path */ function getGlobalConfigPath(): string { - return path.join(os.homedir(), DEFAULT_GLOBAL_CONFIG_DIR, DEFAULT_CONFIG_FILE_NAME) + return getRequiredGlobalConfigPath() } /** @@ -157,7 +156,21 @@ export class ConfigCommand implements Command { } } - const config = readGlobalConfig() // Read existing config + let config: ConfigObject + + try { + config = readGlobalConfig() + } + catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + return { + success: false, + filesAffected: 0, + dirsAffected: 0, + message: errorMessage + } + } + const errors: string[] = [] const updated: string[] = [] @@ -209,7 +222,18 @@ export class ConfigCommand implements Command { } if (updated.length > 0) { // Write config if there are valid updates - writeGlobalConfig(config) + try { + writeGlobalConfig(config) + } + catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + return { + success: false, + filesAffected: 0, + dirsAffected: 0, + message: errorMessage + } + } logger.info('global config written', {path: getGlobalConfigPath()}) } diff --git a/cli/src/commands/DryRunOutputCommand.ts b/cli/src/commands/DryRunOutputCommand.ts index 323daf6a..180501f6 100644 --- a/cli/src/commands/DryRunOutputCommand.ts +++ b/cli/src/commands/DryRunOutputCommand.ts @@ -1,5 +1,7 @@ import type {Command, CommandContext, CommandResult} from './Command' +import {syncWindowsConfigIntoWsl} from '@/wsl-mirror-sync' import { + collectOutputDeclarations, executeDeclarativeWriteOutputs } from '../plugins/plugin-core' @@ -14,7 +16,8 @@ export class DryRunOutputCommand implements Command { logger.info('started', {command: 'dry-run-output', dryRun: true}) const writeCtx = createWriteContext(true) - const results = await executeDeclarativeWriteOutputs(outputPlugins, writeCtx) + const predeclaredOutputs = await collectOutputDeclarations(outputPlugins, writeCtx) + const results = await executeDeclarativeWriteOutputs(outputPlugins, writeCtx, predeclaredOutputs) let totalFiles = 0 let totalDirs = 0 @@ -24,6 +27,18 @@ export class DryRunOutputCommand implements Command { logger.info('plugin result', {plugin: pluginName, files: result.files.length, dirs: result.dirs.length, dryRun: true}) } + const wslMirrorResult = await syncWindowsConfigIntoWsl(outputPlugins, writeCtx, void 0, predeclaredOutputs) + if (wslMirrorResult.errors.length > 0) { + return { + success: false, + filesAffected: totalFiles, + dirsAffected: totalDirs, + message: wslMirrorResult.errors.join('\n') + } + } + + totalFiles += wslMirrorResult.mirroredFiles + logger.info('complete', {command: 'dry-run-output', totalFiles, totalDirs, dryRun: true}) return { diff --git a/cli/src/commands/ExecuteCommand.ts b/cli/src/commands/ExecuteCommand.ts index 65cf5cab..8f4c1c96 100644 --- a/cli/src/commands/ExecuteCommand.ts +++ b/cli/src/commands/ExecuteCommand.ts @@ -1,4 +1,5 @@ import type {Command, CommandContext, CommandResult} from './Command' +import {syncWindowsConfigIntoWsl} from '@/wsl-mirror-sync' import { collectOutputDeclarations, executeDeclarativeWriteOutputs @@ -54,6 +55,19 @@ export class ExecuteCommand implements Command { } } + const wslMirrorResult = await syncWindowsConfigIntoWsl(outputPlugins, writeCtx, void 0, predeclaredOutputs) + + if (wslMirrorResult.errors.length > 0) { + return { + success: false, + filesAffected: totalFiles, + dirsAffected: totalDirs, + message: wslMirrorResult.errors.join('\n') + } + } + + totalFiles += wslMirrorResult.mirroredFiles + logger.info('complete', {command: 'execute', pluginCount: results.size}) return { diff --git a/cli/src/commands/config_cmd.rs b/cli/src/commands/config_cmd.rs index e70d93cb..e7eb62b5 100644 --- a/cli/src/commands/config_cmd.rs +++ b/cli/src/commands/config_cmd.rs @@ -4,11 +4,26 @@ use crate::diagnostic_helpers::{diagnostic, line, optional_details}; use serde_json::json; use tnmsc_logger::create_logger; -use crate::core::config::{ConfigLoader, get_global_config_path}; +use crate::core::config::{ConfigLoader, get_required_global_config_path}; pub fn execute(pairs: &[(String, String)]) -> ExitCode { let logger = create_logger("config", None); - let result = ConfigLoader::with_defaults().load(std::path::Path::new(".")); + let result = match ConfigLoader::with_defaults().try_load(std::path::Path::new(".")) { + Ok(result) => result, + Err(error) => { + logger.error(diagnostic( + "GLOBAL_CONFIG_PATH_RESOLUTION_FAILED", + "Failed to resolve the global config path", + line("The runtime could not determine which global config file should be updated."), + Some(line( + "Ensure the required global config exists and retry the command.", + )), + None, + optional_details(json!({ "error": error })), + )); + return ExitCode::FAILURE; + } + }; let mut config = result.config; for (key, value) in pairs { @@ -30,7 +45,22 @@ pub fn execute(pairs: &[(String, String)]) -> ExitCode { } } - let config_path = get_global_config_path(); + let config_path = match get_required_global_config_path() { + Ok(path) => path, + Err(error) => { + logger.error(diagnostic( + "GLOBAL_CONFIG_PATH_RESOLUTION_FAILED", + "Failed to resolve the global config path", + line("The runtime could not determine which global config file should be written."), + Some(line( + "Ensure the required global config exists and retry the command.", + )), + None, + optional_details(json!({ "error": error })), + )); + return ExitCode::FAILURE; + } + }; match serde_json::to_string_pretty(&config) { Ok(json) => { if let Some(parent) = config_path.parent() { diff --git a/cli/src/commands/config_show.rs b/cli/src/commands/config_show.rs index a881cc49..0c9be861 100644 --- a/cli/src/commands/config_show.rs +++ b/cli/src/commands/config_show.rs @@ -8,7 +8,22 @@ use crate::core::config::ConfigLoader; pub fn execute() -> ExitCode { let logger = create_logger("config-show", None); - let result = ConfigLoader::with_defaults().load(std::path::Path::new(".")); + let result = match ConfigLoader::with_defaults().try_load(std::path::Path::new(".")) { + Ok(result) => result, + Err(error) => { + logger.error(diagnostic( + "GLOBAL_CONFIG_PATH_RESOLUTION_FAILED", + "Failed to resolve the global config path", + line("The runtime could not determine which global config file should be shown."), + Some(line( + "Ensure the required global config exists and retry the command.", + )), + None, + optional_details(json!({ "error": error })), + )); + return ExitCode::FAILURE; + } + }; match serde_json::to_string_pretty(&result.config) { Ok(json) => { println!("{json}"); diff --git a/cli/src/config.ts b/cli/src/config.ts index d2cffaca..dd081861 100644 --- a/cli/src/config.ts +++ b/cli/src/config.ts @@ -11,7 +11,8 @@ import type { OutputScopeOptions, PluginOptions, PluginOutputScopeTopics, - UserConfigFile + UserConfigFile, + WindowsOptions } from './plugins/plugin-core' import {checkVersionControl} from './Aindex' import {getConfigLoader} from './ConfigLoader' @@ -63,6 +64,7 @@ const DEFAULT_OPTIONS: Required = { blankLineAfter: true }, cleanupProtection: {}, + windows: {}, plugins: [] } @@ -79,6 +81,7 @@ export function userConfigToPluginOptions(userConfig: UserConfigFile): Partial

Option> { optional_details(serde_json::json!({ @@ -126,6 +128,27 @@ pub struct UserProfile { pub extra: HashMap, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(untagged)] +pub enum StringOrStrings { + Single(String), + Multiple(Vec), +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct WindowsWsl2Options { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub instances: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct WindowsOptions { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub wsl2: Option, +} + /// User configuration file (.tnmsc.json). /// All fields are optional — missing fields use default values. #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] @@ -143,6 +166,8 @@ pub struct UserConfigFile { pub fast_command_series_options: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub profile: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub windows: Option, } // --------------------------------------------------------------------------- @@ -178,14 +203,334 @@ pub struct GlobalConfigValidationResult { // Path helpers // --------------------------------------------------------------------------- +#[derive(Debug, Clone, Default)] +pub struct RuntimeEnvironmentContext { + pub is_wsl: bool, + pub native_home_dir: Option, + pub effective_home_dir: Option, + pub selected_global_config_path: Option, + pub windows_users_root: PathBuf, +} + fn home_dir() -> Option { dirs::home_dir() } +fn normalize_posix_like_path(raw_path: &str) -> String { + let replaced = raw_path.replace('\\', "/"); + let has_root = replaced.starts_with('/'); + let mut components: Vec<&str> = Vec::new(); + + for component in replaced.split('/') { + if component.is_empty() || component == "." { + continue; + } + + if component == ".." { + if let Some(last_component) = components.last() { + if *last_component != ".." { + components.pop(); + continue; + } + } + + if !has_root { + components.push(component); + } + continue; + } + + components.push(component); + } + + let joined = components.join("/"); + if has_root { + if joined.is_empty() { + "/".to_string() + } else { + format!("/{joined}") + } + } else { + joined + } +} + +fn is_same_or_child_path(candidate_path: &str, parent_path: &str) -> bool { + let normalized_candidate = normalize_posix_like_path(candidate_path); + let normalized_parent = normalize_posix_like_path(parent_path); + + normalized_candidate == normalized_parent + || normalized_candidate.starts_with(&format!("{normalized_parent}/")) +} + +fn convert_windows_path_to_wsl(raw_path: &str) -> Option { + let bytes = raw_path.as_bytes(); + if bytes.len() < 3 + || !bytes[0].is_ascii_alphabetic() + || bytes[1] != b':' + || (bytes[2] != b'\\' && bytes[2] != b'/') + { + return None; + } + + let drive_letter = char::from(bytes[0]).to_ascii_lowercase(); + let relative_path = raw_path[2..] + .trim_start_matches(['\\', '/']) + .replace('\\', "/"); + let base_path = format!("/mnt/{drive_letter}"); + + if relative_path.is_empty() { + Some(PathBuf::from(base_path)) + } else { + Some(Path::new(&base_path).join(relative_path)) + } +} + +fn resolve_wsl_host_home_candidate(users_root: &Path, raw_path: Option<&str>) -> Option { + let raw_path = raw_path?.trim(); + if raw_path.is_empty() { + return None; + } + + let normalized_users_root = normalize_posix_like_path(&users_root.to_string_lossy()); + let candidate_paths = [ + convert_windows_path_to_wsl(raw_path) + .map(|candidate_path| normalize_posix_like_path(&candidate_path.to_string_lossy())), + Some(normalize_posix_like_path(raw_path)), + ]; + + for candidate_path in candidate_paths.into_iter().flatten() { + if is_same_or_child_path(&candidate_path, &normalized_users_root) { + return Some(PathBuf::from(candidate_path)); + } + } + + None +} + +fn resolve_preferred_wsl_host_home_dirs_for( + users_root: &Path, + userprofile: Option<&str>, + homedrive: Option<&str>, + homepath: Option<&str>, + home: Option<&str>, +) -> Vec { + let mut preferred_home_dirs: Vec = Vec::new(); + let combined_home_path = match (homedrive, homepath) { + (Some(drive), Some(home_path)) if !drive.is_empty() && !home_path.is_empty() => { + Some(format!("{drive}{home_path}")) + } + _ => None, + }; + + for candidate in [ + resolve_wsl_host_home_candidate(users_root, userprofile), + resolve_wsl_host_home_candidate(users_root, combined_home_path.as_deref()), + resolve_wsl_host_home_candidate(users_root, home), + ] + .into_iter() + .flatten() + { + if !preferred_home_dirs.iter().any(|existing| existing == &candidate) { + preferred_home_dirs.push(candidate); + } + } + + preferred_home_dirs +} + +fn non_empty_env_var(name: &str) -> Option { + env::var(name).ok().filter(|value| !value.is_empty()) +} + +fn resolve_preferred_wsl_host_home_dirs_with_root(users_root: &Path) -> Vec { + let userprofile = non_empty_env_var("USERPROFILE"); + let homedrive = non_empty_env_var("HOMEDRIVE"); + let homepath = non_empty_env_var("HOMEPATH"); + let home = non_empty_env_var("HOME"); + + resolve_preferred_wsl_host_home_dirs_for( + users_root, + userprofile.as_deref(), + homedrive.as_deref(), + homepath.as_deref(), + home.as_deref(), + ) +} + +fn global_config_home_dir(candidate_path: &Path) -> Option { + candidate_path + .parent() + .and_then(|parent| parent.parent()) + .map(PathBuf::from) +} + +fn select_wsl_host_global_config_path_for( + users_root: &Path, + userprofile: Option<&str>, + homedrive: Option<&str>, + homepath: Option<&str>, + home: Option<&str>, +) -> Option { + let candidates = find_wsl_host_global_config_paths_with_root(users_root); + let preferred_home_dirs = resolve_preferred_wsl_host_home_dirs_for( + users_root, + userprofile, + homedrive, + homepath, + home, + ); + + if !preferred_home_dirs.is_empty() { + for preferred_home_dir in preferred_home_dirs { + if let Some(candidate_path) = candidates.iter().find(|candidate_path| { + global_config_home_dir(candidate_path).as_ref() == Some(&preferred_home_dir) + }) { + return Some(candidate_path.clone()); + } + } + + return None; + } + + if candidates.len() == 1 { + return candidates.into_iter().next(); + } + + None +} + +fn select_wsl_host_global_config_path_with_root(users_root: &Path) -> Option { + let userprofile = non_empty_env_var("USERPROFILE"); + let homedrive = non_empty_env_var("HOMEDRIVE"); + let homepath = non_empty_env_var("HOMEPATH"); + let home = non_empty_env_var("HOME"); + + select_wsl_host_global_config_path_for( + users_root, + userprofile.as_deref(), + homedrive.as_deref(), + homepath.as_deref(), + home.as_deref(), + ) +} + +fn build_required_wsl_config_resolution_error(users_root: &Path) -> String { + let preferred_home_dirs = resolve_preferred_wsl_host_home_dirs_with_root(users_root); + let candidates = find_wsl_host_global_config_paths_with_root(users_root); + let config_lookup_pattern = format!( + "\"{}/*/{}/{}\"", + users_root.to_string_lossy(), + DEFAULT_GLOBAL_CONFIG_DIR, + DEFAULT_CONFIG_FILE_NAME + ); + + if candidates.is_empty() { + return format!("WSL host config file not found under {config_lookup_pattern}."); + } + + if !preferred_home_dirs.is_empty() { + return format!( + "WSL host config file for the current Windows user was not found under {config_lookup_pattern}." + ); + } + + format!( + "WSL host config file could not be matched to the current Windows user under {config_lookup_pattern}." + ) +} + +fn is_wsl_runtime_for(os_name: &str, wsl_distro_name: Option<&str>, wsl_interop: Option<&str>, release: &str) -> bool { + if os_name != "linux" { + return false; + } + + if wsl_distro_name.is_some_and(|value| !value.is_empty()) || wsl_interop.is_some_and(|value| !value.is_empty()) { + return true; + } + + release.to_lowercase().contains("microsoft") +} + +pub fn is_wsl_runtime() -> bool { + let release = fs::read_to_string("/proc/sys/kernel/osrelease").unwrap_or_default(); + let wsl_distro_name = env::var("WSL_DISTRO_NAME").ok(); + let wsl_interop = env::var("WSL_INTEROP").ok(); + + is_wsl_runtime_for( + env::consts::OS, + wsl_distro_name.as_deref(), + wsl_interop.as_deref(), + &release, + ) +} + +pub fn find_wsl_host_global_config_paths_with_root(users_root: &Path) -> Vec { + if !users_root.is_dir() { + return vec![]; + } + + let mut candidates: Vec = match fs::read_dir(users_root) { + Ok(entries) => entries + .filter_map(|entry| entry.ok()) + .filter_map(|entry| { + let entry_path = entry.path(); + if !entry_path.is_dir() { + return None; + } + + let candidate_path = entry_path + .join(DEFAULT_GLOBAL_CONFIG_DIR) + .join(DEFAULT_CONFIG_FILE_NAME); + if candidate_path.is_file() { + Some(candidate_path) + } else { + None + } + }) + .collect(), + Err(_) => vec![], + }; + + candidates.sort_by(|a, b| a.to_string_lossy().cmp(&b.to_string_lossy())); + candidates +} + +pub fn resolve_runtime_environment_with_root(users_root: PathBuf) -> RuntimeEnvironmentContext { + let native_home_dir = home_dir(); + let is_wsl = is_wsl_runtime(); + let selected_global_config_path = if is_wsl { + select_wsl_host_global_config_path_with_root(&users_root) + } else { + None + }; + let effective_home_dir = selected_global_config_path + .as_ref() + .and_then(|config_path| config_path.parent().and_then(|parent| parent.parent())) + .map(PathBuf::from) + .or_else(|| native_home_dir.clone()); + + RuntimeEnvironmentContext { + is_wsl, + native_home_dir, + effective_home_dir, + selected_global_config_path, + windows_users_root: users_root, + } +} + +pub fn resolve_runtime_environment() -> RuntimeEnvironmentContext { + resolve_runtime_environment_with_root(PathBuf::from(DEFAULT_WSL_WINDOWS_USERS_ROOT)) +} + /// Resolve `~` prefix to the user's home directory. pub fn resolve_tilde(p: &str) -> PathBuf { + let runtime_environment = resolve_runtime_environment(); if let Some(rest) = p.strip_prefix('~') { - if let Some(home) = home_dir() { + if let Some(home) = runtime_environment + .effective_home_dir + .or(runtime_environment.native_home_dir) + { let rest = rest .strip_prefix('/') .or_else(|| rest.strip_prefix('\\')) @@ -198,7 +543,16 @@ pub fn resolve_tilde(p: &str) -> PathBuf { /// Get the global config file path: `~/.aindex/.tnmsc.json` pub fn get_global_config_path() -> PathBuf { - match home_dir() { + let runtime_environment = resolve_runtime_environment(); + + if let Some(selected_path) = runtime_environment.selected_global_config_path { + return selected_path; + } + + match runtime_environment + .effective_home_dir + .or(runtime_environment.native_home_dir) + { Some(home) => home .join(DEFAULT_GLOBAL_CONFIG_DIR) .join(DEFAULT_CONFIG_FILE_NAME), @@ -206,6 +560,18 @@ pub fn get_global_config_path() -> PathBuf { } } +pub fn get_required_global_config_path() -> Result { + let runtime_environment = resolve_runtime_environment(); + + if runtime_environment.is_wsl && runtime_environment.selected_global_config_path.is_none() { + return Err(build_required_wsl_config_resolution_error( + &runtime_environment.windows_users_root, + )); + } + + Ok(get_global_config_path()) +} + // --------------------------------------------------------------------------- // Merge logic // --------------------------------------------------------------------------- @@ -230,9 +596,31 @@ fn merge_aindex(a: &Option, b: &Option) -> Option, b: &Option) -> Option { + match (a, b) { + (None, None) => None, + (Some(v), None) => Some(v.clone()), + (None, Some(v)) => Some(v.clone()), + (Some(base), Some(over)) => Some(WindowsOptions { + wsl2: match (&base.wsl2, &over.wsl2) { + (None, None) => None, + (Some(v), None) => Some(v.clone()), + (None, Some(v)) => Some(v.clone()), + (Some(base_wsl2), Some(over_wsl2)) => Some(WindowsWsl2Options { + instances: over_wsl2 + .instances + .clone() + .or_else(|| base_wsl2.instances.clone()), + }), + }, + }), + } +} + /// Merge two configs. `over` fields take priority over `base`. pub fn merge_configs_pair(base: &UserConfigFile, over: &UserConfigFile) -> UserConfigFile { let merged_aindex = merge_aindex(&base.aindex, &over.aindex); + let merged_windows = merge_windows(&base.windows, &over.windows); UserConfigFile { version: over.version.clone().or_else(|| base.version.clone()), @@ -247,6 +635,7 @@ pub fn merge_configs_pair(base: &UserConfigFile, over: &UserConfigFile) -> UserC .clone() .or_else(|| base.fast_command_series_options.clone()), profile: over.profile.clone().or_else(|| base.profile.clone()), + windows: merged_windows, } } @@ -293,6 +682,34 @@ impl ConfigLoader { Self::new(ConfigLoaderOptions::default()) } + pub fn try_get_search_paths(&self, _cwd: &Path) -> Result, String> { + let runtime_environment = resolve_runtime_environment(); + + if runtime_environment.is_wsl { + self.logger.info( + Value::String("wsl environment detected".into()), + Some(serde_json::json!({ + "effectiveHomeDir": runtime_environment + .effective_home_dir + .as_ref() + .map(|path| path.to_string_lossy().into_owned()) + })), + ); + } + + let config_path = get_required_global_config_path()?; + if runtime_environment.is_wsl { + self.logger.info( + Value::String("using wsl host global config".into()), + Some(serde_json::json!({ + "path": config_path.to_string_lossy() + })), + ); + } + + Ok(vec![config_path]) + } + /// Get the list of config file paths to search. pub fn get_search_paths(&self, _cwd: &Path) -> Vec { vec![get_global_config_path()] @@ -353,9 +770,8 @@ impl ConfigLoader { } } - /// Load and merge all config files. - pub fn load(&self, cwd: &Path) -> MergedConfigResult { - let search_paths = self.get_search_paths(cwd); + pub fn try_load(&self, cwd: &Path) -> Result { + let search_paths = self.try_get_search_paths(cwd)?; let mut loaded: Vec = Vec::new(); for path in &search_paths { @@ -369,11 +785,33 @@ impl ConfigLoader { let merged = merge_configs(&configs); let sources: Vec = loaded.iter().filter_map(|r| r.source.clone()).collect(); - MergedConfigResult { + Ok(MergedConfigResult { config: merged, sources, found: !loaded.is_empty(), - } + }) + } + + /// Load and merge all config files. + pub fn load(&self, cwd: &Path) -> MergedConfigResult { + self.try_load(cwd).unwrap_or_else(|error| { + self.logger.error(diagnostic( + "GLOBAL_CONFIG_PATH_RESOLUTION_FAILED", + "Failed to resolve the global config path", + line("The runtime could not determine which global config file should be loaded."), + Some(line( + "Ensure the expected global config exists and retry the command.", + )), + None, + optional_details(serde_json::json!({ "error": error })), + )); + + MergedConfigResult { + config: UserConfigFile::default(), + sources: vec![], + found: false, + } + }) } fn parse_config(&self, content: &str, file_path: &Path) -> Result { @@ -414,8 +852,8 @@ impl ConfigLoader { // --------------------------------------------------------------------------- /// Load user configuration using default loader. -pub fn load_user_config(cwd: &Path) -> MergedConfigResult { - ConfigLoader::with_defaults().load(cwd) +pub fn load_user_config(cwd: &Path) -> Result { + ConfigLoader::with_defaults().try_load(cwd) } // --------------------------------------------------------------------------- @@ -475,7 +913,27 @@ pub fn validate_and_ensure_global_config( default_config: &UserConfigFile, ) -> GlobalConfigValidationResult { let logger = create_logger("ConfigLoader", None); - let config_path = get_global_config_path(); + let config_path = match get_required_global_config_path() { + Ok(path) => path, + Err(error) => { + logger.error(diagnostic( + "GLOBAL_CONFIG_PATH_RESOLUTION_FAILED", + "Failed to resolve the global config path", + line("The runtime could not determine the expected global config file location."), + Some(line( + "Ensure the required host config exists before retrying tnmsc.", + )), + None, + optional_details(serde_json::json!({ "error": error })), + )); + return GlobalConfigValidationResult { + valid: false, + exists: false, + errors: vec![error], + should_exit: true, + }; + } + }; if !config_path.exists() { logger.warn(diagnostic( @@ -690,6 +1148,29 @@ mod tests { ); } + #[test] + fn test_user_config_file_deserialize_with_windows_wsl2_instances() { + let json = r#"{ + "windows": { + "wsl2": { + "instances": ["Ubuntu", "Debian"] + } + } + }"#; + let config: UserConfigFile = serde_json::from_str(json).unwrap(); + + match config + .windows + .and_then(|windows| windows.wsl2) + .and_then(|wsl2| wsl2.instances) + { + Some(StringOrStrings::Multiple(instances)) => { + assert_eq!(instances, vec!["Ubuntu".to_string(), "Debian".to_string()]); + } + other => panic!("expected windows.wsl2.instances array, got {:?}", other), + } + } + #[test] fn test_user_config_file_roundtrip() { let config = UserConfigFile { @@ -752,6 +1233,32 @@ mod tests { ); } + #[test] + fn test_merge_configs_merges_windows_options() { + let base_config = UserConfigFile { + windows: Some(WindowsOptions { + wsl2: Some(WindowsWsl2Options { + instances: Some(StringOrStrings::Single("Ubuntu".into())), + }), + }), + ..Default::default() + }; + let override_config = UserConfigFile { + log_level: Some("debug".into()), + ..Default::default() + }; + + let merged = merge_configs_pair(&base_config, &override_config); + match merged + .windows + .and_then(|windows| windows.wsl2) + .and_then(|wsl2| wsl2.instances) + { + Some(StringOrStrings::Single(instance)) => assert_eq!(instance, "Ubuntu"), + other => panic!("expected merged windows.wsl2.instances value, got {:?}", other), + } + } + #[test] fn test_merge_aindex_deep() { let cwd_config = UserConfigFile { @@ -800,6 +1307,83 @@ mod tests { assert_eq!(paths, vec![get_global_config_path()]); } + #[test] + fn test_find_wsl_host_global_config_paths_with_root_sorts_candidates() { + let temp_dir = TempDir::new().unwrap(); + let users_root = temp_dir.path().join("Users"); + let alpha_config_path = users_root.join("alpha").join(".aindex").join(".tnmsc.json"); + let bravo_config_path = users_root.join("bravo").join(".aindex").join(".tnmsc.json"); + + fs::create_dir_all(alpha_config_path.parent().unwrap()).unwrap(); + fs::create_dir_all(bravo_config_path.parent().unwrap()).unwrap(); + fs::write(&alpha_config_path, "{}\n").unwrap(); + fs::write(&bravo_config_path, "{}\n").unwrap(); + + let candidates = find_wsl_host_global_config_paths_with_root(&users_root); + assert_eq!(candidates, vec![alpha_config_path, bravo_config_path]); + } + + #[test] + fn test_select_wsl_host_global_config_path_for_prefers_matching_userprofile() { + let temp_dir = TempDir::new().unwrap(); + let users_root = temp_dir.path().join("Users"); + let alpha_config_path = users_root.join("alpha").join(".aindex").join(".tnmsc.json"); + let bravo_config_path = users_root.join("bravo").join(".aindex").join(".tnmsc.json"); + + fs::create_dir_all(alpha_config_path.parent().unwrap()).unwrap(); + fs::create_dir_all(bravo_config_path.parent().unwrap()).unwrap(); + fs::write(&alpha_config_path, "{}\n").unwrap(); + fs::write(&bravo_config_path, "{}\n").unwrap(); + + let selected = select_wsl_host_global_config_path_for( + &users_root, + Some(&users_root.join("bravo").to_string_lossy()), + None, + None, + None, + ); + + assert_eq!(selected, Some(bravo_config_path)); + } + + #[test] + fn test_select_wsl_host_global_config_path_for_rejects_other_windows_profile() { + let temp_dir = TempDir::new().unwrap(); + let users_root = temp_dir.path().join("Users"); + let alpha_config_path = users_root.join("alpha").join(".aindex").join(".tnmsc.json"); + + fs::create_dir_all(alpha_config_path.parent().unwrap()).unwrap(); + fs::write(&alpha_config_path, "{}\n").unwrap(); + + let selected = select_wsl_host_global_config_path_for( + &users_root, + Some(&users_root.join("bravo").to_string_lossy()), + None, + None, + None, + ); + + assert_eq!(selected, None); + } + + #[test] + fn test_is_wsl_runtime_for_detects_linux_wsl_inputs() { + assert!(is_wsl_runtime_for("linux", Some("Ubuntu"), None, "")); + assert!(is_wsl_runtime_for( + "linux", + None, + Some("/run/WSL/12_interop"), + "" + )); + assert!(is_wsl_runtime_for( + "linux", + None, + None, + "5.15.167.4-microsoft-standard-WSL2" + )); + assert!(!is_wsl_runtime_for("windows", Some("Ubuntu"), None, "")); + } + #[test] fn test_config_loader_load_nonexistent() { let loader = ConfigLoader::with_defaults(); @@ -875,14 +1459,16 @@ mod napi_binding { #[napi] pub fn load_user_config(cwd: String) -> napi::Result { let path = std::path::Path::new(&cwd); - let result = ConfigLoader::with_defaults().load(path); + let result = super::load_user_config(path).map_err(napi::Error::from_reason)?; serde_json::to_string(&result.config).map_err(|e| napi::Error::from_reason(e.to_string())) } /// Get the global config file path (~/.aindex/.tnmsc.json). #[napi] - pub fn get_global_config_path_str() -> String { - get_global_config_path().to_string_lossy().into_owned() + pub fn get_global_config_path_str() -> napi::Result { + get_required_global_config_path() + .map(|path| path.to_string_lossy().into_owned()) + .map_err(napi::Error::from_reason) } /// Merge two config JSON strings. `over` fields take priority over `base`. diff --git a/cli/src/inputs/AbstractInputCapability.ts b/cli/src/inputs/AbstractInputCapability.ts index c3a318a3..fc44100c 100644 --- a/cli/src/inputs/AbstractInputCapability.ts +++ b/cli/src/inputs/AbstractInputCapability.ts @@ -14,13 +14,13 @@ import type { } from '@/plugins/plugin-core' import {spawn} from 'node:child_process' -import * as os from 'node:os' import * as path from 'node:path' import {createLogger} from '@truenine/logger' import {parseMarkdown} from '@truenine/md-compiler/markdown' import {buildDiagnostic, diagnosticLines} from '@/diagnostics' import {PathPlaceholders} from '@/plugins/plugin-core' import {logProtectedDeletionGuardError, ProtectedDeletionGuardError} from '@/ProtectedDeletionGuard' +import {resolveUserPath} from '@/runtime-environment' export abstract class AbstractInputCapability implements InputCapability { private readonly inputEffects: InputEffectRegistration[] = [] @@ -165,11 +165,11 @@ export abstract class AbstractInputCapability implements InputCapability { protected resolvePath(rawPath: string, workspaceDir: string): string { let resolved = rawPath - if (resolved.startsWith(PathPlaceholders.USER_HOME)) resolved = resolved.replace(PathPlaceholders.USER_HOME, os.homedir()) - if (resolved.includes(PathPlaceholders.WORKSPACE)) resolved = resolved.replace(PathPlaceholders.WORKSPACE, workspaceDir) - return path.normalize(resolved) + if (resolved.startsWith(PathPlaceholders.USER_HOME)) return resolveUserPath(resolved) + + return path.normalize(resolveUserPath(resolved)) } protected resolveAindexPath(relativePath: string, aindexDir: string): string { diff --git a/cli/src/inputs/input-global-memory.ts b/cli/src/inputs/input-global-memory.ts index 0c8b7d70..3b5de5dd 100644 --- a/cli/src/inputs/input-global-memory.ts +++ b/cli/src/inputs/input-global-memory.ts @@ -1,17 +1,18 @@ import type {InputCapabilityContext, InputCollectedContext} from '../plugins/plugin-core' -import * as os from 'node:os' import process from 'node:process' import {mdxToMd} from '@truenine/md-compiler' import {CompilerDiagnosticError, ScopeError} from '@truenine/md-compiler/errors' import {parseMarkdown} from '@truenine/md-compiler/markdown' +import {getGlobalConfigPath} from '@/ConfigLoader' import { buildConfigDiagnostic, buildPathStateDiagnostic, buildPromptCompilerDiagnostic, diagnosticLines } from '@/diagnostics' +import {getEffectiveHomeDir} from '@/runtime-environment' import {AbstractInputCapability, FilePathKind, GlobalConfigDirectoryType, PromptKind} from '../plugins/plugin-core' import {assertNoResidualModuleSyntax} from '../plugins/plugin-core/DistPromptGuards' import {formatPromptCompilerDiagnostic} from '../plugins/plugin-core/PromptCompilerDiagnostics' @@ -24,6 +25,8 @@ export class GlobalMemoryInputCapability extends AbstractInputCapability { async collect(ctx: InputCapabilityContext): Promise> { const {userConfigOptions: options, fs, path, globalScope} = ctx const {aindexDir} = this.resolveBasePaths(options) + const globalConfigPath = getGlobalConfigPath() + const effectiveHomeDir = getEffectiveHomeDir() const globalMemoryFile = this.resolveAindexPath(options.aindex.globalPrompt.dist, aindexDir) @@ -84,11 +87,11 @@ export class GlobalMemoryInputCapability extends AbstractInputCapability { code: 'GLOBAL_MEMORY_SCOPE_VARIABLES_MISSING', title: 'Global memory prompt references missing config variables', reason: diagnosticLines( - 'The global memory prompt uses scope variables that are not defined in `~/.aindex/.tnmsc.json`.' + `The global memory prompt uses scope variables that are not defined in "${globalConfigPath}".` ), - configPath: '~/.aindex/.tnmsc.json', + configPath: globalConfigPath, exactFix: diagnosticLines( - 'Add the missing variables to `~/.aindex/.tnmsc.json` and rerun tnmsc.' + `Add the missing variables to "${globalConfigPath}" and rerun tnmsc.` ), possibleFixes: [ diagnosticLines('If you reference `{profile.name}`, define `profile.name` in the config file.') @@ -127,9 +130,9 @@ export class GlobalMemoryInputCapability extends AbstractInputCapability { directory: { pathKind: FilePathKind.Relative, path: '', - basePath: os.homedir(), - getDirectoryName: () => path.basename(os.homedir()), - getAbsolutePath: () => os.homedir() + basePath: effectiveHomeDir, + getDirectoryName: () => path.basename(effectiveHomeDir), + getAbsolutePath: () => effectiveHomeDir } } } diff --git a/cli/src/inputs/input-project-prompt.ts b/cli/src/inputs/input-project-prompt.ts index 7204590a..44ee79e6 100644 --- a/cli/src/inputs/input-project-prompt.ts +++ b/cli/src/inputs/input-project-prompt.ts @@ -12,6 +12,7 @@ import process from 'node:process' import {mdxToMd} from '@truenine/md-compiler' import {CompilerDiagnosticError, ScopeError} from '@truenine/md-compiler/errors' import {parseMarkdown} from '@truenine/md-compiler/markdown' +import {getGlobalConfigPath} from '@/ConfigLoader' import { buildConfigDiagnostic, buildFileOperationDiagnostic, @@ -126,15 +127,16 @@ export class ProjectPromptInputCapability extends AbstractInputCapability { } })) if (e instanceof ScopeError) { + const globalConfigPath = getGlobalConfigPath() logger.error(buildConfigDiagnostic({ code: 'WORKSPACE_ROOT_MEMORY_SCOPE_VARIABLES_MISSING', title: 'Workspace root memory prompt references missing config variables', reason: diagnosticLines( - 'The workspace root memory prompt uses scope variables that are not defined in `~/.aindex/.tnmsc.json`.' + `The workspace root memory prompt uses scope variables that are not defined in "${globalConfigPath}".` ), - configPath: '~/.aindex/.tnmsc.json', + configPath: globalConfigPath, exactFix: diagnosticLines( - 'Define the missing variables in `~/.aindex/.tnmsc.json` and rerun tnmsc.' + `Define the missing variables in "${globalConfigPath}" and rerun tnmsc.` ), details: { promptPath: filePath, @@ -231,15 +233,16 @@ export class ProjectPromptInputCapability extends AbstractInputCapability { } })) if (e instanceof ScopeError) { + const globalConfigPath = getGlobalConfigPath() logger.error(buildConfigDiagnostic({ code: 'PROJECT_ROOT_MEMORY_SCOPE_VARIABLES_MISSING', title: 'Project root memory prompt references missing config variables', reason: diagnosticLines( - 'The project root memory prompt uses scope variables that are not defined in `~/.aindex/.tnmsc.json`.' + `The project root memory prompt uses scope variables that are not defined in "${globalConfigPath}".` ), - configPath: '~/.aindex/.tnmsc.json', + configPath: globalConfigPath, exactFix: diagnosticLines( - 'Define the missing variables in `~/.aindex/.tnmsc.json` and rerun tnmsc.' + `Define the missing variables in "${globalConfigPath}" and rerun tnmsc.` ), details: { promptPath: filePath, @@ -377,15 +380,16 @@ export class ProjectPromptInputCapability extends AbstractInputCapability { } })) if (e instanceof ScopeError) { + const globalConfigPath = getGlobalConfigPath() logger.error(buildConfigDiagnostic({ code: 'PROJECT_CHILD_MEMORY_SCOPE_VARIABLES_MISSING', title: 'Project child memory prompt references missing config variables', reason: diagnosticLines( - 'The project child memory prompt uses scope variables that are not defined in `~/.aindex/.tnmsc.json`.' + `The project child memory prompt uses scope variables that are not defined in "${globalConfigPath}".` ), - configPath: '~/.aindex/.tnmsc.json', + configPath: globalConfigPath, exactFix: diagnosticLines( - 'Define the missing variables in `~/.aindex/.tnmsc.json` and rerun tnmsc.' + `Define the missing variables in "${globalConfigPath}" and rerun tnmsc.` ), details: { promptPath: filePath, diff --git a/cli/src/inputs/input-readme.ts b/cli/src/inputs/input-readme.ts index 3a5647f8..086f9098 100644 --- a/cli/src/inputs/input-readme.ts +++ b/cli/src/inputs/input-readme.ts @@ -4,6 +4,7 @@ import process from 'node:process' import {mdxToMd} from '@truenine/md-compiler' import {CompilerDiagnosticError, ScopeError} from '@truenine/md-compiler/errors' +import {getGlobalConfigPath} from '@/ConfigLoader' import { buildConfigDiagnostic, buildFileOperationDiagnostic, @@ -117,15 +118,16 @@ export class ReadmeMdInputCapability extends AbstractInputCapability { } })) if (e instanceof ScopeError) { + const globalConfigPath = getGlobalConfigPath() logger.error(buildConfigDiagnostic({ code: 'README_SCOPE_VARIABLES_MISSING', title: 'Readme-family prompt references missing config variables', reason: diagnosticLines( - 'The readme-family prompt uses scope variables that are not defined in `~/.aindex/.tnmsc.json`.' + `The readme-family prompt uses scope variables that are not defined in "${globalConfigPath}".` ), - configPath: '~/.aindex/.tnmsc.json', + configPath: globalConfigPath, exactFix: diagnosticLines( - 'Define the missing variables in `~/.aindex/.tnmsc.json` and rerun tnmsc.' + `Define the missing variables in "${globalConfigPath}" and rerun tnmsc.` ), details: { promptPath: filePath, diff --git a/cli/src/lib.rs b/cli/src/lib.rs index 1c1a1b20..01062b49 100644 --- a/cli/src/lib.rs +++ b/cli/src/lib.rs @@ -54,12 +54,16 @@ pub fn version() -> &'static str { /// Load and merge configuration from the canonical global config path. pub fn load_config(cwd: &Path) -> Result { - Ok(core::config::ConfigLoader::with_defaults().load(cwd)) + core::config::ConfigLoader::with_defaults() + .try_load(cwd) + .map_err(CliError::ConfigError) } /// Return the merged global configuration as a pretty-printed JSON string. pub fn config_show(cwd: &Path) -> Result { - let result = core::config::ConfigLoader::with_defaults().load(cwd); + let result = core::config::ConfigLoader::with_defaults() + .try_load(cwd) + .map_err(CliError::ConfigError)?; serde_json::to_string_pretty(&result.config).map_err(CliError::from) } diff --git a/cli/src/plugins/ClaudeCodeCLIOutputPlugin.ts b/cli/src/plugins/ClaudeCodeCLIOutputPlugin.ts index 85ee8e0d..1ff83185 100644 --- a/cli/src/plugins/ClaudeCodeCLIOutputPlugin.ts +++ b/cli/src/plugins/ClaudeCodeCLIOutputPlugin.ts @@ -53,6 +53,10 @@ export class ClaudeCodeCLIOutputPlugin extends AbstractOutputPlugin { } } }, + wslMirrors: [ + '~/.claude/settings.json', + '~/.claude/config.json' + ], capabilities: { prompt: { scopes: ['project', 'global'], diff --git a/cli/src/plugins/CodexCLIOutputPlugin.ts b/cli/src/plugins/CodexCLIOutputPlugin.ts index 83146137..986cdcfa 100644 --- a/cli/src/plugins/CodexCLIOutputPlugin.ts +++ b/cli/src/plugins/CodexCLIOutputPlugin.ts @@ -60,6 +60,10 @@ const CODEX_OUTPUT_OPTIONS = { } } }, + wslMirrors: [ + '~/.codex/config.toml', + '~/.codex/auth.json' + ], dependsOn: [PLUGIN_NAMES.AgentsOutput], capabilities: { prompt: { diff --git a/cli/src/plugins/WslMirrorDeclarations.test.ts b/cli/src/plugins/WslMirrorDeclarations.test.ts new file mode 100644 index 00000000..69f48e58 --- /dev/null +++ b/cli/src/plugins/WslMirrorDeclarations.test.ts @@ -0,0 +1,25 @@ +import {describe, expect, it} from 'vitest' +import {ClaudeCodeCLIOutputPlugin} from './ClaudeCodeCLIOutputPlugin' +import {CodexCLIOutputPlugin} from './CodexCLIOutputPlugin' + +describe('wSL mirror declarations', () => { + it('declares the expected Claude host config files', async () => { + const plugin = new ClaudeCodeCLIOutputPlugin() + const declarations = await plugin.declareWslMirrorFiles?.({} as never) + + expect(declarations).toEqual([ + {sourcePath: '~/.claude/settings.json'}, + {sourcePath: '~/.claude/config.json'} + ]) + }) + + it('declares the expected Codex host config files', async () => { + const plugin = new CodexCLIOutputPlugin() + const declarations = await plugin.declareWslMirrorFiles?.({} as never) + + expect(declarations).toEqual([ + {sourcePath: '~/.codex/config.toml'}, + {sourcePath: '~/.codex/auth.json'} + ]) + }) +}) diff --git a/cli/src/plugins/desk-paths.test.ts b/cli/src/plugins/desk-paths.test.ts new file mode 100644 index 00000000..8dc16f43 --- /dev/null +++ b/cli/src/plugins/desk-paths.test.ts @@ -0,0 +1,66 @@ +import * as path from 'node:path' +import {afterEach, describe, expect, it, vi} from 'vitest' + +import {getPlatformFixedDir} from './desk-paths' + +const {resolveRuntimeEnvironmentMock, resolveUserPathMock} = vi.hoisted(() => ({ + resolveRuntimeEnvironmentMock: vi.fn(), + resolveUserPathMock: vi.fn((value: string) => value) +})) + +vi.mock('@/runtime-environment', async importActual => { + const actual = await importActual() + return { + ...actual, + resolveRuntimeEnvironment: resolveRuntimeEnvironmentMock, + resolveUserPath: resolveUserPathMock + } +}) + +const originalXdgDataHome = process.env['XDG_DATA_HOME'] +const originalLocalAppData = process.env['LOCALAPPDATA'] + +describe('desk paths', () => { + afterEach(() => { + vi.clearAllMocks() + + if (originalXdgDataHome == null) delete process.env['XDG_DATA_HOME'] + else process.env['XDG_DATA_HOME'] = originalXdgDataHome + if (originalLocalAppData == null) delete process.env['LOCALAPPDATA'] + else process.env['LOCALAPPDATA'] = originalLocalAppData + }) + + it('uses linux data paths outside WSL', () => { + delete process.env['XDG_DATA_HOME'] + resolveRuntimeEnvironmentMock.mockReturnValue({ + platform: 'linux', + isWsl: false, + nativeHomeDir: '/home/alpha', + effectiveHomeDir: '/home/alpha', + globalConfigCandidates: [], + windowsUsersRoot: '/mnt/c/Users', + expandedEnv: {} + }) + + expect(getPlatformFixedDir().replaceAll('\\', '/')).toBe(path.join('/home/alpha', '.local', 'share').replaceAll('\\', '/')) + }) + + it('uses Windows fixed-dir semantics when WSL targets the host home', () => { + process.env['LOCALAPPDATA'] = 'C:\\Users\\alpha\\AppData\\Local' + resolveRuntimeEnvironmentMock.mockReturnValue({ + platform: 'linux', + isWsl: true, + nativeHomeDir: '/home/alpha', + effectiveHomeDir: '/mnt/c/Users/alpha', + globalConfigCandidates: ['/mnt/c/Users/alpha/.aindex/.tnmsc.json'], + selectedGlobalConfigPath: '/mnt/c/Users/alpha/.aindex/.tnmsc.json', + wslHostHomeDir: '/mnt/c/Users/alpha', + windowsUsersRoot: '/mnt/c/Users', + expandedEnv: {} + }) + resolveUserPathMock.mockReturnValue('/mnt/c/Users/alpha/AppData/Local') + + expect(getPlatformFixedDir()).toBe('/mnt/c/Users/alpha/AppData/Local') + expect(resolveUserPathMock).toHaveBeenCalledWith('C:\\Users\\alpha\\AppData\\Local') + }) +}) diff --git a/cli/src/plugins/desk-paths.ts b/cli/src/plugins/desk-paths.ts index e8dc920a..84f98fe1 100644 --- a/cli/src/plugins/desk-paths.ts +++ b/cli/src/plugins/desk-paths.ts @@ -1,10 +1,10 @@ import type {Buffer} from 'node:buffer' import type {LoggerDiagnosticInput} from './plugin-core' import * as fs from 'node:fs' -import os from 'node:os' import path from 'node:path' import process from 'node:process' import {buildFileOperationDiagnostic} from '@/diagnostics' +import {resolveRuntimeEnvironment, resolveUserPath} from '@/runtime-environment' /** * Represents a fixed set of platform directory identifiers. @@ -32,7 +32,7 @@ type PlatformFixedDir = 'win32' | 'darwin' | 'linux' */ function getLinuxDataDir(homeDir: string): string { const xdgDataHome = process.env['XDG_DATA_HOME'] - if (typeof xdgDataHome === 'string' && xdgDataHome.trim().length > 0) return xdgDataHome + if (typeof xdgDataHome === 'string' && xdgDataHome.trim().length > 0) return resolveUserPath(xdgDataHome) return path.join(homeDir, '.local', 'share') } @@ -44,10 +44,11 @@ function getLinuxDataDir(homeDir: string): string { * @throws {Error} If the platform is unsupported. */ export function getPlatformFixedDir(): string { - const platform = process.platform as PlatformFixedDir - const homeDir = os.homedir() + const runtimeEnvironment = resolveRuntimeEnvironment() + const platform = (runtimeEnvironment.isWsl ? 'win32' : runtimeEnvironment.platform) as PlatformFixedDir + const homeDir = runtimeEnvironment.effectiveHomeDir - if (platform === 'win32') return process.env['LOCALAPPDATA'] ?? path.join(homeDir, 'AppData', 'Local') + if (platform === 'win32') return resolveUserPath(process.env['LOCALAPPDATA'] ?? path.join(homeDir, 'AppData', 'Local')) if (platform === 'darwin') return path.join(homeDir, 'Library', 'Application Support') if (platform === 'linux') return getLinuxDataDir(homeDir) diff --git a/cli/src/plugins/plugin-core/AbstractOutputPlugin.ts b/cli/src/plugins/plugin-core/AbstractOutputPlugin.ts index 4080230d..6100c915 100644 --- a/cli/src/plugins/plugin-core/AbstractOutputPlugin.ts +++ b/cli/src/plugins/plugin-core/AbstractOutputPlugin.ts @@ -1,15 +1,15 @@ import type {BuildPromptTomlArtifactOptions} from '@truenine/md-compiler' import type {ToolPresetName} from './GlobalScopeCollector' import type {RegistryWriter} from './RegistryWriter' -import type {CommandPrompt, CommandSeriesPluginOverride, ILogger, OutputCleanContext, OutputCleanupDeclarations, OutputCleanupPathDeclaration, OutputCleanupScope, OutputDeclarationScope, OutputFileDeclaration, OutputPlugin, OutputPluginCapabilities, OutputPluginContext, OutputScopeSelection, OutputScopeTopic, OutputTopicCapability, OutputWriteContext, Path, Project, ProjectConfig, RegistryData, RegistryOperationResult, RulePrompt, RuleScope, SkillPrompt, SubAgentPrompt} from './types' +import type {CommandPrompt, CommandSeriesPluginOverride, ILogger, OutputCleanContext, OutputCleanupDeclarations, OutputCleanupPathDeclaration, OutputCleanupScope, OutputDeclarationScope, OutputFileDeclaration, OutputPlugin, OutputPluginCapabilities, OutputPluginContext, OutputScopeSelection, OutputScopeTopic, OutputTopicCapability, OutputWriteContext, Path, Project, ProjectConfig, RegistryData, RegistryOperationResult, RulePrompt, RuleScope, SkillPrompt, SubAgentPrompt, WslMirrorFileDeclaration} from './types' import {Buffer} from 'node:buffer' -import * as os from 'node:os' import * as path from 'node:path' import process from 'node:process' import {buildPromptTomlArtifact, mdxToMd} from '@truenine/md-compiler' import {buildMarkdownWithFrontMatter, buildMarkdownWithRawFrontMatter} from '@truenine/md-compiler/markdown' import {buildConfigDiagnostic, diagnosticLines} from '@/diagnostics' +import {getEffectiveHomeDir} from '@/runtime-environment' import {AbstractPlugin} from './AbstractPlugin' import {FilePathKind, PluginKind} from './enums' import { @@ -197,6 +197,9 @@ export interface AbstractOutputPluginOptions { /** Cleanup configuration (declarative) */ cleanup?: OutputCleanupConfig + /** Host-home files that should be mirrored into configured WSL instances */ + wslMirrors?: readonly string[] + /** Explicit output capability matrix for scope override validation */ capabilities?: OutputPluginCapabilities @@ -290,6 +293,8 @@ export abstract class AbstractOutputPlugin extends AbstractPlugin implements Out protected readonly cleanupConfig: OutputCleanupConfig + protected readonly wslMirrorPaths: readonly string[] + protected readonly supportsBlankLineAfterFrontMatter: boolean private readonly registryWriterCache: Map> = new Map() @@ -317,6 +322,7 @@ export abstract class AbstractOutputPlugin extends AbstractPlugin implements Out sourceScopes: options?.rules?.sourceScopes ?? ['project', 'global'] } // Initialize rule output config with defaults this.cleanupConfig = options?.cleanup ?? {} + this.wslMirrorPaths = options?.wslMirrors ?? [] this.supportsBlankLineAfterFrontMatter = options?.supportsBlankLineAfterFrontMatter ?? true this.outputCapabilities = options?.capabilities != null @@ -538,7 +544,7 @@ export abstract class AbstractOutputPlugin extends AbstractPlugin implements Out } protected getHomeDir(): string { - return os.homedir() + return getEffectiveHomeDir() } protected joinPath(...segments: string[]): string { @@ -1113,6 +1119,11 @@ export abstract class AbstractOutputPlugin extends AbstractPlugin implements Out } } + async declareWslMirrorFiles(ctx: OutputWriteContext): Promise { + void ctx + return this.wslMirrorPaths.map(sourcePath => ({sourcePath})) + } + async convertContent( declaration: OutputFileDeclaration, ctx: OutputWriteContext diff --git a/cli/src/plugins/plugin-core/ConfigTypes.schema.ts b/cli/src/plugins/plugin-core/ConfigTypes.schema.ts index a48579cd..03fd772b 100644 --- a/cli/src/plugins/plugin-core/ConfigTypes.schema.ts +++ b/cli/src/plugins/plugin-core/ConfigTypes.schema.ts @@ -82,6 +82,13 @@ export const ZCleanupProtectionRule = z.object({ }) export const ZCleanupProtectionOptions = z.object({rules: z.array(ZCleanupProtectionRule).optional()}) +export const ZStringOrStringArray = z.union([z.string(), z.array(z.string()).min(1)]) +export const ZWindowsWsl2Options = z.object({ + instances: ZStringOrStringArray.optional() +}) +export const ZWindowsOptions = z.object({ + wsl2: ZWindowsWsl2Options.optional() +}) /** * Zod schema for user profile information. @@ -105,6 +112,7 @@ export const ZUserConfigFile = z.object({ outputScopes: ZOutputScopeOptions.optional(), frontMatter: ZFrontMatterOptions.optional(), cleanupProtection: ZCleanupProtectionOptions.optional(), + windows: ZWindowsOptions.optional(), profile: ZUserProfile.optional() }) @@ -152,6 +160,9 @@ export type ProtectionMode = z.infer export type ProtectionRuleMatcher = z.infer export type CleanupProtectionRule = z.infer export type CleanupProtectionOptions = z.infer +export type StringOrStringArray = z.infer +export type WindowsWsl2Options = z.infer +export type WindowsOptions = z.infer export type UserConfigFile = z.infer export type McpProjectConfig = z.infer export type TypeSeriesConfig = z.infer diff --git a/cli/src/plugins/plugin-core/GlobalScopeCollector.ts b/cli/src/plugins/plugin-core/GlobalScopeCollector.ts index 45042e26..2e6157b8 100644 --- a/cli/src/plugins/plugin-core/GlobalScopeCollector.ts +++ b/cli/src/plugins/plugin-core/GlobalScopeCollector.ts @@ -4,6 +4,7 @@ import type {UserConfigFile} from './types' import * as os from 'node:os' import process from 'node:process' import {OsKind, ShellKind, ToolPresets} from '@truenine/md-compiler/globals' +import {getEffectiveHomeDir} from '@/runtime-environment' /** * Tool preset names supported by GlobalScopeCollector @@ -49,7 +50,7 @@ export class GlobalScopeCollector { platform, arch: os.arch(), hostname: os.hostname(), - homedir: os.homedir(), + homedir: getEffectiveHomeDir(), tmpdir: os.tmpdir(), type: os.type(), release: os.release(), diff --git a/cli/src/plugins/plugin-core/RegistryWriter.ts b/cli/src/plugins/plugin-core/RegistryWriter.ts index 1b4a334f..4e74cd69 100644 --- a/cli/src/plugins/plugin-core/RegistryWriter.ts +++ b/cli/src/plugins/plugin-core/RegistryWriter.ts @@ -10,7 +10,6 @@ import type {ILogger, RegistryData, RegistryOperationResult} from './types' import * as fs from 'node:fs' -import * as os from 'node:os' import * as path from 'node:path' import {createLogger} from '@truenine/logger' import { @@ -18,6 +17,7 @@ import { buildFileOperationDiagnostic, diagnosticLines } from '@/diagnostics' +import {resolveUserPath} from '@/runtime-environment' /** * Abstract base class for registry configuration writers. @@ -42,7 +42,7 @@ export abstract class RegistryWriter< } protected resolvePath(p: string): string { - if (p.startsWith('~')) return path.join(os.homedir(), p.slice(1)) + if (p.startsWith('~')) return resolveUserPath(p) return path.resolve(p) } diff --git a/cli/src/plugins/plugin-core/plugin.ts b/cli/src/plugins/plugin-core/plugin.ts index 4c287fbf..d4def91e 100644 --- a/cli/src/plugins/plugin-core/plugin.ts +++ b/cli/src/plugins/plugin-core/plugin.ts @@ -8,7 +8,8 @@ import type { OutputScopeOptions, OutputScopeSelection, PluginOutputScopeTopics, - ProtectionMode + ProtectionMode, + WindowsOptions } from './ConfigTypes.schema' import type {PluginKind} from './enums' import type { @@ -100,6 +101,16 @@ export interface OutputWriteContext extends OutputPluginContext { readonly registeredPluginNames?: readonly string[] } +/** + * Declarative host-home file that should be mirrored into configured WSL instances. + */ +export interface WslMirrorFileDeclaration { + /** Source path on the Windows host, typically under ~ */ + readonly sourcePath: string + /** Optional label for diagnostics/logging */ + readonly label?: string +} + /** * Result of a single write operation */ @@ -219,6 +230,8 @@ export interface OutputPlugin extends Plugin { convertContent: (declaration: OutputFileDeclaration, ctx: OutputWriteContext) => Awaitable declareCleanupPaths?: (ctx: OutputCleanContext) => Awaitable + + declareWslMirrorFiles?: (ctx: OutputWriteContext) => Awaitable } /** @@ -530,6 +543,8 @@ export interface PluginOptions { readonly cleanupProtection?: CleanupProtectionOptions + readonly windows?: WindowsOptions + plugins?: readonly (InputCapability | OutputPlugin)[] logLevel?: 'trace' | 'debug' | 'info' | 'warn' | 'error' } diff --git a/cli/src/prompts.ts b/cli/src/prompts.ts index 2e27eb69..554b80b2 100644 --- a/cli/src/prompts.ts +++ b/cli/src/prompts.ts @@ -1,12 +1,12 @@ import type {PluginOptions, YAMLFrontMatter} from '@/plugins/plugin-core' import * as fs from 'node:fs' -import * as os from 'node:os' import * as path from 'node:path' import {parseMarkdown} from '@truenine/md-compiler/markdown' import glob from 'fast-glob' import {mergeConfig, userConfigToPluginOptions} from './config' import {getConfigLoader} from './ConfigLoader' import {PathPlaceholders} from './plugins/plugin-core' +import {resolveUserPath} from './runtime-environment' export type ManagedPromptKind = | 'global-memory' @@ -144,11 +144,9 @@ function isSingleSegmentIdentifier(value: string): boolean { function resolveConfiguredPath(rawPath: string, workspaceDir: string): string { let resolved = rawPath - if (resolved.startsWith(PathPlaceholders.USER_HOME)) resolved = resolved.replace(PathPlaceholders.USER_HOME, os.homedir()) - if (resolved.includes(PathPlaceholders.WORKSPACE)) resolved = resolved.replace(PathPlaceholders.WORKSPACE, workspaceDir) - return path.normalize(resolved) + return resolveUserPath(resolved) } function resolvePromptEnvironment(options: PromptServiceOptions = {}): ResolvedPromptEnvironment { diff --git a/cli/src/runtime-environment.test.ts b/cli/src/runtime-environment.test.ts new file mode 100644 index 00000000..ad577c0c --- /dev/null +++ b/cli/src/runtime-environment.test.ts @@ -0,0 +1,149 @@ +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import {afterEach, describe, expect, it} from 'vitest' +import { + getRequiredGlobalConfigPath, + resolveRuntimeEnvironment, + resolveUserPath +} from './runtime-environment' + +describe('runtime environment', () => { + let tempDir: string | undefined + + afterEach(() => { + if (tempDir != null) fs.rmSync(tempDir, {recursive: true, force: true}) + tempDir = void 0 + }) + + it('uses the native Windows home config path when running on Windows', () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tnmsc-win-runtime-')) + const windowsHomeDir = path.join(tempDir, 'WindowsHome') + const configPath = path.join(windowsHomeDir, '.aindex', '.tnmsc.json') + + fs.mkdirSync(path.dirname(configPath), {recursive: true}) + fs.writeFileSync(configPath, '{}\n', 'utf8') + + const runtimeEnvironment = resolveRuntimeEnvironment({ + fs, + platform: 'win32', + env: { + USERPROFILE: windowsHomeDir + }, + homedir: windowsHomeDir + }) + + expect(runtimeEnvironment.isWsl).toBe(false) + expect(runtimeEnvironment.selectedGlobalConfigPath).toBeUndefined() + expect(runtimeEnvironment.effectiveHomeDir).toBe(windowsHomeDir) + expect(getRequiredGlobalConfigPath({ + fs, + platform: 'win32', + env: { + USERPROFILE: windowsHomeDir + }, + homedir: windowsHomeDir + })).toBe(configPath) + expect(resolveUserPath('~/.codex/config.toml', { + fs, + platform: 'win32', + env: { + USERPROFILE: windowsHomeDir + }, + homedir: windowsHomeDir + })).toBe(path.join(windowsHomeDir, '.codex', 'config.toml')) + }) + + it('selects the host config path that matches the current Windows profile in WSL', () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tnmsc-wsl-runtime-')) + const usersRoot = path.join(tempDir, 'Users') + const alphaConfigPath = path.join(usersRoot, 'alpha', '.aindex', '.tnmsc.json') + const bravoConfigPath = path.join(usersRoot, 'bravo', '.aindex', '.tnmsc.json') + + fs.mkdirSync(path.dirname(alphaConfigPath), {recursive: true}) + fs.mkdirSync(path.dirname(bravoConfigPath), {recursive: true}) + fs.writeFileSync(alphaConfigPath, '{}\n', 'utf8') + fs.writeFileSync(bravoConfigPath, '{}\n', 'utf8') + + const runtimeEnvironment = resolveRuntimeEnvironment({ + fs, + platform: 'linux', + env: { + WSL_DISTRO_NAME: 'Ubuntu', + USERPROFILE: path.join(usersRoot, 'bravo') + }, + homedir: '/home/linux-user', + windowsUsersRoot: usersRoot + }) + + expect(runtimeEnvironment.isWsl).toBe(true) + expect(runtimeEnvironment.selectedGlobalConfigPath).toBe(bravoConfigPath) + expect(runtimeEnvironment.effectiveHomeDir).toBe(path.join(usersRoot, 'bravo').replaceAll('\\', '/')) + expect(getRequiredGlobalConfigPath({ + fs, + platform: 'linux', + env: { + WSL_DISTRO_NAME: 'Ubuntu', + USERPROFILE: path.join(usersRoot, 'bravo') + }, + homedir: '/home/linux-user', + windowsUsersRoot: usersRoot + })).toBe(bravoConfigPath) + }) + + it('fails when the discovered config belongs to another Windows profile', () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tnmsc-wsl-runtime-mismatch-')) + const usersRoot = path.join(tempDir, 'Users') + const alphaConfigPath = path.join(usersRoot, 'alpha', '.aindex', '.tnmsc.json') + + fs.mkdirSync(path.dirname(alphaConfigPath), {recursive: true}) + fs.writeFileSync(alphaConfigPath, '{}\n', 'utf8') + + expect(() => getRequiredGlobalConfigPath({ + fs, + platform: 'linux', + env: { + WSL_DISTRO_NAME: 'Ubuntu', + USERPROFILE: path.join(usersRoot, 'bravo') + }, + homedir: '/home/linux-user', + windowsUsersRoot: usersRoot + })).toThrow('current Windows user') + }) + + it('fails when WSL is active but no host config exists', () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tnmsc-wsl-runtime-missing-')) + + expect(() => getRequiredGlobalConfigPath({ + fs, + platform: 'linux', + env: {WSL_DISTRO_NAME: 'Ubuntu'}, + homedir: '/home/linux-user', + windowsUsersRoot: path.join(tempDir, 'Users') + })).toThrow('WSL host config file not found') + }) + + it('maps host-home, windows drive, and environment-variable paths for WSL workloads', () => { + const runtimeEnvironment = { + platform: 'linux', + isWsl: true, + nativeHomeDir: '/home/linux-user', + effectiveHomeDir: '/mnt/c/Users/alpha', + globalConfigCandidates: ['/mnt/c/Users/alpha/.aindex/.tnmsc.json'], + selectedGlobalConfigPath: '/mnt/c/Users/alpha/.aindex/.tnmsc.json', + wslHostHomeDir: '/mnt/c/Users/alpha', + windowsUsersRoot: '/mnt/c/Users', + expandedEnv: { + HOME: '/mnt/c/Users/alpha', + USERPROFILE: '/mnt/c/Users/alpha', + HOMEDRIVE: 'C:', + HOMEPATH: '\\Users\\alpha' + } + } as const + + expect(resolveUserPath('~/workspace\\foo', runtimeEnvironment)).toBe('/mnt/c/Users/alpha/workspace/foo') + expect(resolveUserPath('C:\\Work\\Repo', runtimeEnvironment)).toBe('/mnt/c/Work/Repo') + expect(resolveUserPath('%USERPROFILE%\\workspace\\bar', runtimeEnvironment)).toBe('/mnt/c/Users/alpha/workspace/bar') + expect(resolveUserPath('$HOME/workspace/baz', runtimeEnvironment)).toBe('/mnt/c/Users/alpha/workspace/baz') + }) +}) diff --git a/cli/src/runtime-environment.ts b/cli/src/runtime-environment.ts new file mode 100644 index 00000000..7c2db229 --- /dev/null +++ b/cli/src/runtime-environment.ts @@ -0,0 +1,361 @@ +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import process from 'node:process' + +export const DEFAULT_WSL_WINDOWS_USERS_ROOT = '/mnt/c/Users' +export const DEFAULT_GLOBAL_CONFIG_DIR = '.aindex' +export const DEFAULT_GLOBAL_CONFIG_FILE_NAME = '.tnmsc.json' + +const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/u +const PERCENT_ENV_PATTERN = /%([^%]+)%/gu +const BRACED_ENV_PATTERN = /\$\{([A-Za-z_]\w*)\}/gu +const SHELL_ENV_PATTERN = /\$([A-Za-z_]\w*)/gu + +type RuntimeFs = Pick + +export interface RuntimeEnvironmentDependencies { + readonly fs?: RuntimeFs + readonly env?: NodeJS.ProcessEnv + readonly platform?: NodeJS.Platform + readonly homedir?: string + readonly release?: string + readonly windowsUsersRoot?: string +} + +export interface RuntimeEnvironmentContext { + readonly platform: NodeJS.Platform + readonly isWsl: boolean + readonly nativeHomeDir: string + readonly effectiveHomeDir: string + readonly globalConfigCandidates: readonly string[] + readonly selectedGlobalConfigPath?: string + readonly wslHostHomeDir?: string + readonly windowsUsersRoot: string + readonly expandedEnv: Readonly> +} + +function isRuntimeEnvironmentContext( + value: RuntimeEnvironmentDependencies | RuntimeEnvironmentContext | undefined +): value is RuntimeEnvironmentContext { + return value != null + && 'effectiveHomeDir' in value + && 'expandedEnv' in value +} + +function getFs(dependencies?: RuntimeEnvironmentDependencies): RuntimeFs { + return dependencies?.fs ?? fs +} + +function getPlatform(dependencies?: RuntimeEnvironmentDependencies): NodeJS.Platform { + return dependencies?.platform ?? process.platform +} + +function getRelease(dependencies?: RuntimeEnvironmentDependencies): string { + return dependencies?.release ?? os.release() +} + +function getNativeHomeDir(dependencies?: RuntimeEnvironmentDependencies): string { + return dependencies?.homedir ?? os.homedir() +} + +function getEnv(dependencies?: RuntimeEnvironmentDependencies): NodeJS.ProcessEnv { + return dependencies?.env ?? process.env +} + +function getWindowsUsersRoot(dependencies?: RuntimeEnvironmentDependencies): string { + return dependencies?.windowsUsersRoot ?? DEFAULT_WSL_WINDOWS_USERS_ROOT +} + +function normalizePosixLikePath(rawPath: string): string { + return path.posix.normalize(rawPath.replaceAll('\\', '/')) +} + +function isSameOrChildPath(candidatePath: string, parentPath: string): boolean { + const normalizedCandidate = normalizePosixLikePath(candidatePath) + const normalizedParent = normalizePosixLikePath(parentPath) + + if (normalizedCandidate === normalizedParent) return true + return normalizedCandidate.startsWith(`${normalizedParent}/`) +} + +function resolveWslHostHomeCandidate( + rawPath: string | undefined, + usersRoot: string +): string | undefined { + if (typeof rawPath !== 'string') return void 0 + + const trimmedPath = rawPath.trim() + if (trimmedPath.length === 0) return void 0 + + const candidatePaths = [ + convertWindowsPathToWsl(trimmedPath), + normalizePosixLikePath(trimmedPath) + ] + + for (const candidatePath of candidatePaths) { + if (candidatePath == null) continue + if (isSameOrChildPath(candidatePath, usersRoot)) return normalizePosixLikePath(candidatePath) + } + + return void 0 +} + +function getPreferredWslHostHomeDirs( + dependencies?: RuntimeEnvironmentDependencies +): string[] { + const env = getEnv(dependencies) + const usersRoot = normalizePosixLikePath(getWindowsUsersRoot(dependencies)) + const homeDrive = env['HOMEDRIVE'] + const homePath = env['HOMEPATH'] + const preferredHomeDirs = [ + resolveWslHostHomeCandidate(env['USERPROFILE'], usersRoot), + typeof homeDrive === 'string' && homeDrive.length > 0 && typeof homePath === 'string' && homePath.length > 0 + ? resolveWslHostHomeCandidate(`${homeDrive}${homePath}`, usersRoot) + : void 0, + resolveWslHostHomeCandidate(env['HOME'], usersRoot) + ] + + return [...new Set(preferredHomeDirs.filter((candidate): candidate is string => candidate != null))] +} + +function getWslHostHomeDirForConfigPath(configPath: string): string { + const normalizedConfigPath = normalizePosixLikePath(configPath) + return path.posix.dirname(path.posix.dirname(normalizedConfigPath)) +} + +function selectWslHostGlobalConfigPath( + globalConfigCandidates: readonly string[], + dependencies?: RuntimeEnvironmentDependencies +): string | undefined { + const preferredHomeDirs = getPreferredWslHostHomeDirs(dependencies) + + if (preferredHomeDirs.length <= 0) return globalConfigCandidates.length === 1 ? globalConfigCandidates[0] : void 0 + + for (const preferredHomeDir of preferredHomeDirs) { + const matchedCandidate = globalConfigCandidates.find(candidatePath => + getWslHostHomeDirForConfigPath(candidatePath) === preferredHomeDir) + if (matchedCandidate != null) return matchedCandidate + } + return void 0 +} + +function isDirectory(fsImpl: RuntimeFs, targetPath: string): boolean { + try { + return fsImpl.statSync(targetPath).isDirectory() + } + catch { + return false + } +} + +function isFile(fsImpl: RuntimeFs, targetPath: string): boolean { + try { + return fsImpl.statSync(targetPath).isFile() + } + catch { + return false + } +} + +function getPathModule(platform: NodeJS.Platform): typeof path.posix | typeof path.win32 { + return platform === 'win32' ? path.win32 : path.posix +} + +function buildExpandedEnv( + rawEnv: NodeJS.ProcessEnv, + nativeHomeDir: string, + effectiveHomeDir: string +): Readonly> { + const expandedEnv: Record = {} + + for (const [key, value] of Object.entries(rawEnv)) { + if (typeof value === 'string') expandedEnv[key] = value + } + + if (effectiveHomeDir === nativeHomeDir) return expandedEnv + + expandedEnv['HOME'] = effectiveHomeDir + expandedEnv['USERPROFILE'] = effectiveHomeDir + const hostHomeMatch = /^\/mnt\/([a-zA-Z])\/(.+)$/u.exec(effectiveHomeDir) + if (hostHomeMatch == null) return expandedEnv + + const driveLetter = hostHomeMatch[1] + const relativePath = hostHomeMatch[2] + if (driveLetter == null || relativePath == null) return expandedEnv + expandedEnv['HOMEDRIVE'] = `${driveLetter.toUpperCase()}:` + expandedEnv['HOMEPATH'] = `\\${relativePath.replaceAll('/', '\\')}` + return expandedEnv +} + +function expandEnvironmentVariables( + rawPath: string, + environment: Readonly> +): string { + const replaceValue = (match: string, key: string): string => environment[key] ?? match + + return rawPath + .replaceAll(PERCENT_ENV_PATTERN, replaceValue) + .replaceAll(BRACED_ENV_PATTERN, replaceValue) + .replaceAll(SHELL_ENV_PATTERN, replaceValue) +} + +function expandHomeDirectory( + rawPath: string, + homeDir: string, + platform: NodeJS.Platform +): string { + if (rawPath === '~') return homeDir + if (!(rawPath.startsWith('~/') || rawPath.startsWith('~\\'))) return rawPath + + const pathModule = getPathModule(platform) + const normalizedSuffix = platform === 'win32' + ? rawPath.slice(2).replaceAll('/', '\\') + : rawPath.slice(2).replaceAll('\\', '/') + + return pathModule.resolve(homeDir, normalizedSuffix) +} + +function convertWindowsPathToWsl(rawPath: string): string | undefined { + if (!WINDOWS_DRIVE_PATH_PATTERN.test(rawPath)) return void 0 + + const driveLetter = rawPath.slice(0, 1).toLowerCase() + const relativePath = rawPath + .slice(2) + .replaceAll('\\', '/') + .replace(/^\/+/u, '') + + const basePath = `/mnt/${driveLetter}` + if (relativePath.length === 0) return basePath + return path.posix.join(basePath, relativePath) +} + +function normalizeResolvedPath(rawPath: string, platform: NodeJS.Platform): string { + if (platform === 'win32') return path.win32.normalize(rawPath.replaceAll('/', '\\')) + return path.posix.normalize(rawPath) +} + +export function isWslRuntime( + dependencies?: RuntimeEnvironmentDependencies +): boolean { + if (getPlatform(dependencies) !== 'linux') return false + + const env = getEnv(dependencies) + if (typeof env['WSL_DISTRO_NAME'] === 'string' && env['WSL_DISTRO_NAME'].length > 0) return true + if (typeof env['WSL_INTEROP'] === 'string' && env['WSL_INTEROP'].length > 0) return true + + return getRelease(dependencies).toLowerCase().includes('microsoft') +} + +export function findWslHostGlobalConfigPaths( + dependencies?: RuntimeEnvironmentDependencies +): string[] { + const fsImpl = getFs(dependencies) + const usersRoot = getWindowsUsersRoot(dependencies) + + if (!isDirectory(fsImpl, usersRoot)) return [] + + try { + const dirEntries = fsImpl.readdirSync(usersRoot, {withFileTypes: true}) + const candidates = dirEntries + .filter(dirEntry => dirEntry.isDirectory()) + .map(dirEntry => path.join(usersRoot, dirEntry.name, DEFAULT_GLOBAL_CONFIG_DIR, DEFAULT_GLOBAL_CONFIG_FILE_NAME)) + .filter(candidatePath => fsImpl.existsSync(candidatePath) && isFile(fsImpl, candidatePath)) + + candidates.sort((a, b) => a.localeCompare(b)) + return candidates + } + catch { + return [] + } +} + +export function resolveRuntimeEnvironment( + dependencies?: RuntimeEnvironmentDependencies +): RuntimeEnvironmentContext { + const platform = getPlatform(dependencies) + const nativeHomeDir = getNativeHomeDir(dependencies) + const wslRuntime = isWslRuntime(dependencies) + const globalConfigCandidates = wslRuntime ? findWslHostGlobalConfigPaths(dependencies) : [] + const selectedGlobalConfigPath = wslRuntime + ? selectWslHostGlobalConfigPath(globalConfigCandidates, dependencies) + : void 0 + const effectiveHomeDir = selectedGlobalConfigPath != null + ? getWslHostHomeDirForConfigPath(selectedGlobalConfigPath) + : nativeHomeDir + + return { + platform, + isWsl: wslRuntime, + nativeHomeDir, + effectiveHomeDir, + globalConfigCandidates, + ...selectedGlobalConfigPath != null && {selectedGlobalConfigPath}, + ...selectedGlobalConfigPath != null && {wslHostHomeDir: effectiveHomeDir}, + windowsUsersRoot: getWindowsUsersRoot(dependencies), + expandedEnv: buildExpandedEnv(getEnv(dependencies), nativeHomeDir, effectiveHomeDir) + } +} + +export function getEffectiveHomeDir( + dependencies?: RuntimeEnvironmentDependencies +): string { + return resolveRuntimeEnvironment(dependencies).effectiveHomeDir +} + +export function getGlobalConfigPath( + dependencies?: RuntimeEnvironmentDependencies +): string { + const runtimeEnvironment = resolveRuntimeEnvironment(dependencies) + if (runtimeEnvironment.selectedGlobalConfigPath != null) return runtimeEnvironment.selectedGlobalConfigPath + + return path.join( + runtimeEnvironment.effectiveHomeDir, + DEFAULT_GLOBAL_CONFIG_DIR, + DEFAULT_GLOBAL_CONFIG_FILE_NAME + ) +} + +export function getRequiredGlobalConfigPath( + dependencies?: RuntimeEnvironmentDependencies +): string { + const runtimeEnvironment = resolveRuntimeEnvironment(dependencies) + + if (!runtimeEnvironment.isWsl || runtimeEnvironment.selectedGlobalConfigPath != null) { + return getGlobalConfigPath(dependencies) + } + + const configLookupPattern = `"${runtimeEnvironment.windowsUsersRoot}/*/${DEFAULT_GLOBAL_CONFIG_DIR}/${DEFAULT_GLOBAL_CONFIG_FILE_NAME}"` + if (runtimeEnvironment.globalConfigCandidates.length === 0) { + throw new Error(`WSL host config file not found under ${configLookupPattern}.`) + } + if (getPreferredWslHostHomeDirs(dependencies).length > 0) { + throw new Error(`WSL host config file for the current Windows user was not found under ${configLookupPattern}.`) + } + throw new Error(`WSL host config file could not be matched to the current Windows user under ${configLookupPattern}.`) +} + +export function resolveUserPath( + rawPath: string, + dependenciesOrContext?: RuntimeEnvironmentDependencies | RuntimeEnvironmentContext +): string { + const runtimeEnvironment = isRuntimeEnvironmentContext(dependenciesOrContext) + ? dependenciesOrContext + : resolveRuntimeEnvironment(dependenciesOrContext) + + let resolvedPath = expandEnvironmentVariables(rawPath, runtimeEnvironment.expandedEnv) + resolvedPath = expandHomeDirectory(resolvedPath, runtimeEnvironment.effectiveHomeDir, runtimeEnvironment.platform) + + if (!runtimeEnvironment.isWsl) return normalizeResolvedPath(resolvedPath, runtimeEnvironment.platform) + + const convertedWindowsPath = convertWindowsPathToWsl(resolvedPath) + if (convertedWindowsPath != null) resolvedPath = convertedWindowsPath + else if ( + resolvedPath.startsWith(runtimeEnvironment.effectiveHomeDir) + || resolvedPath.startsWith('/mnt/') + || resolvedPath.startsWith('/') + ) { + resolvedPath = resolvedPath.replaceAll('\\', '/') + } + return normalizeResolvedPath(resolvedPath, runtimeEnvironment.platform) +} diff --git a/cli/src/wsl-mirror-sync.test.ts b/cli/src/wsl-mirror-sync.test.ts new file mode 100644 index 00000000..d4af7962 --- /dev/null +++ b/cli/src/wsl-mirror-sync.test.ts @@ -0,0 +1,588 @@ +import type {ILogger, OutputFileDeclaration, OutputPlugin, OutputWriteContext} from './plugins/plugin-core' +import {Buffer} from 'node:buffer' +import * as path from 'node:path' +import {describe, expect, it, vi} from 'vitest' +import {PluginKind} from './plugins/plugin-core' +import {syncWindowsConfigIntoWsl} from './wsl-mirror-sync' + +class MemoryMirrorFs { + readonly files = new Map() + + readonly directories = new Set() + + private normalizePath(targetPath: string): string { + if (targetPath.includes('\\') || /^[A-Za-z]:[\\/]/u.test(targetPath)) { + return path.win32.normalize(targetPath) + } + + return path.posix.normalize(targetPath) + } + + private getPathModule(targetPath: string): typeof path.win32 | typeof path.posix { + if (targetPath.includes('\\') || /^[A-Za-z]:[\\/]/u.test(targetPath)) { + return path.win32 + } + + return path.posix + } + + existsSync(targetPath: string): boolean { + const normalizedPath = this.normalizePath(targetPath) + return this.files.has(normalizedPath) || this.directories.has(normalizedPath) + } + + mkdirSync(targetPath: string, options?: {recursive?: boolean}): void { + const pathModule = this.getPathModule(targetPath) + const normalizedPath = pathModule.normalize(targetPath) + + if (options?.recursive === true) { + let currentPath = normalizedPath + while (currentPath.length > 0 && !this.directories.has(currentPath)) { + this.directories.add(currentPath) + const parentPath = pathModule.dirname(currentPath) + if (parentPath === currentPath) break + currentPath = parentPath + } + return + } + + this.directories.add(normalizedPath) + } + + readFileSync(targetPath: string): Buffer { + const normalizedPath = this.normalizePath(targetPath) + const content = this.files.get(normalizedPath) + if (content == null) throw new Error(`ENOENT: ${normalizedPath}`) + return Buffer.from(content) + } + + writeFileSync(targetPath: string, data: string | NodeJS.ArrayBufferView): void { + const pathModule = this.getPathModule(targetPath) + const normalizedPath = pathModule.normalize(targetPath) + this.directories.add(pathModule.dirname(normalizedPath)) + + if (typeof data === 'string') { + this.files.set(normalizedPath, Buffer.from(data, 'utf8')) + return + } + + this.files.set(normalizedPath, Buffer.from(data.buffer, data.byteOffset, data.byteLength)) + } + + seedDirectory(targetPath: string): void { + this.directories.add(this.normalizePath(targetPath)) + } + + seedFile(targetPath: string, content: string): void { + const pathModule = this.getPathModule(targetPath) + const normalizedPath = pathModule.normalize(targetPath) + this.directories.add(pathModule.dirname(normalizedPath)) + this.files.set(normalizedPath, Buffer.from(content, 'utf8')) + } +} + +interface RecordedLogger extends ILogger { + readonly infoMessages: string[] +} + +function createLogger(): RecordedLogger { + const infoMessages: string[] = [] + return { + trace: () => {}, + debug: () => {}, + info: (message: unknown) => { + infoMessages.push(String(message)) + }, + warn: () => {}, + error: () => {}, + fatal: () => {}, + infoMessages + } as RecordedLogger +} + +function createMirrorPlugin(sourcePaths: string | readonly string[] = []): OutputPlugin { + const normalizedPaths = Array.isArray(sourcePaths) ? sourcePaths : [sourcePaths] + + return { + type: PluginKind.Output, + name: 'MirrorPlugin', + log: createLogger(), + declarativeOutput: true, + outputCapabilities: {}, + async declareOutputFiles() { + return [] + }, + async convertContent() { + return '' + }, + async declareWslMirrorFiles() { + return normalizedPaths + .filter(sourcePath => sourcePath.length > 0) + .map(sourcePath => ({sourcePath})) + } + } +} + +function createWriteContext(instances?: string | string[], dryRun: boolean = false): OutputWriteContext { + return { + logger: createLogger(), + dryRun, + runtimeTargets: { + jetbrainsCodexDirs: [] + }, + pluginOptions: { + windows: { + wsl2: { + instances + } + } + }, + collectedOutputContext: { + workspace: { + directory: { + pathKind: 'absolute', + path: 'C:\\workspace', + getDirectoryName: () => 'workspace' + }, + projects: [] + } + } + } as unknown as OutputWriteContext +} + +function createPredeclaredOutputs( + plugin: OutputPlugin, + declarations: readonly OutputFileDeclaration[] +): ReadonlyMap { + return new Map([[plugin, declarations]]) +} + +function createGlobalOutputDeclaration( + targetPath: string +): OutputFileDeclaration { + return { + path: targetPath, + scope: 'global', + source: {kind: 'generated'} + } +} + +function createWslSpawnSyncMock( + homesByInstance: Readonly>, + discoveredInstances: readonly string[] = Object.keys(homesByInstance) +) { + return vi.fn((_command: string, args: readonly string[]) => { + if (args[0] === '--list' && args[1] === '--quiet') { + return { + status: 0, + stdout: Buffer.from(discoveredInstances.join('\r\n'), 'utf16le'), + stderr: Buffer.alloc(0) + } + } + + if (args[0] === '-d') { + const instance = args[1] + const linuxHomeDir = instance == null ? void 0 : homesByInstance[instance] + + if (linuxHomeDir == null) { + return { + status: 1, + stdout: Buffer.alloc(0), + stderr: Buffer.from(`distribution "${instance}" not found`, 'utf8') + } + } + + return { + status: 0, + stdout: Buffer.from(linuxHomeDir, 'utf8'), + stderr: Buffer.alloc(0) + } + } + + throw new Error(`Unexpected spawnSync args: ${JSON.stringify(args)}`) + }) +} + +function wasWslListCalled( + spawnSyncMock: ReturnType +): boolean { + return spawnSyncMock.mock.calls.some(([, args]) => Array.isArray(args) && args[0] === '--list' && args[1] === '--quiet') +} + +describe('wsl mirror sync', () => { + it('copies declared host config files into each resolved WSL home', async () => { + const memoryFs = new MemoryMirrorFs() + const hostHomeDir = 'C:\\Users\\alpha' + const sourcePath = path.win32.join(hostHomeDir, '.codex', 'config.toml') + const targetHomeDir = '\\\\wsl$\\Ubuntu\\home\\alpha' + const targetPath = path.win32.join(targetHomeDir, '.codex', 'config.toml') + + memoryFs.seedFile(sourcePath, 'codex = true\n') + memoryFs.seedDirectory(targetHomeDir) + + const spawnSyncMock = createWslSpawnSyncMock({Ubuntu: '/home/alpha'}) + + const result = await syncWindowsConfigIntoWsl( + [createMirrorPlugin('~/.codex/config.toml')], + createWriteContext('Ubuntu'), + { + fs: memoryFs, + spawnSync: spawnSyncMock as never, + platform: 'win32', + effectiveHomeDir: hostHomeDir + } + ) + + expect(result.errors).toEqual([]) + expect(result.warnings).toEqual([]) + expect(result.mirroredFiles).toBe(1) + expect(memoryFs.readFileSync(targetPath).toString('utf8')).toBe('codex = true\n') + expect(wasWslListCalled(spawnSyncMock)).toBe(false) + }) + + it('copies generated global outputs under the host home into each resolved WSL home', async () => { + const memoryFs = new MemoryMirrorFs() + const hostHomeDir = 'C:\\Users\\alpha' + const sourcePath = path.win32.join(hostHomeDir, '.codex', 'AGENTS.md') + const targetHomeDir = '\\\\wsl$\\Ubuntu\\home\\alpha' + const targetPath = path.win32.join(targetHomeDir, '.codex', 'AGENTS.md') + const plugin = createMirrorPlugin() + + memoryFs.seedFile(sourcePath, 'global prompt\n') + memoryFs.seedDirectory(targetHomeDir) + + const result = await syncWindowsConfigIntoWsl( + [plugin], + createWriteContext('Ubuntu'), + { + fs: memoryFs, + spawnSync: createWslSpawnSyncMock({Ubuntu: '/home/alpha'}) as never, + platform: 'win32', + effectiveHomeDir: hostHomeDir + }, + createPredeclaredOutputs(plugin, [createGlobalOutputDeclaration(sourcePath)]) + ) + + expect(result.errors).toEqual([]) + expect(result.warnings).toEqual([]) + expect(result.mirroredFiles).toBe(1) + expect(memoryFs.readFileSync(targetPath).toString('utf8')).toBe('global prompt\n') + }) + + it('excludes generated Windows app-data globals from WSL mirroring', async () => { + const memoryFs = new MemoryMirrorFs() + const hostHomeDir = 'C:\\Users\\alpha' + const sourcePath = path.win32.join(hostHomeDir, 'AppData', 'Local', 'JetBrains', 'IntelliJIdea2026.1', 'aia', 'codex', 'AGENTS.md') + const targetHomeDir = '\\\\wsl$\\Ubuntu\\home\\alpha' + const plugin = createMirrorPlugin() + + memoryFs.seedFile(sourcePath, 'jetbrains prompt\n') + memoryFs.seedDirectory(targetHomeDir) + + const result = await syncWindowsConfigIntoWsl( + [plugin], + createWriteContext('Ubuntu'), + { + fs: memoryFs, + spawnSync: createWslSpawnSyncMock({Ubuntu: '/home/alpha'}) as never, + platform: 'win32', + effectiveHomeDir: hostHomeDir + }, + createPredeclaredOutputs(plugin, [createGlobalOutputDeclaration(sourcePath)]) + ) + + expect(result).toEqual({ + mirroredFiles: 0, + warnings: [], + errors: [] + }) + }) + + it('unions generated globals with declared mirror files and dedupes by source path', async () => { + const memoryFs = new MemoryMirrorFs() + const hostHomeDir = 'C:\\Users\\alpha' + const configPath = path.win32.join(hostHomeDir, '.codex', 'config.toml') + const authPath = path.win32.join(hostHomeDir, '.codex', 'auth.json') + const promptPath = path.win32.join(hostHomeDir, '.codex', 'AGENTS.md') + const targetHomeDir = '\\\\wsl$\\Ubuntu\\home\\alpha' + const plugin = createMirrorPlugin(['~/.codex/config.toml', '~/.codex/auth.json']) + + memoryFs.seedFile(configPath, 'codex = true\n') + memoryFs.seedFile(authPath, '{"token":"abc"}\n') + memoryFs.seedFile(promptPath, 'global prompt\n') + memoryFs.seedDirectory(targetHomeDir) + + const result = await syncWindowsConfigIntoWsl( + [plugin], + createWriteContext('Ubuntu'), + { + fs: memoryFs, + spawnSync: createWslSpawnSyncMock({Ubuntu: '/home/alpha'}) as never, + platform: 'win32', + effectiveHomeDir: hostHomeDir + }, + createPredeclaredOutputs(plugin, [ + createGlobalOutputDeclaration(configPath), + createGlobalOutputDeclaration(promptPath) + ]) + ) + + expect(result.errors).toEqual([]) + expect(result.warnings).toEqual([]) + expect(result.mirroredFiles).toBe(3) + expect(memoryFs.readFileSync(path.win32.join(targetHomeDir, '.codex', 'config.toml')).toString('utf8')).toBe('codex = true\n') + expect(memoryFs.readFileSync(path.win32.join(targetHomeDir, '.codex', 'auth.json')).toString('utf8')).toBe('{"token":"abc"}\n') + expect(memoryFs.readFileSync(path.win32.join(targetHomeDir, '.codex', 'AGENTS.md')).toString('utf8')).toBe('global prompt\n') + }) + + it('auto-discovers WSL instances when none are configured', async () => { + const memoryFs = new MemoryMirrorFs() + const hostHomeDir = 'C:\\Users\\alpha' + const sourcePath = path.win32.join(hostHomeDir, '.codex', 'config.toml') + const spawnSyncMock = createWslSpawnSyncMock({ + Ubuntu: '/home/alpha', + Debian: '/home/beta' + }, ['Ubuntu', 'Debian']) + + memoryFs.seedFile(sourcePath, 'codex = true\n') + memoryFs.seedDirectory('\\\\wsl$\\Ubuntu\\home\\alpha') + memoryFs.seedDirectory('\\\\wsl$\\Debian\\home\\beta') + + const result = await syncWindowsConfigIntoWsl( + [createMirrorPlugin('~/.codex/config.toml')], + createWriteContext(), + { + fs: memoryFs, + spawnSync: spawnSyncMock as never, + platform: 'win32', + effectiveHomeDir: hostHomeDir + } + ) + + expect(result.errors).toEqual([]) + expect(result.warnings).toEqual([]) + expect(result.mirroredFiles).toBe(2) + expect(wasWslListCalled(spawnSyncMock)).toBe(true) + }) + + it('prefers configured WSL instances over auto-discovery', async () => { + const memoryFs = new MemoryMirrorFs() + const hostHomeDir = 'C:\\Users\\alpha' + const sourcePath = path.win32.join(hostHomeDir, '.codex', 'config.toml') + const spawnSyncMock = createWslSpawnSyncMock({ + Ubuntu: '/home/alpha', + Debian: '/home/beta' + }, ['Ubuntu', 'Debian']) + + memoryFs.seedFile(sourcePath, 'codex = true\n') + memoryFs.seedDirectory('\\\\wsl$\\Ubuntu\\home\\alpha') + + const result = await syncWindowsConfigIntoWsl( + [createMirrorPlugin('~/.codex/config.toml')], + createWriteContext('Ubuntu'), + { + fs: memoryFs, + spawnSync: spawnSyncMock as never, + platform: 'win32', + effectiveHomeDir: hostHomeDir + } + ) + + expect(result.errors).toEqual([]) + expect(result.warnings).toEqual([]) + expect(result.mirroredFiles).toBe(1) + expect(wasWslListCalled(spawnSyncMock)).toBe(false) + }) + + it('warns and skips when a declared host config file does not exist', async () => { + const memoryFs = new MemoryMirrorFs() + memoryFs.seedDirectory('\\\\wsl$\\Ubuntu\\home\\alpha') + + const result = await syncWindowsConfigIntoWsl( + [createMirrorPlugin('~/.claude/settings.json')], + createWriteContext('Ubuntu'), + { + fs: memoryFs, + spawnSync: createWslSpawnSyncMock({Ubuntu: '/home/alpha'}) as never, + platform: 'win32', + effectiveHomeDir: 'C:\\Users\\alpha' + } + ) + + expect(result.errors).toEqual([]) + expect(result.warnings).toEqual([ + 'Skipping missing WSL mirror source file: C:\\Users\\alpha\\.claude\\settings.json' + ]) + expect(result.mirroredFiles).toBe(0) + }) + + it('validates WSL instance probing before writing any mirrored files', async () => { + const memoryFs = new MemoryMirrorFs() + const hostHomeDir = 'C:\\Users\\alpha' + memoryFs.seedFile(path.win32.join(hostHomeDir, '.codex', 'auth.json'), '{"ok":true}\n') + + const result = await syncWindowsConfigIntoWsl( + [createMirrorPlugin('~/.codex/auth.json')], + createWriteContext('BrokenUbuntu'), + { + fs: memoryFs, + spawnSync: vi.fn(() => ({ + status: 1, + stdout: Buffer.alloc(0), + stderr: Buffer.from('distribution not found', 'utf8') + })) as never, + platform: 'win32', + effectiveHomeDir: hostHomeDir + } + ) + + expect(result.mirroredFiles).toBe(0) + expect(result.warnings).toEqual([]) + expect(result.errors).toEqual([ + 'Failed to probe WSL instance "BrokenUbuntu". distribution not found' + ]) + }) + + it('counts dry-run mirror operations without writing explicit mirror files', async () => { + const memoryFs = new MemoryMirrorFs() + const hostHomeDir = 'C:\\Users\\alpha' + const sourcePath = path.win32.join(hostHomeDir, '.claude', 'config.json') + const targetHomeDir = '\\\\wsl$\\Ubuntu\\home\\alpha' + const targetPath = path.win32.join(targetHomeDir, '.claude', 'config.json') + + memoryFs.seedFile(sourcePath, '{"theme":"dark"}\n') + memoryFs.seedDirectory(targetHomeDir) + + const result = await syncWindowsConfigIntoWsl( + [createMirrorPlugin('~/.claude/config.json')], + createWriteContext('Ubuntu', true), + { + fs: memoryFs, + spawnSync: createWslSpawnSyncMock({Ubuntu: '/home/alpha'}) as never, + platform: 'win32', + effectiveHomeDir: hostHomeDir + } + ) + + expect(result.errors).toEqual([]) + expect(result.mirroredFiles).toBe(1) + expect(memoryFs.existsSync(targetPath)).toBe(false) + }) + + it('counts generated outputs during dry-run even before the host file exists', async () => { + const memoryFs = new MemoryMirrorFs() + const hostHomeDir = 'C:\\Users\\alpha' + const sourcePath = path.win32.join(hostHomeDir, '.codex', 'AGENTS.md') + const targetHomeDir = '\\\\wsl$\\Ubuntu\\home\\alpha' + const targetPath = path.win32.join(targetHomeDir, '.codex', 'AGENTS.md') + const plugin = createMirrorPlugin() + + memoryFs.seedDirectory(targetHomeDir) + + const result = await syncWindowsConfigIntoWsl( + [plugin], + createWriteContext('Ubuntu', true), + { + fs: memoryFs, + spawnSync: createWslSpawnSyncMock({Ubuntu: '/home/alpha'}) as never, + platform: 'win32', + effectiveHomeDir: hostHomeDir + }, + createPredeclaredOutputs(plugin, [createGlobalOutputDeclaration(sourcePath)]) + ) + + expect(result.errors).toEqual([]) + expect(result.warnings).toEqual([]) + expect(result.mirroredFiles).toBe(1) + expect(memoryFs.existsSync(targetPath)).toBe(false) + }) + + it('logs info and skips mirror sync when WSL is unavailable on the host', async () => { + const memoryFs = new MemoryMirrorFs() + const logger = createLogger() + + const result = await syncWindowsConfigIntoWsl( + [createMirrorPlugin('~/.codex/config.toml')], + { + ...createWriteContext('Ubuntu'), + logger + }, + { + fs: memoryFs, + spawnSync: vi.fn(() => ({ + status: null, + stdout: Buffer.alloc(0), + stderr: Buffer.alloc(0), + error: Object.assign(new Error('spawnSync wsl.exe ENOENT'), {code: 'ENOENT'}) + })) as never, + platform: 'win32', + effectiveHomeDir: 'C:\\Users\\alpha' + } + ) + + expect(result).toEqual({ + mirroredFiles: 0, + warnings: [], + errors: [] + }) + expect(logger.infoMessages).toContain('wsl is unavailable, skipping WSL mirror sync') + }) + + it('mirrors declared host config files back into the current WSL home when running inside WSL', async () => { + const memoryFs = new MemoryMirrorFs() + const hostHomeDir = '/mnt/c/Users/alpha' + const nativeHomeDir = '/home/alpha' + const sourcePath = path.posix.join(hostHomeDir, '.codex', 'config.toml') + const targetPath = path.posix.join(nativeHomeDir, '.codex', 'config.toml') + + memoryFs.seedFile(sourcePath, 'codex = true\n') + memoryFs.seedDirectory(nativeHomeDir) + + const result = await syncWindowsConfigIntoWsl( + [createMirrorPlugin('~/.codex/config.toml')], + createWriteContext(), + { + fs: memoryFs, + platform: 'linux', + isWsl: true, + effectiveHomeDir: hostHomeDir, + nativeHomeDir + } + ) + + expect(result.errors).toEqual([]) + expect(result.warnings).toEqual([]) + expect(result.mirroredFiles).toBe(1) + expect(memoryFs.readFileSync(targetPath).toString('utf8')).toBe('codex = true\n') + }) + + it('mirrors generated global outputs back into the current WSL home when running inside WSL', async () => { + const memoryFs = new MemoryMirrorFs() + const hostHomeDir = '/mnt/c/Users/alpha' + const nativeHomeDir = '/home/alpha' + const sourcePath = path.posix.join(hostHomeDir, '.codex', 'AGENTS.md') + const targetPath = path.posix.join(nativeHomeDir, '.codex', 'AGENTS.md') + const plugin = createMirrorPlugin() + + memoryFs.seedFile(sourcePath, 'global prompt\n') + memoryFs.seedDirectory(nativeHomeDir) + + const result = await syncWindowsConfigIntoWsl( + [plugin], + createWriteContext(), + { + fs: memoryFs, + platform: 'linux', + isWsl: true, + effectiveHomeDir: hostHomeDir, + nativeHomeDir + }, + createPredeclaredOutputs(plugin, [createGlobalOutputDeclaration(sourcePath)]) + ) + + expect(result.errors).toEqual([]) + expect(result.warnings).toEqual([]) + expect(result.mirroredFiles).toBe(1) + expect(memoryFs.readFileSync(targetPath).toString('utf8')).toBe('global prompt\n') + }) +}) diff --git a/cli/src/wsl-mirror-sync.ts b/cli/src/wsl-mirror-sync.ts new file mode 100644 index 00000000..a39343bc --- /dev/null +++ b/cli/src/wsl-mirror-sync.ts @@ -0,0 +1,655 @@ +import type { + ILogger, + OutputFileDeclaration, + OutputPlugin, + OutputWriteContext, + PluginOptions, + WslMirrorFileDeclaration +} from './plugins/plugin-core' +import {Buffer} from 'node:buffer' +import type {RuntimeEnvironmentContext} from './runtime-environment' +import {spawnSync} from 'node:child_process' +import * as fs from 'node:fs' +import * as path from 'node:path' +import process from 'node:process' +import {getEffectiveHomeDir, resolveRuntimeEnvironment, resolveUserPath} from './runtime-environment' + +type MirrorFs = Pick +type SpawnSyncFn = typeof spawnSync +type SpawnSyncResult = ReturnType + +export interface WslMirrorRuntimeDependencies { + readonly fs?: MirrorFs + readonly spawnSync?: SpawnSyncFn + readonly platform?: NodeJS.Platform + readonly effectiveHomeDir?: string + readonly nativeHomeDir?: string + readonly isWsl?: boolean +} + +export interface ResolvedWslInstanceTarget { + readonly instance: string + readonly linuxHomeDir: string + readonly windowsHomeDir: string +} + +export interface WslMirrorSyncResult { + readonly mirroredFiles: number + readonly warnings: readonly string[] + readonly errors: readonly string[] +} + +class WslUnavailableError extends Error {} + +interface ResolvedWslMirrorSource { + readonly kind: 'declared' | 'generated' + readonly sourcePath: string + readonly relativePathSegments: readonly string[] +} + +function getFs(dependencies?: WslMirrorRuntimeDependencies): MirrorFs { + return dependencies?.fs ?? fs +} + +function getSpawnSync(dependencies?: WslMirrorRuntimeDependencies): SpawnSyncFn { + return dependencies?.spawnSync ?? spawnSync +} + +function getPlatform(dependencies?: WslMirrorRuntimeDependencies): NodeJS.Platform { + return dependencies?.platform ?? process.platform +} + +function getHostHomeDir(dependencies?: WslMirrorRuntimeDependencies): string { + return dependencies?.effectiveHomeDir ?? getEffectiveHomeDir() +} + +function getNativeHomeDir(dependencies?: WslMirrorRuntimeDependencies): string { + return dependencies?.nativeHomeDir ?? resolveRuntimeEnvironment().nativeHomeDir +} + +function isWslExecutionRuntime(dependencies?: WslMirrorRuntimeDependencies): boolean { + return dependencies?.isWsl ?? resolveRuntimeEnvironment().isWsl +} + +function getPathModuleForPlatform( + platform: NodeJS.Platform +): typeof path.win32 | typeof path.posix { + return platform === 'win32' ? path.win32 : path.posix +} + +function normalizeInstanceNames( + instances: readonly string[] +): string[] { + return [...new Set(instances.map(instance => instance.trim()).filter(instance => instance.length > 0))] +} + +function normalizeConfiguredInstances( + pluginOptions?: PluginOptions +): string[] { + const configuredInstances = pluginOptions?.windows?.wsl2?.instances + const instanceList = configuredInstances == null + ? [] + : Array.isArray(configuredInstances) + ? configuredInstances + : [configuredInstances] + + return normalizeInstanceNames(instanceList) +} + +function buildWindowsWslHomePath( + instance: string, + linuxHomeDir: string +): string { + if (!linuxHomeDir.startsWith('/')) { + throw new Error(`WSL instance "${instance}" returned a non-absolute home path: "${linuxHomeDir}".`) + } + + const pathSegments = linuxHomeDir.split('/').filter(segment => segment.length > 0) + return path.win32.join(`\\\\wsl$\\${instance}`, ...pathSegments) +} + +function resolveMirroredRelativePathSegments( + sourcePath: string, + hostHomeDir: string, + platform: NodeJS.Platform +): string[] { + const pathModule = getPathModuleForPlatform(platform) + const normalizedHostHome = pathModule.normalize(hostHomeDir) + const normalizedSourcePath = pathModule.normalize(sourcePath) + const relativePath = pathModule.relative(normalizedHostHome, normalizedSourcePath) + + if ( + relativePath.length === 0 + || relativePath.startsWith('..') + || pathModule.isAbsolute(relativePath) + ) { + throw new Error( + `WSL mirror source "${sourcePath}" must stay under the host home directory "${hostHomeDir}".` + ) + } + + return relativePath.split(/[\\/]+/u).filter(segment => segment.length > 0) +} + +function decodeWslCliOutput( + value: unknown +): string { + if (typeof value === 'string') return value + if (!Buffer.isBuffer(value) || value.length === 0) return '' + + const hasUtf16LeBom = value.length >= 2 && value[0] === 0xff && value[1] === 0xfe + const hasUtf16BeBom = value.length >= 2 && value[0] === 0xfe && value[1] === 0xff + if (hasUtf16LeBom || hasUtf16BeBom) return value.toString('utf16le').replace(/^\uFEFF/u, '') + + const utf8Text = value.toString('utf8') + if (utf8Text.includes('\u0000')) return value.toString('utf16le').replace(/^\uFEFF/u, '') + return utf8Text +} + +function getSpawnOutputText( + value: unknown +): string { + return decodeWslCliOutput(value).replaceAll('\u0000', '') +} + +function getSpawnSyncErrorCode(result: SpawnSyncResult): string | undefined { + const {error} = result + if (error == null || typeof error !== 'object') return void 0 + return 'code' in error && typeof error.code === 'string' ? error.code : void 0 +} + +function getWslUnavailableReason(result: SpawnSyncResult): string | undefined { + const errorCode = getSpawnSyncErrorCode(result) + if (errorCode === 'ENOENT') return 'wsl.exe is not available on PATH.' + + const combinedOutput = [result.stderr, result.stdout] + .map(value => getSpawnOutputText(value).trim()) + .filter(value => value.length > 0) + .join('\n') + .toLowerCase() + + if (combinedOutput.length === 0) return void 0 + + const unavailableMarkers = [ + 'windows subsystem for linux has no installed distributions', + 'windows subsystem for linux has not been enabled', + 'the windows subsystem for linux optional component is not enabled', + 'wsl is not installed', + 'run \'wsl.exe --install\'', + 'run "wsl.exe --install"', + 'wslregisterdistribution failed with error: 0x8007019e' + ] + + return unavailableMarkers.some(marker => combinedOutput.includes(marker)) + ? combinedOutput + : void 0 +} + +export async function collectDeclaredWslMirrorFiles( + outputPlugins: readonly OutputPlugin[], + ctx: OutputWriteContext +): Promise { + const declarations = await Promise.all(outputPlugins.map(async plugin => { + if (plugin.declareWslMirrorFiles == null) return [] + return plugin.declareWslMirrorFiles(ctx) + })) + + const dedupedDeclarations = new Map() + for (const group of declarations) { + for (const declaration of group) { + dedupedDeclarations.set(declaration.sourcePath, declaration) + } + } + + return [...dedupedDeclarations.values()] +} + +function buildWindowsMirrorPathRuntimeContext( + hostHomeDir: string +): RuntimeEnvironmentContext { + return { + platform: 'win32', + isWsl: false, + nativeHomeDir: hostHomeDir, + effectiveHomeDir: hostHomeDir, + globalConfigCandidates: [], + windowsUsersRoot: '', + expandedEnv: { + HOME: hostHomeDir, + USERPROFILE: hostHomeDir + } + } +} + +function buildWslHostMirrorPathRuntimeContext( + hostHomeDir: string, + nativeHomeDir: string +): RuntimeEnvironmentContext { + return { + platform: 'linux', + isWsl: true, + nativeHomeDir, + effectiveHomeDir: hostHomeDir, + globalConfigCandidates: [], + windowsUsersRoot: '', + expandedEnv: { + HOME: hostHomeDir, + USERPROFILE: hostHomeDir + } + } +} + +function parseWslInstanceList( + rawOutput: string +): string[] { + const instanceList = rawOutput + .split(/\r?\n/u) + .map(line => line.replace(/^\*/u, '').trim()) + .filter(line => line.length > 0) + + return normalizeInstanceNames(instanceList) +} + +function discoverWslInstances( + logger: ILogger, + dependencies?: WslMirrorRuntimeDependencies +): string[] { + const spawnSyncImpl = getSpawnSync(dependencies) + const listResult = spawnSyncImpl('wsl.exe', ['--list', '--quiet'], { + shell: false, + windowsHide: true + }) + + const unavailableReason = getWslUnavailableReason(listResult) + if (unavailableReason != null) throw new WslUnavailableError(unavailableReason) + + if (listResult.status !== 0) { + const stderr = getSpawnOutputText(listResult.stderr).trim() + throw new Error( + `Failed to enumerate WSL instances. ${stderr.length > 0 ? stderr : 'wsl.exe returned a non-zero exit status.'}` + ) + } + + const discoveredInstances = parseWslInstanceList(getSpawnOutputText(listResult.stdout)) + logger.info('discovered wsl instances', { + instances: discoveredInstances + }) + return discoveredInstances +} + +function resolveConfiguredOrDiscoveredInstances( + pluginOptions: Required, + logger: ILogger, + dependencies?: WslMirrorRuntimeDependencies +): string[] { + const configuredInstances = normalizeConfiguredInstances(pluginOptions) + if (configuredInstances.length > 0) return configuredInstances + return discoverWslInstances(logger, dependencies) +} + +function resolveGeneratedWslMirrorSource( + declaration: OutputFileDeclaration, + hostHomeDir: string, + platform: NodeJS.Platform +): ResolvedWslMirrorSource | undefined { + if (declaration.scope !== 'global') return void 0 + + const pathModule = getPathModuleForPlatform(platform) + const sourcePath = pathModule.normalize(declaration.path) + let relativePathSegments: string[] + try { + relativePathSegments = resolveMirroredRelativePathSegments(sourcePath, hostHomeDir, platform) + } + catch { + return void 0 + } + + const [topLevelSegment] = relativePathSegments + + // Mirror home-style tool config roots only. Windows app-data trees such as + // AppData\Local\JetBrains\... stay Windows-only even though they live under the user profile. + if (topLevelSegment == null || !topLevelSegment.startsWith('.')) return void 0 + + return { + kind: 'generated', + sourcePath, + relativePathSegments + } +} + +function collectGeneratedWslMirrorSources( + predeclaredOutputs: ReadonlyMap | undefined, + hostHomeDir: string, + platform: NodeJS.Platform +): readonly ResolvedWslMirrorSource[] { + if (predeclaredOutputs == null) return [] + + const dedupedSources = new Map() + for (const declarations of predeclaredOutputs.values()) { + for (const declaration of declarations) { + const resolvedSource = resolveGeneratedWslMirrorSource(declaration, hostHomeDir, platform) + if (resolvedSource == null) continue + dedupedSources.set(resolvedSource.sourcePath, resolvedSource) + } + } + + return [...dedupedSources.values()] +} + +function resolveDeclaredWslMirrorSource( + declaration: WslMirrorFileDeclaration, + pathRuntimeContext: RuntimeEnvironmentContext, + hostHomeDir: string, + platform: NodeJS.Platform +): ResolvedWslMirrorSource { + const pathModule = getPathModuleForPlatform(platform) + const sourcePath = pathModule.normalize(resolveUserPath(declaration.sourcePath, pathRuntimeContext)) + const relativePathSegments = resolveMirroredRelativePathSegments(sourcePath, hostHomeDir, platform) + + return { + kind: 'declared', + sourcePath, + relativePathSegments + } +} + +function combineWslMirrorSources( + mirrorDeclarations: readonly WslMirrorFileDeclaration[], + generatedMirrorSources: readonly ResolvedWslMirrorSource[], + pathRuntimeContext: RuntimeEnvironmentContext, + hostHomeDir: string, + platform: NodeJS.Platform +): {readonly sources: readonly ResolvedWslMirrorSource[], readonly errors: readonly string[]} { + const dedupedSources = new Map() + const errors: string[] = [] + + for (const declaration of mirrorDeclarations) { + try { + const resolvedSource = resolveDeclaredWslMirrorSource(declaration, pathRuntimeContext, hostHomeDir, platform) + dedupedSources.set(resolvedSource.sourcePath, resolvedSource) + } + catch (error) { + errors.push(error instanceof Error ? error.message : String(error)) + } + } + + for (const source of generatedMirrorSources) { + dedupedSources.set(source.sourcePath, source) + } + + return { + sources: [...dedupedSources.values()], + errors + } +} + +export function resolveWslInstanceTargets( + pluginOptions: Required, + logger: ILogger, + dependencies?: WslMirrorRuntimeDependencies +): ResolvedWslInstanceTarget[] { + if (getPlatform(dependencies) !== 'win32') return [] + + const configuredInstances = resolveConfiguredOrDiscoveredInstances(pluginOptions, logger, dependencies) + if (configuredInstances.length === 0) return [] + + const fsImpl = getFs(dependencies) + const spawnSyncImpl = getSpawnSync(dependencies) + const resolvedTargets: ResolvedWslInstanceTarget[] = [] + + for (const instance of configuredInstances) { + const probeResult = spawnSyncImpl('wsl.exe', ['-d', instance, 'sh', '-lc', 'printf %s "$HOME"'], { + shell: false, + windowsHide: true + }) + + const unavailableReason = getWslUnavailableReason(probeResult) + if (unavailableReason != null) throw new WslUnavailableError(unavailableReason) + + if (probeResult.status !== 0) { + const stderr = getSpawnOutputText(probeResult.stderr).trim() + throw new Error( + `Failed to probe WSL instance "${instance}". ${stderr.length > 0 ? stderr : 'wsl.exe returned a non-zero exit status.'}` + ) + } + + const linuxHomeDir = getSpawnOutputText(probeResult.stdout).trim() + if (linuxHomeDir.length === 0) throw new Error(`WSL instance "${instance}" returned an empty home directory.`) + + const windowsHomeDir = buildWindowsWslHomePath(instance, linuxHomeDir) + if (!fsImpl.existsSync(windowsHomeDir)) { + throw new Error( + `WSL instance "${instance}" home directory is unavailable at "${windowsHomeDir}".` + ) + } + + logger.info('resolved wsl instance home', { + instance, + linuxHomeDir, + windowsHomeDir + }) + + resolvedTargets.push({ + instance, + linuxHomeDir, + windowsHomeDir + }) + } + + return resolvedTargets +} + +function syncResolvedMirrorSourcesIntoCurrentWslHome( + sources: readonly ResolvedWslMirrorSource[], + ctx: OutputWriteContext, + dependencies?: WslMirrorRuntimeDependencies +): WslMirrorSyncResult { + const fsImpl = getFs(dependencies) + const nativeHomeDir = path.posix.normalize(getNativeHomeDir(dependencies)) + let mirroredFiles = 0 + const warnings: string[] = [] + const errors: string[] = [] + + for (const source of sources) { + if (source.kind === 'declared' && !fsImpl.existsSync(source.sourcePath)) { + const warningMessage = `Skipping missing WSL mirror source file: ${source.sourcePath}` + warnings.push(warningMessage) + ctx.logger.warn({ + code: 'WSL_MIRROR_SOURCE_MISSING', + title: 'WSL mirror source file is missing', + rootCause: [warningMessage], + exactFix: [ + 'Create the source file on the Windows host or remove the WSL mirror declaration before retrying tnmsc.' + ] + }) + continue + } + + const targetPath = path.posix.join(nativeHomeDir, ...source.relativePathSegments) + try { + if (ctx.dryRun === true) { + ctx.logger.info('would mirror host config into wsl runtime home', { + sourcePath: source.sourcePath, + targetPath, + dryRun: true + }) + } else { + const content = fsImpl.readFileSync(source.sourcePath) + fsImpl.mkdirSync(path.posix.dirname(targetPath), {recursive: true}) + fsImpl.writeFileSync(targetPath, content) + ctx.logger.info('mirrored host config into wsl runtime home', { + sourcePath: source.sourcePath, + targetPath + }) + } + + mirroredFiles += 1 + } + catch (error) { + errors.push( + `Failed to mirror "${source.sourcePath}" into the current WSL home at "${targetPath}": ${error instanceof Error ? error.message : String(error)}` + ) + } + } + + return { + mirroredFiles, + warnings, + errors + } +} + +export async function syncWindowsConfigIntoWsl( + outputPlugins: readonly OutputPlugin[], + ctx: OutputWriteContext, + dependencies?: WslMirrorRuntimeDependencies, + predeclaredOutputs?: ReadonlyMap +): Promise { + const platform = getPlatform(dependencies) + const wslRuntime = platform === 'linux' && isWslExecutionRuntime(dependencies) + if (platform !== 'win32' && !wslRuntime) { + return { + mirroredFiles: 0, + warnings: [], + errors: [] + } + } + + const hostHomeDir = wslRuntime + ? path.posix.normalize(getHostHomeDir(dependencies)) + : path.win32.normalize(getHostHomeDir(dependencies)) + const mirrorDeclarations = await collectDeclaredWslMirrorFiles(outputPlugins, ctx) + const generatedMirrorSources = collectGeneratedWslMirrorSources(predeclaredOutputs, hostHomeDir, platform) + if (mirrorDeclarations.length === 0 && generatedMirrorSources.length === 0) { + return { + mirroredFiles: 0, + warnings: [], + errors: [] + } + } + + const pluginOptions = (ctx.pluginOptions ?? {}) as Required + const nativeHomeDir = wslRuntime ? path.posix.normalize(getNativeHomeDir(dependencies)) : void 0 + const pathRuntimeContext = wslRuntime + ? buildWslHostMirrorPathRuntimeContext(hostHomeDir, nativeHomeDir ?? hostHomeDir) + : buildWindowsMirrorPathRuntimeContext(hostHomeDir) + const resolvedMirrorSources = combineWslMirrorSources( + mirrorDeclarations, + generatedMirrorSources, + pathRuntimeContext, + hostHomeDir, + platform + ) + + if (wslRuntime) { + if (resolvedMirrorSources.sources.length === 0 || nativeHomeDir == null || hostHomeDir === nativeHomeDir) { + return { + mirroredFiles: 0, + warnings: [], + errors: [...resolvedMirrorSources.errors] + } + } + + const localMirrorResult = syncResolvedMirrorSourcesIntoCurrentWslHome( + resolvedMirrorSources.sources, + ctx, + dependencies + ) + + return { + mirroredFiles: localMirrorResult.mirroredFiles, + warnings: [...localMirrorResult.warnings], + errors: [...resolvedMirrorSources.errors, ...localMirrorResult.errors] + } + } + + let resolvedTargets: ResolvedWslInstanceTarget[] + try { + resolvedTargets = resolveWslInstanceTargets(pluginOptions, ctx.logger, dependencies) + } + catch (error) { + if (error instanceof WslUnavailableError) { + ctx.logger.info('wsl is unavailable, skipping WSL mirror sync', { + reason: error.message + }) + return { + mirroredFiles: 0, + warnings: [], + errors: [] + } + } + + return { + mirroredFiles: 0, + warnings: [], + errors: [error instanceof Error ? error.message : String(error)] + } + } + + if (resolvedTargets.length === 0 || resolvedMirrorSources.sources.length === 0) { + return { + mirroredFiles: 0, + warnings: [], + errors: [...resolvedMirrorSources.errors] + } + } + + const fsImpl = getFs(dependencies) + let mirroredFiles = 0 + const warnings: string[] = [] + const errors: string[] = [...resolvedMirrorSources.errors] + + for (const declaration of resolvedMirrorSources.sources) { + if (declaration.kind === 'declared' && !fsImpl.existsSync(declaration.sourcePath)) { + const warningMessage = `Skipping missing WSL mirror source file: ${declaration.sourcePath}` + warnings.push(warningMessage) + ctx.logger.warn({ + code: 'WSL_MIRROR_SOURCE_MISSING', + title: 'WSL mirror source file is missing', + rootCause: [warningMessage], + exactFix: [ + 'Create the source file on the Windows host or remove the WSL mirror declaration before retrying tnmsc.' + ] + }) + continue + } + + for (const resolvedTarget of resolvedTargets) { + const sourcePath = declaration.sourcePath + const targetPath = path.win32.join(resolvedTarget.windowsHomeDir, ...declaration.relativePathSegments) + + try { + if (ctx.dryRun === true) { + ctx.logger.info('would mirror windows config into wsl', { + instance: resolvedTarget.instance, + sourcePath, + targetPath, + dryRun: true + }) + } else { + const content = fsImpl.readFileSync(sourcePath) + fsImpl.mkdirSync(path.win32.dirname(targetPath), {recursive: true}) + fsImpl.writeFileSync(targetPath, content) + ctx.logger.info('mirrored windows config into wsl', { + instance: resolvedTarget.instance, + sourcePath, + targetPath + }) + } + + mirroredFiles += 1 + } + catch (error) { + errors.push( + `Failed to mirror "${sourcePath}" into WSL instance "${resolvedTarget.instance}" at "${targetPath}": ${error instanceof Error ? error.message : String(error)}` + ) + } + } + } + + return { + mirroredFiles, + warnings, + errors + } +} diff --git a/doc/app/docs/layout.tsx b/doc/app/docs/layout.tsx index c5a98b41..172a0dd5 100644 --- a/doc/app/docs/layout.tsx +++ b/doc/app/docs/layout.tsx @@ -19,22 +19,30 @@ export default async function DocsLayout({children}: {readonly children: ReactNo pageMap={pageMap} navbar={( + Docs memory-sync )} - align="left" > -

+
+ + +
+ + GitHub + +
+
)} footer={( diff --git a/doc/app/globals.css b/doc/app/globals.css index f30c6ff6..8c9f7876 100644 --- a/doc/app/globals.css +++ b/doc/app/globals.css @@ -127,15 +127,19 @@ samp { padding: 6px 0 18px; } -.home-brand, -.docs-brand { +.home-brand { display: inline-flex; align-items: baseline; gap: 10px; } -.home-brand strong, -.docs-brand-title { +.docs-brand { + display: inline-flex; + align-items: center; + gap: 10px; +} + +.home-brand strong { font-size: 0.95rem; font-weight: 600; letter-spacing: -0.02em; @@ -147,6 +151,28 @@ samp { letter-spacing: 0.02em; } +.docs-brand-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 24px; + padding: 0 8px; + border: 1px solid var(--surface-border); + border-radius: 999px; + background: color-mix(in srgb, var(--surface-strong) 82%, transparent); + color: var(--page-fg-muted); + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.docs-brand-title { + font-size: 0.95rem; + font-weight: 600; + letter-spacing: -0.02em; +} + .home-topbar-nav { display: inline-flex; align-items: center; @@ -170,15 +196,75 @@ samp { transform 0.2s ease; } +.docs-site-navbar nav:not(.docs-navbar-links) { + gap: 14px; +} + +.docs-site-navbar nav:not(.docs-navbar-links) > :nth-child(2) { + display: none; +} + +.docs-site-navbar nav:not(.docs-navbar-links) > :nth-child(3) { + order: 3; +} + +.docs-site-navbar nav:not(.docs-navbar-links) > :nth-child(4) { + order: 2; +} + +.docs-site-navbar nav:not(.docs-navbar-links) > :nth-child(5) { + order: 4; +} + +.docs-navbar-shell { + display: inline-flex; + flex: 1 1 auto; + align-items: center; + justify-content: flex-end; + gap: 12px; + min-width: 0; + overflow: hidden; +} + .docs-navbar-links { display: inline-flex; align-items: center; - flex-wrap: wrap; + flex-wrap: nowrap; + justify-content: flex-end; gap: 8px; + min-width: 0; + overflow-x: auto; + padding-bottom: 2px; +} + +.docs-navbar-actions { + display: inline-flex; + align-items: center; + gap: 10px; +} + +.docs-navbar-action { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 36px; + padding: 0 12px; + border: 1px solid var(--surface-border); + border-radius: 999px; + background: color-mix(in srgb, var(--surface-strong) 84%, transparent); + color: var(--page-fg-soft); + font-size: 0.84rem; + font-weight: 500; + transition: + border-color 0.2s ease, + background-color 0.2s ease, + color 0.2s ease, + transform 0.2s ease; } .home-topbar-nav a:hover, -.docs-nav-link:hover { +.docs-nav-link:hover, +.docs-navbar-action:hover { border-color: var(--surface-border); background: var(--surface); color: var(--page-fg); @@ -467,10 +553,6 @@ samp { border-color: var(--surface-border); } -.nextra-navbar nav > div:first-of-type { - display: none; -} - .nextra-sidebar, .nextra-toc, .nextra-footer { @@ -737,7 +819,8 @@ samp { } html.dark .home-topbar-nav a:hover, -html.dark .docs-nav-link:hover { +html.dark .docs-nav-link:hover, +html.dark .docs-navbar-action:hover { border-color: var(--surface-border); background: var(--surface-highlight); } @@ -833,6 +916,16 @@ html.dark .nextra-navbar-blur { border-color: var(--surface-separator); } +html.dark .docs-brand-badge { + border-color: var(--surface-border); + background: color-mix(in srgb, var(--surface-highlight) 100%, var(--surface-overlay)); +} + +html.dark .docs-navbar-action { + border-color: var(--surface-border); + background: color-mix(in srgb, var(--surface-highlight) 100%, var(--surface-overlay)); +} + html.dark .nextra-sidebar, html.dark .nextra-toc { border-color: var(--surface-separator); @@ -1062,10 +1155,15 @@ html.dark .nextra-body-typesetting-article .mermaid-diagram__fallback { font-size: clamp(1.8rem, 10vw, 2.45rem); } - .docs-navbar-links { - overflow-x: auto; - max-width: 100%; - padding-bottom: 2px; + .docs-site-navbar nav:not(.docs-navbar-links) { + gap: 12px; + } +} + +@media (max-width: 768px) { + .docs-brand-badge, + .docs-navbar-shell { + display: none; } } diff --git a/doc/package.json b/doc/package.json index ace67077..b8b4db2d 100644 --- a/doc/package.json +++ b/doc/package.json @@ -1,6 +1,6 @@ { "name": "@truenine/memory-sync-docs", - "version": "2026.10323.10152", + "version": "2026.10323.10738", "private": true, "description": "Chinese-first manifesto-led documentation site for @truenine/memory-sync.", "engines": { diff --git a/gui/package.json b/gui/package.json index a30f24ff..e530c892 100644 --- a/gui/package.json +++ b/gui/package.json @@ -1,6 +1,6 @@ { "name": "@truenine/memory-sync-gui", - "version": "2026.10323.10152", + "version": "2026.10323.10738", "private": true, "engines": { "node": ">=25.2.1", diff --git a/gui/src-tauri/Cargo.toml b/gui/src-tauri/Cargo.toml index 65079007..1cb5826c 100644 --- a/gui/src-tauri/Cargo.toml +++ b/gui/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "memory-sync-gui" -version = "2026.10323.10152" +version = "2026.10323.10738" description = "Memory Sync desktop GUI application" authors.workspace = true edition.workspace = true diff --git a/gui/src-tauri/tauri.conf.json b/gui/src-tauri/tauri.conf.json index 239de566..a57b7f68 100644 --- a/gui/src-tauri/tauri.conf.json +++ b/gui/src-tauri/tauri.conf.json @@ -1,6 +1,6 @@ { "$schema": "https://schema.tauri.app/config/2", - "version": "2026.10323.10152", + "version": "2026.10323.10738", "productName": "Memory Sync", "identifier": "org.truenine.memory-sync", "build": { diff --git a/libraries/logger/package.json b/libraries/logger/package.json index 433d7045..d2d593df 100644 --- a/libraries/logger/package.json +++ b/libraries/logger/package.json @@ -1,7 +1,7 @@ { "name": "@truenine/logger", "type": "module", - "version": "2026.10323.10152", + "version": "2026.10323.10738", "private": true, "description": "Rust-powered structured logger for Node.js via N-API", "license": "AGPL-3.0-only", diff --git a/libraries/md-compiler/package.json b/libraries/md-compiler/package.json index b309c45d..b8bef19c 100644 --- a/libraries/md-compiler/package.json +++ b/libraries/md-compiler/package.json @@ -1,7 +1,7 @@ { "name": "@truenine/md-compiler", "type": "module", - "version": "2026.10323.10152", + "version": "2026.10323.10738", "private": true, "description": "Rust-powered MDX→Markdown compiler for Node.js with pure-TS fallback", "license": "AGPL-3.0-only", diff --git a/libraries/script-runtime/package.json b/libraries/script-runtime/package.json index e439b8b0..7a8ca748 100644 --- a/libraries/script-runtime/package.json +++ b/libraries/script-runtime/package.json @@ -1,7 +1,7 @@ { "name": "@truenine/script-runtime", "type": "module", - "version": "2026.10323.10152", + "version": "2026.10323.10738", "private": true, "description": "Rust-backed TypeScript proxy runtime for tnmsc", "license": "AGPL-3.0-only", diff --git a/mcp/package.json b/mcp/package.json index a3a432d9..9d3780dd 100644 --- a/mcp/package.json +++ b/mcp/package.json @@ -1,7 +1,7 @@ { "name": "@truenine/memory-sync-mcp", "type": "module", - "version": "2026.10323.10152", + "version": "2026.10323.10738", "description": "MCP stdio server for managing memory-sync prompt sources and translation artifacts", "author": "TrueNine", "license": "AGPL-3.0-only", diff --git a/package.json b/package.json index 11d77f1e..cec36846 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@truenine/memory-sync", - "version": "2026.10323.10152", + "version": "2026.10323.10738", "description": "Cross-AI-tool prompt synchronisation toolkit (CLI + Tauri desktop GUI) — one ruleset, multi-target adaptation. Monorepo powered by pnpm + Turbo.", "license": "AGPL-3.0-only", "keywords": [