Skip to content

Commit 08ca7ee

Browse files
fix: make script template creation reliable
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 8137950 commit 08ca7ee

5 files changed

Lines changed: 930 additions & 85 deletions

File tree

docs/managing-python-projects.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,9 +75,9 @@ Use this to scaffold a brand new Python project with the correct structure and f
7575
- **Package**: A structured Python package with `pyproject.toml`, tests folder, and package directory
7676
- **Script**: A simple standalone Python file using PEP 723 inline metadata
7777
4. Enter a name for your project.
78-
5. Choose whether to create a virtual environment.
78+
5. If you're creating a package, choose whether to create a virtual environment.
7979

80-
The extension creates the project structure, adds it to your workspace, and optionally creates a virtual environment.
80+
The extension creates the project structure and adds it to your workspace. For packages, it can also create a virtual environment.
8181

8282
#### Package template structure
8383

files/templates/copilot-instructions-text/script-copilot-instructions.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Copilot Instructions for <script_name>
22

3-
- The script `<script_name>` is a Python python project within the workspace.
4-
- It has inline script metadata (as proposed by PEP 723) that defines the script name, required python version, and dependencies.
3+
- The script `<script_name>` is a Python project within the workspace.
4+
- It has inline script metadata (as proposed by PEP 723) that defines the required Python version and dependencies.
55
- If imports which require a specific Python version or dependencies are added, keep the inline script metadata up to date.
66
- You need to call the `Get Python Environment Information` tool on the `<script_name>` path to get the Python executable details.
77
- Substitute the Python executable you get from the `Get Python Environment Information` tool anywhere you see `<python>` in these instructions.

files/templates/new723ScriptTemplate/script.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
# /// script
2-
# requires-python = ">=X.XX" TODO: Update this to the minimum Python version you want to support
3-
# dependencies = [
4-
# TODO: Add any dependencies your script requires
5-
# ]
2+
# requires-python = ">=3.9"
3+
# dependencies = []
4+
# # Add dependency requirement strings to the array above as needed.
65
# ///
76

87
# TODO: Update the main function to your needs or remove it.
Lines changed: 238 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,127 @@
11
import * as fs from 'fs-extra';
22
import * 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';
44
import { PythonProject, PythonProjectCreator, PythonProjectCreatorOptions } from '../../api';
55
import { NEW_PROJECT_TEMPLATES_FOLDER } from '../../common/constants';
66
import { 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';
811
import { PythonProjectManager } from '../../internal.api';
912
import { 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(/\.py$/, '');
23+
const deviceBaseName = baseName.split('.')[0];
24+
if (isWindows() && /^(con|prn|aux|nul|com[1-9]|lpt[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-z0-9_][a-z0-9._-]*[a-z0-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+
11106
export 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(/\.py$/, '');
46-
// following PyPI (PEP 508) rules for package names
47-
if (!/^([a-z_]|[a-z0-9_][a-z0-9._-]*[a-z0-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(/\.py$/, ''));
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(/\.py$/, ''));
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

Comments
 (0)