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 { isSameOrParentPath , isWindowsReservedDeviceName } from '../../common/utils/pathUtils' ;
8+ import { showErrorMessage , showInputBoxWithButtons , showTextDocument } from '../../common/window.apis' ;
9+ import { getWorkspaceFolder , getWorkspaceFolders } from '../../common/workspace.apis' ;
810import { PythonProjectManager } from '../../internal.api' ;
911import { isCopilotInstalled , manageCopilotInstructionsFile , replaceInFilesAndNames } from './creationHelpers' ;
1012
13+ function validateScriptFileName ( value : string ) : string | null {
14+ const pathSegments = value . split ( / [ \\ / ] / ) ;
15+ if ( pathSegments . length !== 1 || pathSegments . includes ( '..' ) ) {
16+ return l10n . t ( 'Script name must be a file name without path separators or traversal.' ) ;
17+ }
18+ if ( ! value . endsWith ( '.py' ) ) {
19+ return l10n . t ( 'Script name must end with ".py".' ) ;
20+ }
21+ const baseName = value . replace ( / \. p y $ / , '' ) ;
22+ if ( isWindowsReservedDeviceName ( baseName ) ) {
23+ return l10n . t ( 'Script name uses a reserved Windows device name.' ) ;
24+ }
25+ // following PyPI (PEP 508) rules for package names
26+ if ( ! / ^ ( [ a - z _ ] | [ a - z 0 - 9 _ ] [ a - z 0 - 9 . _ - ] * [ a - z 0 - 9 _ ] ) $ / i. test ( baseName ) ) {
27+ return l10n . t (
28+ 'Invalid script name. Use only letters, numbers, underscores, hyphens, or periods. Must start and end with a letter or number.' ,
29+ ) ;
30+ }
31+ if ( / ^ [ - . _ 0 - 9 ] $ / i. test ( baseName ) ) {
32+ return l10n . t ( 'Single-character script names cannot be a number, hyphen, or period.' ) ;
33+ }
34+ return null ;
35+ }
36+
37+ function uriForFileRootInWorkspace ( rootPath : string , workspaceFolder : WorkspaceFolder ) : Uri {
38+ const relativeRootPath = path . relative ( path . resolve ( workspaceFolder . uri . fsPath ) , path . resolve ( rootPath ) ) ;
39+ const pathSegments = relativeRootPath . split ( / [ \\ / ] / ) . filter ( ( segment ) => segment . length > 0 ) ;
40+ return workspaceFolder . uri . with ( {
41+ path : path . posix . join ( workspaceFolder . uri . path , ...pathSegments ) ,
42+ } ) ;
43+ }
44+
1145export class NewScriptProject implements PythonProjectCreator {
1246 public readonly name = l10n . t ( 'newScript' ) ;
1347 public readonly displayName = l10n . t ( 'Script' ) ;
14- public readonly description = l10n . t ( 'Creates a new script folder in your current workspace' ) ;
48+ public readonly description = l10n . t ( 'Creates a new script in your current workspace' ) ;
1549 public readonly tooltip = new MarkdownString ( l10n . t ( 'Create a new Python script' ) ) ;
1650
1751 constructor ( private readonly projectManager : PythonProjectManager ) { }
1852
1953 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
2454 let scriptFileName = options ?. name ;
2555 let createCopilotInstructions : boolean | undefined ;
2656 if ( options ?. quickCreate === true ) {
2757 // If quickCreate is true, we should not prompt for any input
2858 if ( ! scriptFileName ) {
2959 throw new Error ( 'Script file name is required in quickCreate mode.' ) ;
3060 }
61+ if ( path . extname ( scriptFileName ) === '' ) {
62+ scriptFileName = `${ scriptFileName } .py` ;
63+ }
3164 createCopilotInstructions = true ;
3265 } else {
3366 //Prompt as quickCreate is false
@@ -37,23 +70,7 @@ export class NewScriptProject implements PythonProjectCreator {
3770 prompt : l10n . t ( 'What is the name of the script? (e.g. my_script.py)' ) ,
3871 ignoreFocusOut : true ,
3972 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- } ,
73+ validateInput : validateScriptFileName ,
5774 } ) ;
5875 } catch ( ex ) {
5976 if ( ex === QuickInputButtons . Back ) {
@@ -67,64 +84,143 @@ export class NewScriptProject implements PythonProjectCreator {
6784 createCopilotInstructions = true ;
6885 }
6986 }
87+ }
88+ const validationError = validateScriptFileName ( scriptFileName ) ;
89+ if ( validationError ) {
90+ if ( options ?. quickCreate === true ) {
91+ throw new Error ( validationError ) ;
92+ }
93+ window . showErrorMessage ( validationError ) ;
94+ return undefined ;
95+ }
7096
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 } ` ) ;
97+ // 1. Copy template file
98+ const newScriptTemplateFile = path . join ( NEW_PROJECT_TEMPLATES_FOLDER , 'newInlineScriptTemplate' , 'script.py' ) ;
99+ if ( ! ( await fs . pathExists ( newScriptTemplateFile ) ) ) {
100+ window . showErrorMessage ( l10n . t ( 'Template file does not exist, aborting creation.' ) ) ;
101+ traceError ( `Template file not found at: ${ newScriptTemplateFile } ` ) ;
102+ return undefined ;
103+ }
104+
105+ // Check if the destination folder is provided, otherwise use the first workspace folder.
106+ let destinationRootUri = options ?. rootUri ;
107+ let workspaceFolders : readonly WorkspaceFolder [ ] | undefined ;
108+ if ( ! destinationRootUri ) {
109+ workspaceFolders = getWorkspaceFolders ( ) ;
110+ if ( ! workspaceFolders || workspaceFolders . length === 0 ) {
111+ window . showErrorMessage ( l10n . t ( 'No workspace folder is open or provided, aborting creation.' ) ) ;
76112 return undefined ;
77113 }
114+ destinationRootUri = workspaceFolders [ 0 ] . uri ;
115+ }
78116
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 ;
88- }
117+ const destRoot = destinationRootUri . fsPath ;
118+ const resolvedDestRoot = path . resolve ( destRoot ) ;
119+ let workspaceFolder = getWorkspaceFolder ( destinationRootUri ) ;
120+ if ( ! workspaceFolder && destinationRootUri . scheme === 'file' ) {
121+ workspaceFolders ??= getWorkspaceFolders ( ) ;
122+ workspaceFolder = workspaceFolders
123+ ?. filter ( ( folder ) => isSameOrParentPath ( folder . uri . fsPath , resolvedDestRoot ) )
124+ . sort ( ( first , second ) => second . uri . fsPath . length - first . uri . fsPath . length ) [ 0 ] ;
125+ }
126+ if ( ! workspaceFolder ) {
127+ showErrorMessage ( l10n . t ( 'Destination folder must be inside an open workspace, aborting creation.' ) ) ;
128+ return undefined ;
129+ }
89130
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 ;
131+ let physicalDestRoot : string ;
132+ let physicalWorkspaceRoot : string ;
133+ try {
134+ [ physicalDestRoot , physicalWorkspaceRoot ] = await Promise . all ( [
135+ fs . realpath ( resolvedDestRoot ) ,
136+ fs . realpath ( workspaceFolder . uri . fsPath ) ,
137+ ] ) ;
138+ } catch ( error ) {
139+ traceError ( 'Failed to resolve the destination or workspace folder:' , error ) ;
140+ showErrorMessage ( l10n . t ( 'Unable to resolve the destination folder inside the open workspace.' ) ) ;
141+ return undefined ;
142+ }
143+ if ( ! isSameOrParentPath ( physicalWorkspaceRoot , physicalDestRoot ) ) {
144+ showErrorMessage ( l10n . t ( 'Destination folder must resolve inside the open workspace, aborting creation.' ) ) ;
145+ return undefined ;
146+ }
147+
148+ const identityRootUri =
149+ destinationRootUri . scheme === 'file' && workspaceFolder . uri . scheme !== 'file'
150+ ? uriForFileRootInWorkspace ( resolvedDestRoot , workspaceFolder )
151+ : destinationRootUri ;
152+ const scriptDestination = path . resolve ( resolvedDestRoot , scriptFileName ) ;
153+ const relativeScriptPath = path . relative ( resolvedDestRoot , scriptDestination ) ;
154+ if (
155+ relativeScriptPath === '' ||
156+ relativeScriptPath === '..' ||
157+ relativeScriptPath . startsWith ( `..${ path . sep } ` ) ||
158+ path . isAbsolute ( relativeScriptPath )
159+ ) {
160+ const containmentError = l10n . t ( 'Script name must resolve to a file inside the destination folder.' ) ;
161+ if ( options ?. quickCreate === true ) {
162+ throw new Error ( containmentError ) ;
99163 }
100- await fs . copy ( newScriptTemplateFile , scriptDestination ) ;
164+ window . showErrorMessage ( containmentError ) ;
165+ return undefined ;
166+ }
101167
102- // 2. Replace 'script_name' in the file using a helper (just script name remove .py)
168+ // Check if the destination file already exists
169+ if ( await fs . pathExists ( scriptDestination ) ) {
170+ window . showErrorMessage (
171+ l10n . t (
172+ 'A script file by that name already exists, aborting creation. Please retry with a unique script name given your workspace.' ,
173+ ) ,
174+ ) ;
175+ return undefined ;
176+ }
177+ // Build the project entry up front so copying the template, substituting
178+ // the script name, and registering the project share one cleanup boundary:
179+ // if any step fails, the partially created script is removed so a retry
180+ // starts from a clean state.
181+ const createdScript : PythonProject = {
182+ name : scriptFileName ,
183+ uri : identityRootUri . with ( {
184+ path : path . posix . join ( identityRootUri . path , scriptFileName ) ,
185+ } ) ,
186+ } ;
187+ let projectRegistrationAttempted = false ;
188+ try {
189+ await fs . copy ( newScriptTemplateFile , scriptDestination ) ;
190+ // Replace 'script_name' in the file (script name without the .py suffix).
103191 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- ] ) ;
192+ projectRegistrationAttempted = true ;
193+ await this . projectManager . add ( createdScript ) ;
194+ } catch ( creationError ) {
195+ if ( projectRegistrationAttempted ) {
196+ try {
197+ this . projectManager . remove ( createdScript ) ;
198+ } catch ( rollbackError ) {
199+ traceError ( 'Failed to remove the new script project after creation failed:' , rollbackError ) ;
200+ }
115201 }
202+ try {
203+ await fs . remove ( scriptDestination ) ;
204+ } catch ( rollbackError ) {
205+ traceError ( 'Failed to delete the new script after creation failed:' , rollbackError ) ;
206+ }
207+ throw creationError ;
208+ }
116209
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 ) ;
210+ // 3. add custom github copilot instructions
211+ if ( createCopilotInstructions ) {
212+ const packageInstructionsPath = path . join (
213+ NEW_PROJECT_TEMPLATES_FOLDER ,
214+ 'copilot-instructions-text' ,
215+ 'script-copilot-instructions.md' ,
216+ ) ;
217+ await manageCopilotInstructionsFile ( destRoot , packageInstructionsPath , [
218+ { searchValue : '<script_name>' , replaceValue : scriptFileName } ,
219+ ] ) ;
220+ }
123221
124- await showTextDocument ( createdScript . uri ) ;
222+ await showTextDocument ( createdScript . uri ) ;
125223
126- return createdScript ;
127- }
128- return undefined ;
224+ return createdScript ;
129225 }
130226}
0 commit comments