1515 * Requirements: 9.1-9.5
1616 */
1717
18+ import {
19+ execSync ,
20+ } from 'node:child_process' ;
1821import * as fs from 'node:fs' ;
1922import * as os from 'node:os' ;
2023import * as path from 'node:path' ;
@@ -60,17 +63,78 @@ export interface CopilotFile {
6063 targetPath : string ;
6164}
6265
66+ type CodeFlavourFolder = 'Code' | 'Code - Insiders' ;
67+
6368/**
6469 * Service to sync bundle prompts to GitHub Copilot's native directories at user level.
6570 * Implements IScopeService for consistent scope handling.
6671 */
6772export class UserScopeService implements IScopeService {
6873 private readonly logger : Logger ;
74+ private readonly appNameMap : Map < string , CodeFlavourFolder > = new Map ( [
75+ [ 'vscode' , 'Code' ] ,
76+ [ 'vscode-insiders' , 'Code - Insiders' ]
77+ ] ) ;
78+
79+ private windowsHomeInWSL : string | undefined ;
80+ private cachedPromptsDir : string | undefined ;
6981
7082 constructor ( private readonly context : vscode . ExtensionContext ) {
7183 this . logger = Logger . getInstance ( ) ;
7284 }
7385
86+ /**
87+ * Function to detect if the extension is running in a WSL remote context
88+ * We need this to determine if we should sync to Windows filesystem instead of WSL filesystem
89+ * @returns True if running in WSL, false otherwise
90+ */
91+ private isRunningInWSL ( ) : boolean {
92+ return vscode . env . remoteName === 'wsl' ;
93+ }
94+
95+ /**
96+ * When running on wsl, get the Windows home directory
97+ * @returns The Windows home directory path in WSL as mnt/c/Users/<User>
98+ */
99+ private getWindowsHomeDirectoryInWSL ( ) : string | undefined {
100+ if ( this . windowsHomeInWSL ) {
101+ return this . windowsHomeInWSL ;
102+ }
103+ try {
104+ const wslWindowsHome = execSync ( `wslpath -u "$(cmd.exe /c echo %USERPROFILE% 2>/dev/null)"` , { encoding : 'utf8' , timeout : 5000 } ) . trim ( ) ;
105+ this . logger . info ( `[UserScopeService] Detected Windows home directory in WSL: ${ wslWindowsHome } ` ) ;
106+ this . windowsHomeInWSL = wslWindowsHome ;
107+ return wslWindowsHome ;
108+ } catch ( error ) {
109+ this . logger . error ( 'Failed to get Windows home directory in WSL' , error as Error ) ;
110+ return undefined ;
111+ }
112+ }
113+
114+ /**
115+ * Get the Windows User directory when running in WSL.
116+ * Since we construct the path ourselves, we return the User dir directly
117+ * instead of globalStorage (avoiding redundant path parsing downstream).
118+ * @returns The User directory path in WSL, or undefined if not in WSL or detection fails.
119+ */
120+ private getWindowsWslUserDir ( ) : string | undefined {
121+ if ( ! this . isRunningInWSL ( ) ) {
122+ return undefined ;
123+ }
124+ try {
125+ const windowsHome = this . getWindowsHomeDirectoryInWSL ( ) ;
126+ if ( windowsHome ) {
127+ const folderName = this . appNameMap . get ( vscode . env . uriScheme ) || 'Code' ;
128+ const userDir = path . join ( windowsHome , 'AppData' , 'Roaming' , folderName , 'User' ) ;
129+ this . logger . debug ( `[UserScopeService] Resolved WSL User directory: ${ userDir } ` ) ;
130+ return userDir ;
131+ }
132+ } catch {
133+ this . logger . warn ( '[UserScopeService] Failed to resolve Windows path, falling back to WSL path' ) ;
134+ }
135+ return undefined ;
136+ }
137+
74138 /**
75139 * Get the Copilot prompts directory for current VSCode flavor
76140 * Uses the extension's globalStorageUri to dynamically determine the IDE's data directory
@@ -86,44 +150,66 @@ export class UserScopeService implements IScopeService {
86150 * so we need to sync prompts to the Windows filesystem, not the WSL filesystem.
87151 */
88152 private getCopilotPromptsDirectory ( ) : string {
89- const globalStoragePath = this . context . globalStorageUri . fsPath ;
153+ if ( this . cachedPromptsDir ) {
154+ return this . cachedPromptsDir ;
155+ }
90156
91- // Find the User directory by looking for '/User/' or '\User\' in the path
92- const userIndex = globalStoragePath . lastIndexOf ( path . sep + 'User' + path . sep ) ;
93- const escapedSep = escapeRegex ( path . sep ) ;
157+ const resolved = this . resolveCopilotPromptsDirectory ( ) ;
94158
95- if ( userIndex === - 1 ) {
96- // Fallback: Custom user-data-dir without 'User' directory
97- // Navigate up from globalStorage/publisher.extension
98- const baseDir = path . dirname ( path . dirname ( globalStoragePath ) ) ;
99-
100- // Check if we're in a profiles structure
101- const [ , profileId ] = baseDir . match ( new RegExp ( `profiles${ escapedSep } ([^${ escapedSep } ]+)` ) ) || [ ] ;
102- if ( profileId ) {
103- const profileName = this . getActiveProfileName ( baseDir ) || profileId ;
104- this . logger . info ( `[UserScopeService] Using profile: ${ profileName } ` ) ;
105- return path . join ( baseDir , 'prompts' ) ;
106- }
159+ // Sanity check: the resolved path should end with 'prompts' and not be a filesystem root
160+ const basename = path . basename ( resolved ) ;
161+ if ( basename !== 'prompts' || resolved === path . dirname ( resolved ) ) {
162+ this . logger . warn ( `[UserScopeService] Resolved prompts directory looks suspicious: ${ resolved } ` ) ;
163+ }
164+
165+ this . cachedPromptsDir = resolved ;
166+ return resolved ;
167+ }
168+
169+ private resolveCopilotPromptsDirectory ( ) : string {
170+ const globalStoragePath = this . context . globalStorageUri . fsPath ;
171+ this . logger . debug ( `[UserScopeService] Original globalStorage path: ${ globalStoragePath } ` ) ;
172+
173+ // WSL: we construct the path ourselves, so we know the User dir directly
174+ const wslUserDir = this . getWindowsWslUserDir ( ) ;
175+ if ( wslUserDir ) {
176+ return this . resolvePromptsFromUserDir ( wslUserDir ) ;
177+ }
107178
108- return path . join ( baseDir , 'prompts' ) ;
179+ if ( this . isRunningInWSL ( ) ) {
180+ this . logger . warn ( '[UserScopeService] Unable to resolve Windows path from WSL. Prompts may not be visible to Copilot.' ) ;
181+ vscode . window . showWarningMessage ( 'Prompt Registry: Unable to resolve Windows path from WSL. Prompts may not be visible to Copilot.' ) ;
109182 }
110183
111- // Extract path up to and including 'User'
112- const userDir = globalStoragePath . substring ( 0 , userIndex + path . sep . length + 'User' . length ) ;
184+ // Non-WSL: parse the User dir from the globalStorage path
185+ const userIndex = globalStoragePath . lastIndexOf ( path . sep + 'User' + path . sep ) ;
186+ const userDir = userIndex === - 1
187+ ? path . dirname ( path . dirname ( globalStoragePath ) )
188+ : globalStoragePath . substring ( 0 , userIndex + path . sep . length + 'User' . length ) ;
113189
114- // Check if this is a profile-based path by looking for '/profiles/' after User
190+ // Check if the globalStorage path itself contains a profile segment
115191 // Path structure: .../User/profiles/<profile-id>/globalStorage/...
116192 const remainingPath = globalStoragePath . substring ( userDir . length ) ;
193+ const escapedSep = escapeRegex ( path . sep ) ;
117194 const profilesMatch = remainingPath . match ( new RegExp ( `^${ escapedSep } profiles${ escapedSep } ([^${ escapedSep } ]+)` ) ) ;
118-
119195 if ( profilesMatch ) {
120- // Profile-based path: include the profile directory
121196 const profileId = profilesMatch [ 1 ] ;
122197 const profileName = this . getActiveProfileName ( userDir ) || profileId ;
123198 this . logger . info ( `[UserScopeService] Using profile: ${ profileName } ` ) ;
124199 return path . join ( userDir , 'profiles' , profileId , 'prompts' ) ;
125200 }
126201
202+ return this . resolvePromptsFromUserDir ( userDir ) ;
203+ }
204+
205+ /**
206+ * Given a known User directory, resolve the prompts directory
207+ * (handles profile detection for both WSL and non-WSL paths).
208+ * @param userDir
209+ */
210+ private resolvePromptsFromUserDir ( userDir : string ) : string {
211+ this . logger . debug ( `[UserScopeService] Resolved User directory: ${ userDir } ` ) ;
212+
127213 // Extension installed globally but user might be in a profile
128214 // Use combined detection method (storage.json + filesystem heuristic)
129215 const detectedProfile = this . detectActiveProfile ( userDir ) ;
@@ -353,8 +439,12 @@ export class UserScopeService implements IScopeService {
353439 // Always remove existing symlink and recreate - simpler and more robust
354440 await unlink ( file . targetPath ) ;
355441 this . logger . debug ( `Removed existing symlink: ${ file . targetPath } ` ) ;
442+ } else if ( this . isRunningInWSL ( ) ) {
443+ // WSL uses copies (not symlinks), so existing regular files are ours — overwrite
444+ await unlink ( file . targetPath ) ;
445+ this . logger . debug ( `Removed existing copy for re-sync (WSL): ${ file . targetPath } ` ) ;
356446 } else {
357- // It's a regular file - might be user's custom file, skip
447+ // It's a regular file on non-WSL - might be user's custom file, skip
358448 this . logger . warn ( `File already exists (not managed): ${ file . targetPath } ` ) ;
359449 return ;
360450 }
@@ -364,16 +454,23 @@ export class UserScopeService implements IScopeService {
364454 const targetDir = path . dirname ( file . targetPath ) ;
365455 await this . ensureDirectory ( targetDir ) ;
366456
367- // Try to create symlink first (preferred)
368- try {
369- await symlink ( file . sourcePath , file . targetPath , 'file' ) ;
370- this . logger . debug ( `Created symlink: ${ path . basename ( file . targetPath ) } ` ) ;
371- } catch {
372- // Symlink failed (maybe Windows or permissions), fall back to copy
373- this . logger . debug ( 'Symlink failed, copying file instead' ) ;
457+ // WSL: symlinks from Windows → WSL paths are broken from Windows' perspective,
458+ // so always copy when running in WSL. On non-WSL, prefer symlinks.
459+ if ( this . isRunningInWSL ( ) ) {
374460 const content = await readFile ( file . sourcePath , 'utf8' ) ;
375461 await writeFile ( file . targetPath , content , 'utf8' ) ;
376- this . logger . debug ( `Copied file: ${ path . basename ( file . targetPath ) } ` ) ;
462+ this . logger . debug ( `Copied file (WSL): ${ path . basename ( file . targetPath ) } ` ) ;
463+ } else {
464+ try {
465+ await symlink ( file . sourcePath , file . targetPath , 'file' ) ;
466+ this . logger . debug ( `Created symlink: ${ path . basename ( file . targetPath ) } ` ) ;
467+ } catch {
468+ // Symlink failed (maybe Windows or permissions), fall back to copy
469+ this . logger . debug ( 'Symlink failed, copying file instead' ) ;
470+ const content = await readFile ( file . sourcePath , 'utf8' ) ;
471+ await writeFile ( file . targetPath , content , 'utf8' ) ;
472+ this . logger . debug ( `Copied file: ${ path . basename ( file . targetPath ) } ` ) ;
473+ }
377474 }
378475
379476 this . logger . info ( `✅ Synced ${ file . type } : ${ file . name } → ${ path . basename ( file . targetPath ) } ` ) ;
@@ -578,6 +675,7 @@ export class UserScopeService implements IScopeService {
578675 // Check if file content matches source before deleting
579676 try {
580677 if ( fs . existsSync ( copilotFile . sourcePath ) ) {
678+ this . logger . debug ( `Target is a regular file, checking content before removal: ${ path . basename ( copilotFile . targetPath ) } ` ) ;
581679 const targetContent = await readFile ( copilotFile . targetPath , 'utf8' ) ;
582680 const sourceContent = await readFile ( copilotFile . sourcePath , 'utf8' ) ;
583681
0 commit comments