11import * as fs from 'fs-extra' ;
22import * as path from 'path' ;
3- import { commands , l10n , MarkdownString , QuickInputButtons , Uri , window , workspace } from 'vscode' ;
3+ import { commands , l10n , MarkdownString , QuickInputButtons , Uri , window , WorkspaceFolder } from 'vscode' ;
44import { PythonProject , PythonProjectCreator , PythonProjectCreatorOptions } from '../../api' ;
55import { NEW_PROJECT_TEMPLATES_FOLDER } from '../../common/constants' ;
66import { traceError } from '../../common/logging' ;
7- import { showInputBoxWithButtons , showTextDocument } from '../../common/window.apis' ;
7+ import { normalizePath } from '../../common/utils/pathUtils' ;
8+ import { isWindows } from '../../common/utils/platformUtils' ;
9+ import { showErrorMessage , showInputBoxWithButtons , showTextDocument } from '../../common/window.apis' ;
10+ import { getWorkspaceFolder , getWorkspaceFolders } from '../../common/workspace.apis' ;
811import { PythonProjectManager } from '../../internal.api' ;
912import { isCopilotInstalled , manageCopilotInstructionsFile , replaceInFilesAndNames } from './creationHelpers' ;
1013
14+ function validateScriptFileName ( value : string ) : string | null {
15+ const pathSegments = value . split ( / [ \\ / ] / ) ;
16+ if ( pathSegments . length !== 1 || pathSegments . includes ( '..' ) ) {
17+ return l10n . t ( 'Script name must be a file name without path separators or traversal.' ) ;
18+ }
19+ if ( ! value . endsWith ( '.py' ) ) {
20+ return l10n . t ( 'Script name must end with ".py".' ) ;
21+ }
22+ const baseName = value . replace ( / \. p y $ / , '' ) ;
23+ const deviceBaseName = baseName . split ( '.' ) [ 0 ] ;
24+ if ( isWindows ( ) && / ^ ( c o n | p r n | a u x | n u l | c o m [ 1 - 9 ] | l p t [ 1 - 9 ] ) $ / i. test ( deviceBaseName ) ) {
25+ return l10n . t ( 'Script name uses a reserved Windows device name.' ) ;
26+ }
27+ // following PyPI (PEP 508) rules for package names
28+ if ( ! / ^ ( [ a - z _ ] | [ a - z 0 - 9 _ ] [ a - z 0 - 9 . _ - ] * [ a - z 0 - 9 _ ] ) $ / i. test ( baseName ) ) {
29+ return l10n . t (
30+ 'Invalid script name. Use only letters, numbers, underscores, hyphens, or periods. Must start and end with a letter or number.' ,
31+ ) ;
32+ }
33+ if ( / ^ [ - . _ 0 - 9 ] $ / i. test ( baseName ) ) {
34+ return l10n . t ( 'Single-character script names cannot be a number, hyphen, or period.' ) ;
35+ }
36+ return null ;
37+ }
38+
39+ function isSameOrDescendantPath ( parentPath : string , candidatePath : string ) : boolean {
40+ const relativePath = path . relative (
41+ normalizePath ( path . resolve ( parentPath ) ) ,
42+ normalizePath ( path . resolve ( candidatePath ) ) ,
43+ ) ;
44+ return (
45+ relativePath === '' ||
46+ ( relativePath !== '..' && ! relativePath . startsWith ( `..${ path . sep } ` ) && ! path . isAbsolute ( relativePath ) )
47+ ) ;
48+ }
49+
50+ function uriForFileRootInWorkspace ( rootPath : string , workspaceFolder : WorkspaceFolder ) : Uri {
51+ const relativeRootPath = path . relative ( path . resolve ( workspaceFolder . uri . fsPath ) , path . resolve ( rootPath ) ) ;
52+ const pathSegments = relativeRootPath . split ( / [ \\ / ] / ) . filter ( ( segment ) => segment . length > 0 ) ;
53+ return workspaceFolder . uri . with ( {
54+ path : path . posix . join ( workspaceFolder . uri . path , ...pathSegments ) ,
55+ } ) ;
56+ }
57+
58+ async function lstatIfExists ( candidatePath : string ) {
59+ try {
60+ return await fs . lstat ( candidatePath ) ;
61+ } catch ( error ) {
62+ if ( ( error as NodeJS . ErrnoException ) . code === 'ENOENT' ) {
63+ return undefined ;
64+ }
65+ throw error ;
66+ }
67+ }
68+
69+ async function isCopilotInstructionsDestinationContained (
70+ destinationRoot : string ,
71+ physicalDestinationRoot : string ,
72+ physicalWorkspaceRoot : string ,
73+ ) : Promise < boolean > {
74+ try {
75+ const githubFolder = path . join ( destinationRoot , '.github' ) ;
76+ const githubEntry = await lstatIfExists ( githubFolder ) ;
77+ if ( ! githubEntry ) {
78+ return isSameOrDescendantPath ( physicalWorkspaceRoot , physicalDestinationRoot ) ;
79+ }
80+
81+ const physicalGithubFolder = await fs . realpath ( githubFolder ) ;
82+ if ( ! isSameOrDescendantPath ( physicalWorkspaceRoot , physicalGithubFolder ) ) {
83+ return false ;
84+ }
85+ if ( ! ( await fs . stat ( githubFolder ) ) . isDirectory ( ) ) {
86+ return false ;
87+ }
88+
89+ const instructionsFile = path . join ( githubFolder , 'copilot-instructions.md' ) ;
90+ const instructionsEntry = await lstatIfExists ( instructionsFile ) ;
91+ if ( ! instructionsEntry ) {
92+ return true ;
93+ }
94+ if ( instructionsEntry . isSymbolicLink ( ) || ! instructionsEntry . isFile ( ) ) {
95+ return false ;
96+ }
97+
98+ const physicalInstructionsFile = await fs . realpath ( instructionsFile ) ;
99+ return isSameOrDescendantPath ( physicalWorkspaceRoot , physicalInstructionsFile ) ;
100+ } catch ( error ) {
101+ traceError ( 'Failed to validate the Copilot instructions destination:' , error ) ;
102+ return false ;
103+ }
104+ }
105+
11106export class NewScriptProject implements PythonProjectCreator {
12107 public readonly name = l10n . t ( 'newScript' ) ;
13108 public readonly displayName = l10n . t ( 'Script' ) ;
14- public readonly description = l10n . t ( 'Creates a new script folder in your current workspace' ) ;
109+ public readonly description = l10n . t ( 'Creates a new script in your current workspace' ) ;
15110 public readonly tooltip = new MarkdownString ( l10n . t ( 'Create a new Python script' ) ) ;
16111
17112 constructor ( private readonly projectManager : PythonProjectManager ) { }
18113
19114 async create ( options ?: PythonProjectCreatorOptions ) : Promise < PythonProject | Uri | undefined > {
20- // quick create (needs name, will always create venv and copilot instructions)
21- // not quick create
22- // ask for script file name
23- // ask if they want venv
24115 let scriptFileName = options ?. name ;
25116 let createCopilotInstructions : boolean | undefined ;
26117 if ( options ?. quickCreate === true ) {
27118 // If quickCreate is true, we should not prompt for any input
28119 if ( ! scriptFileName ) {
29120 throw new Error ( 'Script file name is required in quickCreate mode.' ) ;
30121 }
122+ if ( path . extname ( scriptFileName ) === '' ) {
123+ scriptFileName = `${ scriptFileName } .py` ;
124+ }
31125 createCopilotInstructions = true ;
32126 } else {
33127 //Prompt as quickCreate is false
@@ -37,23 +131,7 @@ export class NewScriptProject implements PythonProjectCreator {
37131 prompt : l10n . t ( 'What is the name of the script? (e.g. my_script.py)' ) ,
38132 ignoreFocusOut : true ,
39133 showBackButton : true ,
40- validateInput : ( value ) => {
41- // Ensure the filename ends with .py and follows valid naming conventions
42- if ( ! value . endsWith ( '.py' ) ) {
43- return l10n . t ( 'Script name must end with ".py".' ) ;
44- }
45- const baseName = value . replace ( / \. p y $ / , '' ) ;
46- // following PyPI (PEP 508) rules for package names
47- if ( ! / ^ ( [ a - z _ ] | [ a - z 0 - 9 _ ] [ a - z 0 - 9 . _ - ] * [ a - z 0 - 9 _ ] ) $ / i. test ( baseName ) ) {
48- return l10n . t (
49- 'Invalid script name. Use only letters, numbers, underscores, hyphens, or periods. Must start and end with a letter or number.' ,
50- ) ;
51- }
52- if ( / ^ [ - . _ 0 - 9 ] $ / i. test ( baseName ) ) {
53- return l10n . t ( 'Single-character script names cannot be a number, hyphen, or period.' ) ;
54- }
55- return null ;
56- } ,
134+ validateInput : validateScriptFileName ,
57135 } ) ;
58136 } catch ( ex ) {
59137 if ( ex === QuickInputButtons . Back ) {
@@ -67,64 +145,152 @@ export class NewScriptProject implements PythonProjectCreator {
67145 createCopilotInstructions = true ;
68146 }
69147 }
148+ }
149+ const validationError = validateScriptFileName ( scriptFileName ) ;
150+ if ( validationError ) {
151+ if ( options ?. quickCreate === true ) {
152+ throw new Error ( validationError ) ;
153+ }
154+ window . showErrorMessage ( validationError ) ;
155+ return undefined ;
156+ }
70157
71- // 1. Copy template folder
72- const newScriptTemplateFile = path . join ( NEW_PROJECT_TEMPLATES_FOLDER , 'new723ScriptTemplate' , 'script.py' ) ;
73- if ( ! ( await fs . pathExists ( newScriptTemplateFile ) ) ) {
74- window . showErrorMessage ( l10n . t ( 'Template file does not exist, aborting creation.' ) ) ;
75- traceError ( `Template file not found at: ${ newScriptTemplateFile } ` ) ;
158+ // 1. Copy template file
159+ const newScriptTemplateFile = path . join ( NEW_PROJECT_TEMPLATES_FOLDER , 'new723ScriptTemplate' , 'script.py' ) ;
160+ if ( ! ( await fs . pathExists ( newScriptTemplateFile ) ) ) {
161+ window . showErrorMessage ( l10n . t ( 'Template file does not exist, aborting creation.' ) ) ;
162+ traceError ( `Template file not found at: ${ newScriptTemplateFile } ` ) ;
163+ return undefined ;
164+ }
165+
166+ // Check if the destination folder is provided, otherwise use the first workspace folder.
167+ let destinationRootUri = options ?. rootUri ;
168+ let workspaceFolders : readonly WorkspaceFolder [ ] | undefined ;
169+ if ( ! destinationRootUri ) {
170+ workspaceFolders = getWorkspaceFolders ( ) ;
171+ if ( ! workspaceFolders || workspaceFolders . length === 0 ) {
172+ window . showErrorMessage ( l10n . t ( 'No workspace folder is open or provided, aborting creation.' ) ) ;
76173 return undefined ;
77174 }
175+ destinationRootUri = workspaceFolders [ 0 ] . uri ;
176+ }
78177
79- // Check if the destination folder is provided, otherwise use the first workspace folder
80- let destRoot = options ?. rootUri . fsPath ;
81- if ( ! destRoot ) {
82- const workspaceFolders = workspace . workspaceFolders ;
83- if ( ! workspaceFolders || workspaceFolders . length === 0 ) {
84- window . showErrorMessage ( l10n . t ( 'No workspace folder is open or provided, aborting creation.' ) ) ;
85- return undefined ;
86- }
87- destRoot = workspaceFolders [ 0 ] . uri . fsPath ;
178+ const destRoot = destinationRootUri . fsPath ;
179+ const resolvedDestRoot = path . resolve ( destRoot ) ;
180+ let workspaceFolder = getWorkspaceFolder ( destinationRootUri ) ;
181+ if ( ! workspaceFolder && destinationRootUri . scheme === 'file' ) {
182+ workspaceFolders ??= getWorkspaceFolders ( ) ;
183+ workspaceFolder = workspaceFolders
184+ ?. filter ( ( folder ) => isSameOrDescendantPath ( folder . uri . fsPath , resolvedDestRoot ) )
185+ . sort ( ( first , second ) => second . uri . fsPath . length - first . uri . fsPath . length ) [ 0 ] ;
186+ }
187+ if ( ! workspaceFolder ) {
188+ showErrorMessage ( l10n . t ( 'Destination folder must be inside an open workspace, aborting creation.' ) ) ;
189+ return undefined ;
190+ }
191+
192+ let physicalDestRoot : string ;
193+ let physicalWorkspaceRoot : string ;
194+ try {
195+ [ physicalDestRoot , physicalWorkspaceRoot ] = await Promise . all ( [
196+ fs . realpath ( resolvedDestRoot ) ,
197+ fs . realpath ( workspaceFolder . uri . fsPath ) ,
198+ ] ) ;
199+ } catch ( error ) {
200+ traceError ( 'Failed to resolve the destination or workspace folder:' , error ) ;
201+ showErrorMessage ( l10n . t ( 'Unable to resolve the destination folder inside the open workspace.' ) ) ;
202+ return undefined ;
203+ }
204+ if ( ! isSameOrDescendantPath ( physicalWorkspaceRoot , physicalDestRoot ) ) {
205+ showErrorMessage ( l10n . t ( 'Destination folder must resolve inside the open workspace, aborting creation.' ) ) ;
206+ return undefined ;
207+ }
208+
209+ const identityRootUri =
210+ destinationRootUri . scheme === 'file' && workspaceFolder . uri . scheme !== 'file'
211+ ? uriForFileRootInWorkspace ( resolvedDestRoot , workspaceFolder )
212+ : destinationRootUri ;
213+ const scriptDestination = path . resolve ( resolvedDestRoot , scriptFileName ) ;
214+ const relativeScriptPath = path . relative ( resolvedDestRoot , scriptDestination ) ;
215+ if (
216+ relativeScriptPath === '' ||
217+ relativeScriptPath === '..' ||
218+ relativeScriptPath . startsWith ( `..${ path . sep } ` ) ||
219+ path . isAbsolute ( relativeScriptPath )
220+ ) {
221+ const containmentError = l10n . t ( 'Script name must resolve to a file inside the destination folder.' ) ;
222+ if ( options ?. quickCreate === true ) {
223+ throw new Error ( containmentError ) ;
88224 }
225+ window . showErrorMessage ( containmentError ) ;
226+ return undefined ;
227+ }
89228
90- // Check if the destination folder already exists
91- const scriptDestination = path . join ( destRoot , scriptFileName ) ;
92- if ( await fs . pathExists ( scriptDestination ) ) {
93- window . showErrorMessage (
94- l10n . t (
95- 'A script file by that name already exists, aborting creation. Please retry with a unique script name given your workspace.' ,
96- ) ,
97- ) ;
98- return undefined ;
229+ if (
230+ createCopilotInstructions &&
231+ ! ( await isCopilotInstructionsDestinationContained (
232+ resolvedDestRoot ,
233+ physicalDestRoot ,
234+ physicalWorkspaceRoot ,
235+ ) )
236+ ) {
237+ showErrorMessage (
238+ l10n . t ( 'Copilot instructions must be stored inside the open workspace, aborting creation.' ) ,
239+ ) ;
240+ return undefined ;
241+ }
242+
243+ // Check if the destination file already exists
244+ if ( await fs . pathExists ( scriptDestination ) ) {
245+ window . showErrorMessage (
246+ l10n . t (
247+ 'A script file by that name already exists, aborting creation. Please retry with a unique script name given your workspace.' ,
248+ ) ,
249+ ) ;
250+ return undefined ;
251+ }
252+ await fs . copy ( newScriptTemplateFile , scriptDestination ) ;
253+
254+ // 2. Replace 'script_name' in the file using a helper (just script name remove .py)
255+ await replaceInFilesAndNames ( scriptDestination , 'script_name' , scriptFileName . replace ( / \. p y $ / , '' ) ) ;
256+
257+ // Add the created script to the project manager
258+ const createdScript : PythonProject = {
259+ name : scriptFileName ,
260+ uri : identityRootUri . with ( {
261+ path : path . posix . join ( identityRootUri . path , scriptFileName ) ,
262+ } ) ,
263+ } ;
264+ try {
265+ await this . projectManager . add ( createdScript ) ;
266+ } catch ( registrationError ) {
267+ try {
268+ this . projectManager . remove ( createdScript ) ;
269+ } catch ( rollbackError ) {
270+ traceError ( 'Failed to remove the new script project after registration failed:' , rollbackError ) ;
99271 }
100- await fs . copy ( newScriptTemplateFile , scriptDestination ) ;
101-
102- // 2. Replace 'script_name' in the file using a helper (just script name remove .py)
103- await replaceInFilesAndNames ( scriptDestination , 'script_name' , scriptFileName . replace ( / \. p y $ / , '' ) ) ;
104-
105- // 3. add custom github copilot instructions
106- if ( createCopilotInstructions ) {
107- const packageInstructionsPath = path . join (
108- NEW_PROJECT_TEMPLATES_FOLDER ,
109- 'copilot-instructions-text' ,
110- 'script-copilot-instructions.md' ,
111- ) ;
112- await manageCopilotInstructionsFile ( destRoot , packageInstructionsPath , [
113- { searchValue : '<script_name>' , replaceValue : scriptFileName } ,
114- ] ) ;
272+ try {
273+ await fs . remove ( scriptDestination ) ;
274+ } catch ( rollbackError ) {
275+ traceError ( 'Failed to delete the new script after registration failed:' , rollbackError ) ;
115276 }
277+ throw registrationError ;
278+ }
116279
117- // Add the created script to the project manager
118- const createdScript : PythonProject | undefined = {
119- name : scriptFileName ,
120- uri : Uri . file ( scriptDestination ) ,
121- } ;
122- this . projectManager . add ( createdScript ) ;
280+ // 3. add custom github copilot instructions
281+ if ( createCopilotInstructions ) {
282+ const packageInstructionsPath = path . join (
283+ NEW_PROJECT_TEMPLATES_FOLDER ,
284+ 'copilot-instructions-text' ,
285+ 'script-copilot-instructions.md' ,
286+ ) ;
287+ await manageCopilotInstructionsFile ( destRoot , packageInstructionsPath , [
288+ { searchValue : '<script_name>' , replaceValue : scriptFileName } ,
289+ ] ) ;
290+ }
123291
124- await showTextDocument ( createdScript . uri ) ;
292+ await showTextDocument ( createdScript . uri ) ;
125293
126- return createdScript ;
127- }
128- return undefined ;
294+ return createdScript ;
129295 }
130296}
0 commit comments