From a79cbe05bfaf97208cd127c5b950dfdc97bf5f9b Mon Sep 17 00:00:00 2001 From: Ximiaw Date: Thu, 6 Aug 2026 17:38:12 +0800 Subject: [PATCH 1/5] feat: add mcpp.newProject command to scaffold and open projects --- package.json | 5 +++ src/cliController.ts | 70 ++++++++++++++++++++++++++++++++++++++++++ src/commands.ts | 1 + src/extension.ts | 17 +++++++++- test/artifacts.test.ts | 30 ++++++++++++++++++ test/commands.test.ts | 1 + 6 files changed, 123 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index e83188c..071823f 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "onCommand:mcpp.refreshCompilationDatabase", "onCommand:mcpp.checkModuleSupport", "onCommand:mcpp.showMenu", + "onCommand:mcpp.newProject", "onCommand:mcpp.build", "onCommand:mcpp.run", "onCommand:mcpp.test", @@ -68,6 +69,10 @@ "command": "mcpp.showMenu", "title": "mcpp: 打开快捷菜单" }, + { + "command": "mcpp.newProject", + "title": "mcpp: 新建工程" + }, { "command": "mcpp.build", "title": "mcpp: 构建" diff --git a/src/cliController.ts b/src/cliController.ts index 77766cf..5324bfd 100644 --- a/src/cliController.ts +++ b/src/cliController.ts @@ -1,3 +1,4 @@ +import { join } from "node:path"; import process from "node:process"; import * as vscode from "vscode"; @@ -24,6 +25,8 @@ import { } from "./tasks"; import { CLI_COMMANDS, quickMenuItems, quickMenuStatusText } from "./commands"; +export const PENDING_NEW_PROJECT_KEY = "mcpp.pendingNewProject"; + export interface McppCliControllerOptions { output: vscode.OutputChannel; currentProject: () => McppProjectDiscovery | undefined; @@ -33,6 +36,7 @@ export interface McppCliControllerOptions { completion: TaskCompletion, ) => Promise; isTrusted: () => boolean; + globalState: vscode.Memento; } interface ToolchainPickItem extends vscode.QuickPickItem { @@ -82,6 +86,7 @@ export class McppCliController { const disposables: vscode.Disposable[] = [ this.status, vscode.commands.registerCommand(CLI_COMMANDS.showMenu, this.guarded(() => this.showMenu())), + vscode.commands.registerCommand(CLI_COMMANDS.newProject, this.guarded(() => this.newProject())), vscode.commands.registerCommand(CLI_COMMANDS.build, this.guarded(() => this.runProjectTask("build"))), vscode.commands.registerCommand(CLI_COMMANDS.run, this.guarded(() => this.runProjectTask("run"))), vscode.commands.registerCommand(CLI_COMMANDS.test, this.guarded(() => this.runProjectTask("test"))), @@ -493,6 +498,71 @@ export class McppCliController { } } + public async newProject(): Promise { + if (!this.requireTrusted()) { + return; + } + + const input = await vscode.window.showInputBox({ + title: "新建 mcpp 工程(1/2)", + prompt: "输入项目名,将在所选位置创建同名项目文件夹", + placeHolder: "hello-mcpp", + validateInput: (value) => { + const trimmed = value.trim(); + if (trimmed.length === 0) { + return "项目名不能为空"; + } + if (/[\\/]/.test(trimmed)) { + return "项目名不能包含路径分隔符"; + } + return undefined; + }, + }); + if (input === undefined) { + return; + } + const projectName = input.trim(); + + const picked = await vscode.window.showOpenDialog({ + title: "选择项目位置(2/2)", + canSelectFiles: false, + canSelectFolders: true, + canSelectMany: false, + openLabel: "在此创建项目", + }); + const location = picked?.[0]; + if (location === undefined) { + return; + } + + const projectRoot = join(location.fsPath, projectName); + const confirmCreate = "创建并打开"; + const choice = await vscode.window.showWarningMessage( + `将在 ${location.fsPath} 执行 “mcpp new ${projectName}”,创建项目文件夹 ${projectRoot} 并打开它。`, + { modal: true }, + confirmCreate, + ); + if (choice !== confirmCreate) { + return; + } + + const executable = this.mcppExecutable(undefined); + const args = mcppCommandArguments("new", projectName); + const result = await runProcess(executable, args, location.fsPath); + this.appendShortCommand("新建工程", executable, args, result); + if (result.exitCode !== 0) { + await vscode.window.showErrorMessage( + `mcpp new ${projectName} 失败(退出码 ${result.exitCode})。请查看 mcpp 输出频道。`, + ); + return; + } + + // vscode.openFolder 会重载窗口;通过 globalState 把刷新请求带到新窗口, + // 由 activate() 在新窗口中执行 mcpp.refreshCompilationDatabase 并激活 clangd。 + await this.options.globalState.update(PENDING_NEW_PROJECT_KEY, projectRoot); + await vscode.commands.executeCommand("vscode.openFolder", vscode.Uri.file(projectRoot)); + } + private guarded(operation: () => Promise): () => Promise { return async () => { try { diff --git a/src/commands.ts b/src/commands.ts index bd1c8fb..a28796c 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -1,5 +1,6 @@ export const CLI_COMMANDS = { showMenu: "mcpp.showMenu", + newProject: "mcpp.newProject", build: "mcpp.build", run: "mcpp.run", test: "mcpp.test", diff --git a/src/extension.ts b/src/extension.ts index 802b186..1def283 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -24,7 +24,7 @@ import { xlingsInstallArgs, } from "./llvmTools"; import { CLI_COMMANDS } from "./commands"; -import { McppCliController } from "./cliController"; +import { McppCliController, PENDING_NEW_PROJECT_KEY } from "./cliController"; import { runClangdCheck, runToolVersion, type ToolVersionResult } from "./process"; import { configurationReadyAfterRestart, @@ -1022,6 +1022,7 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi currentProject: findCurrentProject, afterProjectTask, isTrusted: () => vscode.workspace.isTrusted, + globalState: extensionContext.globalState, }); extensionContext.subscriptions.push( @@ -1218,6 +1219,20 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi const message = error instanceof Error ? error.message : String(error); appendOutputLine(output, `[自动配置] ${message}`); } + + // 新建工程流程在打开文件夹前排入了刷新请求;窗口重载后在这里兑现。 + // 刷新会跑完整 mcpp build,不能 await:activate 未完成时按钮等命令会排队, + // 表现为编辑器标题按钮点击无反应。 + const pendingNewProject = extensionContext.globalState.get(PENDING_NEW_PROJECT_KEY); + if (pendingNewProject !== undefined) { + await extensionContext.globalState.update(PENDING_NEW_PROJECT_KEY, undefined); + if (findCurrentProject()?.root === pendingNewProject) { + void vscode.commands.executeCommand(COMMAND_REFRESH).then(undefined, (error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + appendOutputLine(output, `[新建工程] ${message}`); + }); + } + } } export function deactivate(): void { diff --git a/test/artifacts.test.ts b/test/artifacts.test.ts index f2d24cb..e3373db 100644 --- a/test/artifacts.test.ts +++ b/test/artifacts.test.ts @@ -39,6 +39,7 @@ test("declares the official clangd dependency and mcpp commands", () => { manifest.contributes?.commands?.map((command) => command.command), [ "mcpp.showMenu", + "mcpp.newProject", "mcpp.build", "mcpp.run", "mcpp.test", @@ -231,6 +232,35 @@ test("泛化 triple 工具链由 mcpp 最终校验", () => { assert.match(method, /可能携带 target 语义.*最终由 mcpp 校验/s); }); +test("新建工程选定位置后先确认再创建,打开前排入刷新请求", () => { + const source = readFileSync(path.join(root, "src/cliController.ts"), "utf8"); + const start = source.indexOf("public async newProject"); + const end = source.indexOf("private guarded", start); + assert.notEqual(start, -1); + assert.notEqual(end, -1); + + const method = source.slice(start, end); + const locationPick = method.indexOf("showOpenDialog"); + const confirm = method.indexOf("showWarningMessage"); + const create = method.indexOf("runProcess"); + const pending = method.indexOf("globalState.update(PENDING_NEW_PROJECT_KEY"); + const open = method.indexOf('executeCommand("vscode.openFolder"'); + assert.ok(locationPick >= 0 && locationPick < confirm); + assert.ok(confirm >= 0 && confirm < create); + assert.ok(create >= 0 && create < pending); + assert.ok(pending >= 0 && pending < open); +}); + +test("打开新建工程后刷新编译数据库", () => { + const source = readFileSync(path.join(root, "src/extension.ts"), "utf8"); + const start = source.indexOf("globalState.get(PENDING_NEW_PROJECT_KEY)"); + assert.notEqual(start, -1); + + // 不能 await 刷新:activate 未完成时按钮等命令会排队,表现为点击无反应。 + const refresh = source.indexOf("void vscode.commands.executeCommand(COMMAND_REFRESH)", start); + assert.ok(refresh >= 0); +}); + test("声明 GitHub 仓库和扩展图标", () => { const manifest = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")) as PackageManifest; assert.equal(manifest.icon, "images/logo.png"); diff --git a/test/commands.test.ts b/test/commands.test.ts index 6e59248..843a2fc 100644 --- a/test/commands.test.ts +++ b/test/commands.test.ts @@ -10,6 +10,7 @@ test("状态栏快捷菜单名称与模块状态易于区分", () => { test("CLI 命令覆盖项目、工具链和 IDE", () => { assert.deepEqual(Object.values(CLI_COMMANDS), [ "mcpp.showMenu", + "mcpp.newProject", "mcpp.build", "mcpp.run", "mcpp.test", From d8db658746295c0a2fb3b2fbb1b3dbcb48fc573c Mon Sep 17 00:00:00 2001 From: Ximiaw Date: Fri, 7 Aug 2026 22:23:34 +0800 Subject: [PATCH 2/5] fix: harden new-project validation, drop pending auto-build state --- src/cliController.ts | 25 +++++++++---------------- src/extension.ts | 17 +---------------- src/newProject.ts | 23 +++++++++++++++++++++++ test/artifacts.test.ts | 24 +++++++++++------------- test/newProject.test.ts | 36 ++++++++++++++++++++++++++++++++++++ 5 files changed, 80 insertions(+), 45 deletions(-) create mode 100644 src/newProject.ts create mode 100644 test/newProject.test.ts diff --git a/src/cliController.ts b/src/cliController.ts index 5324bfd..56d880c 100644 --- a/src/cliController.ts +++ b/src/cliController.ts @@ -1,3 +1,4 @@ +import { existsSync } from "node:fs"; import { join } from "node:path"; import process from "node:process"; @@ -24,8 +25,7 @@ import { type TaskCompletion, } from "./tasks"; import { CLI_COMMANDS, quickMenuItems, quickMenuStatusText } from "./commands"; - -export const PENDING_NEW_PROJECT_KEY = "mcpp.pendingNewProject"; +import { validateNewProjectName } from "./newProject"; export interface McppCliControllerOptions { output: vscode.OutputChannel; @@ -36,7 +36,6 @@ export interface McppCliControllerOptions { completion: TaskCompletion, ) => Promise; isTrusted: () => boolean; - globalState: vscode.Memento; } interface ToolchainPickItem extends vscode.QuickPickItem { @@ -507,16 +506,7 @@ export class McppCliController { title: "新建 mcpp 工程(1/2)", prompt: "输入项目名,将在所选位置创建同名项目文件夹", placeHolder: "hello-mcpp", - validateInput: (value) => { - const trimmed = value.trim(); - if (trimmed.length === 0) { - return "项目名不能为空"; - } - if (/[\\/]/.test(trimmed)) { - return "项目名不能包含路径分隔符"; - } - return undefined; - }, + validateInput: validateNewProjectName, }); if (input === undefined) { return; @@ -536,6 +526,10 @@ export class McppCliController { } const projectRoot = join(location.fsPath, projectName); + if (existsSync(projectRoot)) { + await vscode.window.showErrorMessage(`目标路径已存在:${projectRoot}。请更换项目名或位置。`); + return; + } const confirmCreate = "创建并打开"; const choice = await vscode.window.showWarningMessage( `将在 ${location.fsPath} 执行 “mcpp new ${projectName}”,创建项目文件夹 ${projectRoot} 并打开它。`, @@ -557,9 +551,8 @@ export class McppCliController { return; } - // vscode.openFolder 会重载窗口;通过 globalState 把刷新请求带到新窗口, - // 由 activate() 在新窗口中执行 mcpp.refreshCompilationDatabase 并激活 clangd。 - await this.options.globalState.update(PENDING_NEW_PROJECT_KEY, projectRoot); + // 契约:创建并打开工程。打开后的构建交给用户手动触发(或后续 #5 的 + // IDE configure 流程),避免与缺少 CDB 时的 configure 重复执行。 await vscode.commands.executeCommand("vscode.openFolder", vscode.Uri.file(projectRoot)); } diff --git a/src/extension.ts b/src/extension.ts index 1def283..802b186 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -24,7 +24,7 @@ import { xlingsInstallArgs, } from "./llvmTools"; import { CLI_COMMANDS } from "./commands"; -import { McppCliController, PENDING_NEW_PROJECT_KEY } from "./cliController"; +import { McppCliController } from "./cliController"; import { runClangdCheck, runToolVersion, type ToolVersionResult } from "./process"; import { configurationReadyAfterRestart, @@ -1022,7 +1022,6 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi currentProject: findCurrentProject, afterProjectTask, isTrusted: () => vscode.workspace.isTrusted, - globalState: extensionContext.globalState, }); extensionContext.subscriptions.push( @@ -1219,20 +1218,6 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi const message = error instanceof Error ? error.message : String(error); appendOutputLine(output, `[自动配置] ${message}`); } - - // 新建工程流程在打开文件夹前排入了刷新请求;窗口重载后在这里兑现。 - // 刷新会跑完整 mcpp build,不能 await:activate 未完成时按钮等命令会排队, - // 表现为编辑器标题按钮点击无反应。 - const pendingNewProject = extensionContext.globalState.get(PENDING_NEW_PROJECT_KEY); - if (pendingNewProject !== undefined) { - await extensionContext.globalState.update(PENDING_NEW_PROJECT_KEY, undefined); - if (findCurrentProject()?.root === pendingNewProject) { - void vscode.commands.executeCommand(COMMAND_REFRESH).then(undefined, (error: unknown) => { - const message = error instanceof Error ? error.message : String(error); - appendOutputLine(output, `[新建工程] ${message}`); - }); - } - } } export function deactivate(): void { diff --git a/src/newProject.ts b/src/newProject.ts new file mode 100644 index 0000000..bffc4d8 --- /dev/null +++ b/src/newProject.ts @@ -0,0 +1,23 @@ +/** + * 新建工程的项目名校验。返回错误提示;undefined 表示合法。 + * + * 项目名随后作为 `mcpp new ` 的 argv 传入:参数数组只能防 shell 注入, + * 不能阻止 mcpp 自身把名字解析为 CLI 选项(如 --template),所以这里拒绝 + * `-` 前缀以及 `.`、`..`。 + */ +export function validateNewProjectName(input: string): string | undefined { + const name = input.trim(); + if (name.length === 0) { + return "项目名不能为空"; + } + if (/[\\/]/.test(name)) { + return "项目名不能包含路径分隔符"; + } + if (name.startsWith("-")) { + return "项目名不能以 - 开头,否则会被 mcpp 解析为命令行选项"; + } + if (name === "." || name === "..") { + return "项目名不能是 . 或 .."; + } + return undefined; +} diff --git a/test/artifacts.test.ts b/test/artifacts.test.ts index e3373db..47eae62 100644 --- a/test/artifacts.test.ts +++ b/test/artifacts.test.ts @@ -232,7 +232,7 @@ test("泛化 triple 工具链由 mcpp 最终校验", () => { assert.match(method, /可能携带 target 语义.*最终由 mcpp 校验/s); }); -test("新建工程选定位置后先确认再创建,打开前排入刷新请求", () => { +test("新建工程先校验目标路径再确认创建,成功后只打开不构建", () => { const source = readFileSync(path.join(root, "src/cliController.ts"), "utf8"); const start = source.indexOf("public async newProject"); const end = source.indexOf("private guarded", start); @@ -241,24 +241,22 @@ test("新建工程选定位置后先确认再创建,打开前排入刷新请 const method = source.slice(start, end); const locationPick = method.indexOf("showOpenDialog"); + const exists = method.indexOf("existsSync(projectRoot)"); const confirm = method.indexOf("showWarningMessage"); const create = method.indexOf("runProcess"); - const pending = method.indexOf("globalState.update(PENDING_NEW_PROJECT_KEY"); const open = method.indexOf('executeCommand("vscode.openFolder"'); - assert.ok(locationPick >= 0 && locationPick < confirm); + assert.match(method, /validateNewProjectName/); + assert.ok(locationPick >= 0 && locationPick < exists); + assert.ok(exists >= 0 && exists < confirm); assert.ok(confirm >= 0 && confirm < create); - assert.ok(create >= 0 && create < pending); - assert.ok(pending >= 0 && pending < open); + assert.ok(create >= 0 && create < open); }); -test("打开新建工程后刷新编译数据库", () => { - const source = readFileSync(path.join(root, "src/extension.ts"), "utf8"); - const start = source.indexOf("globalState.get(PENDING_NEW_PROJECT_KEY)"); - assert.notEqual(start, -1); - - // 不能 await 刷新:activate 未完成时按钮等命令会排队,表现为点击无反应。 - const refresh = source.indexOf("void vscode.commands.executeCommand(COMMAND_REFRESH)", start); - assert.ok(refresh >= 0); +test("新建工程契约是创建并打开,不自动构建", () => { + const controller = readFileSync(path.join(root, "src/cliController.ts"), "utf8"); + const extension = readFileSync(path.join(root, "src/extension.ts"), "utf8"); + assert.doesNotMatch(controller, /globalState|PENDING_NEW_PROJECT/); + assert.doesNotMatch(extension, /PENDING_NEW_PROJECT/); }); test("声明 GitHub 仓库和扩展图标", () => { diff --git a/test/newProject.test.ts b/test/newProject.test.ts new file mode 100644 index 0000000..615f7ca --- /dev/null +++ b/test/newProject.test.ts @@ -0,0 +1,36 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { validateNewProjectName } from "../src/newProject"; + +test("拒绝空值和纯空白项目名", () => { + for (const name of ["", " "]) { + assert.ok(validateNewProjectName(name) !== undefined, `should reject: ${JSON.stringify(name)}`); + } +}); + +test("拒绝包含路径分隔符的项目名", () => { + for (const name of ["a/b", "a\\b", "/abs", "..\\up"]) { + assert.ok(validateNewProjectName(name) !== undefined, `should reject: ${name}`); + } +}); + +test("拒绝会被 mcpp 解析为 CLI 选项的项目名", () => { + // 参数数组只能防 shell 注入;`-` 前缀会被 mcpp 自身解析为 + // --template、--list-templates 等选项,可能成功退出却没有创建工程。 + for (const name of ["-x", "--template", "--list-templates"]) { + assert.ok(validateNewProjectName(name) !== undefined, `should reject: ${name}`); + } +}); + +test("拒绝相对路径名 . 和 ..", () => { + for (const name of [".", ".."]) { + assert.ok(validateNewProjectName(name) !== undefined, `should reject: ${name}`); + } +}); + +test("接受常规项目名,前后空白忽略", () => { + for (const name of ["hello", "hello-mcpp", "my_project", "a.b.c", "项目", " padded "]) { + assert.equal(validateNewProjectName(name), undefined, `should accept: ${name}`); + } +}); From c239dc09d4a261fb10c2f398479fedd8d605d0da Mon Sep 17 00:00:00 2001 From: Ximiaw Date: Sat, 8 Aug 2026 14:06:11 +0800 Subject: [PATCH 3/5] fix: reject names that corrupt mcpp-generated project files --- src/newProject.ts | 20 ++++++++++++++++++++ test/newProject.test.ts | 19 ++++++++++++++++++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/newProject.ts b/src/newProject.ts index bffc4d8..7b47764 100644 --- a/src/newProject.ts +++ b/src/newProject.ts @@ -1,9 +1,17 @@ +const CONTROL_CHARS = /[\u0000-\u001F\u007F]/; +const WINDOWS_RESERVED_CHARS = /[<>:"|?*]/; +const WINDOWS_DEVICE_NAMES = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])$/i; + /** * 新建工程的项目名校验。返回错误提示;undefined 表示合法。 * * 项目名随后作为 `mcpp new ` 的 argv 传入:参数数组只能防 shell 注入, * 不能阻止 mcpp 自身把名字解析为 CLI 选项(如 --template),所以这里拒绝 * `-` 前缀以及 `.`、`..`。 + * + * mcpp 模板把项目名直接写进 mcpp.toml 的 `name = "{}"` 和 main.cpp,不做 + * TOML/C++ 转义,所以拒绝双引号和控制字符;Windows 保留字符、保留设备名和 + * 尾随点一并按跨平台策略拒绝。根本修复应在 mcpp CLI 自身完成。 */ export function validateNewProjectName(input: string): string | undefined { const name = input.trim(); @@ -19,5 +27,17 @@ export function validateNewProjectName(input: string): string | undefined { if (name === "." || name === "..") { return "项目名不能是 . 或 .."; } + if (CONTROL_CHARS.test(name)) { + return "项目名不能包含控制字符"; + } + if (WINDOWS_RESERVED_CHARS.test(name)) { + return '项目名不能包含 <>:"|?* 等保留字符'; + } + if (name.endsWith(".")) { + return "项目名不能以 . 结尾(Windows 不支持)"; + } + if (WINDOWS_DEVICE_NAMES.test(name)) { + return "项目名不能是 Windows 保留设备名"; + } return undefined; } diff --git a/test/newProject.test.ts b/test/newProject.test.ts index 615f7ca..80daec7 100644 --- a/test/newProject.test.ts +++ b/test/newProject.test.ts @@ -29,8 +29,25 @@ test("拒绝相对路径名 . 和 ..", () => { } }); +test("拒绝双引号和控制字符,避免破坏 mcpp 生成的 TOML 和 C++ 源码", () => { + // mcpp 模板把项目名直接写进 mcpp.toml 的 name = "{}" 和 main.cpp, + // 不做 TOML/C++ 转义,这些输入会生成坏工程。 + for (const name of ['bad"name', "bad\tname", "bad\nname", "bad\rname", "bad\u001Fname", "bad\u007Fname"]) { + assert.ok(validateNewProjectName(name) !== undefined, `should reject: ${JSON.stringify(name)}`); + } +}); + +test("按跨平台策略拒绝 Windows 保留字符、设备名和尾随点", () => { + for (const name of ["ab", "a:b", "a|b", "a?b", "a*b", "name."]) { + assert.ok(validateNewProjectName(name) !== undefined, `should reject: ${name}`); + } + for (const name of ["CON", "con", "PRN", "AUX", "NUL", "COM1", "com9", "LPT1"]) { + assert.ok(validateNewProjectName(name) !== undefined, `should reject: ${name}`); + } +}); + test("接受常规项目名,前后空白忽略", () => { - for (const name of ["hello", "hello-mcpp", "my_project", "a.b.c", "项目", " padded "]) { + for (const name of ["hello", "hello-mcpp", "my_project", "a.b.c", "项目", "console", "com10", " padded "]) { assert.equal(validateNewProjectName(name), undefined, `should accept: ${name}`); } }); From 3aa5ca0799df596cc02e35acf1eb600f4d6952e2 Mon Sep 17 00:00:00 2001 From: Ximiaw Date: Sat, 8 Aug 2026 14:17:41 +0800 Subject: [PATCH 4/5] test: cover new-project flow with injectable dependencies --- src/cliController.ts | 48 ++++++++++------------- src/newProject.ts | 42 ++++++++++++++++++++ test/artifacts.test.ts | 14 ++++--- test/newProject.test.ts | 86 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 156 insertions(+), 34 deletions(-) diff --git a/src/cliController.ts b/src/cliController.ts index 56d880c..2e53172 100644 --- a/src/cliController.ts +++ b/src/cliController.ts @@ -25,7 +25,7 @@ import { type TaskCompletion, } from "./tasks"; import { CLI_COMMANDS, quickMenuItems, quickMenuStatusText } from "./commands"; -import { validateNewProjectName } from "./newProject"; +import { runNewProjectFlow, validateNewProjectName } from "./newProject"; export interface McppCliControllerOptions { output: vscode.OutputChannel; @@ -526,34 +526,26 @@ export class McppCliController { } const projectRoot = join(location.fsPath, projectName); - if (existsSync(projectRoot)) { - await vscode.window.showErrorMessage(`目标路径已存在:${projectRoot}。请更换项目名或位置。`); - return; - } const confirmCreate = "创建并打开"; - const choice = await vscode.window.showWarningMessage( - `将在 ${location.fsPath} 执行 “mcpp new ${projectName}”,创建项目文件夹 ${projectRoot} 并打开它。`, - { modal: true }, - confirmCreate, - ); - if (choice !== confirmCreate) { - return; - } - - const executable = this.mcppExecutable(undefined); - const args = mcppCommandArguments("new", projectName); - const result = await runProcess(executable, args, location.fsPath); - this.appendShortCommand("新建工程", executable, args, result); - if (result.exitCode !== 0) { - await vscode.window.showErrorMessage( - `mcpp new ${projectName} 失败(退出码 ${result.exitCode})。请查看 mcpp 输出频道。`, - ); - return; - } - - // 契约:创建并打开工程。打开后的构建交给用户手动触发(或后续 #5 的 - // IDE configure 流程),避免与缺少 CDB 时的 configure 重复执行。 - await vscode.commands.executeCommand("vscode.openFolder", vscode.Uri.file(projectRoot)); + await runNewProjectFlow(projectName, location.fsPath, projectRoot, { + exists: existsSync, + confirm: async (message) => + (await vscode.window.showWarningMessage(message, { modal: true }, confirmCreate)) + === confirmCreate, + run: async (name, cwd) => { + const executable = this.mcppExecutable(undefined); + const args = mcppCommandArguments("new", name); + const result = await runProcess(executable, args, cwd); + this.appendShortCommand("新建工程", executable, args, result); + return result.exitCode; + }, + openFolder: async (path) => { + await vscode.commands.executeCommand("vscode.openFolder", vscode.Uri.file(path)); + }, + showError: async (message) => { + await vscode.window.showErrorMessage(message); + }, + }); } private guarded(operation: () => Promise): () => Promise { diff --git a/src/newProject.ts b/src/newProject.ts index 7b47764..e57543a 100644 --- a/src/newProject.ts +++ b/src/newProject.ts @@ -1,3 +1,45 @@ +export interface NewProjectActions { + exists(path: string): boolean; + confirm(message: string): Promise; + run(name: string, cwd: string): Promise; + openFolder(path: string): Promise; + showError(message: string): Promise | void; +} + +export type NewProjectOutcome = "exists" | "declined" | "failed" | "opened"; + +/** + * 新建工程的核心流程,依赖全部注入以便单测。契约:创建并打开工程—— + * 打开后的构建交给用户手动触发(或后续 #5 的 IDE configure 流程), + * 避免与缺少 CDB 时的 configure 重复执行。 + */ +export async function runNewProjectFlow( + projectName: string, + location: string, + projectRoot: string, + actions: NewProjectActions, +): Promise { + if (actions.exists(projectRoot)) { + await actions.showError(`目标路径已存在:${projectRoot}。请更换项目名或位置。`); + return "exists"; + } + const confirmed = await actions.confirm( + `将在 ${location} 执行 “mcpp new ${projectName}”,创建项目文件夹 ${projectRoot} 并打开它。`, + ); + if (!confirmed) { + return "declined"; + } + const exitCode = await actions.run(projectName, location); + if (exitCode !== 0) { + await actions.showError( + `mcpp new ${projectName} 失败(退出码 ${exitCode})。请查看 mcpp 输出频道。`, + ); + return "failed"; + } + await actions.openFolder(projectRoot); + return "opened"; +} + const CONTROL_CHARS = /[\u0000-\u001F\u007F]/; const WINDOWS_RESERVED_CHARS = /[<>:"|?*]/; const WINDOWS_DEVICE_NAMES = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])$/i; diff --git a/test/artifacts.test.ts b/test/artifacts.test.ts index 47eae62..e9f304b 100644 --- a/test/artifacts.test.ts +++ b/test/artifacts.test.ts @@ -239,14 +239,16 @@ test("新建工程先校验目标路径再确认创建,成功后只打开不 assert.notEqual(start, -1); assert.notEqual(end, -1); + // 控制流本身由 test/newProject.test.ts 对 runNewProjectFlow 的行为级测试覆盖; + // 这里只验证控制器把 UI/进程依赖注入流程函数。 const method = source.slice(start, end); - const locationPick = method.indexOf("showOpenDialog"); - const exists = method.indexOf("existsSync(projectRoot)"); - const confirm = method.indexOf("showWarningMessage"); - const create = method.indexOf("runProcess"); - const open = method.indexOf('executeCommand("vscode.openFolder"'); assert.match(method, /validateNewProjectName/); - assert.ok(locationPick >= 0 && locationPick < exists); + assert.match(method, /runNewProjectFlow/); + const flow = method.indexOf("runNewProjectFlow"); + const exists = method.indexOf("existsSync", flow); + const confirm = method.indexOf("showWarningMessage", flow); + const create = method.indexOf("runProcess", flow); + const open = method.indexOf('executeCommand("vscode.openFolder"', flow); assert.ok(exists >= 0 && exists < confirm); assert.ok(confirm >= 0 && confirm < create); assert.ok(create >= 0 && create < open); diff --git a/test/newProject.test.ts b/test/newProject.test.ts index 80daec7..9b2f82c 100644 --- a/test/newProject.test.ts +++ b/test/newProject.test.ts @@ -51,3 +51,89 @@ test("接受常规项目名,前后空白忽略", () => { assert.equal(validateNewProjectName(name), undefined, `should accept: ${name}`); } }); +import { runNewProjectFlow, type NewProjectActions } from "../src/newProject"; + +function recordingActions(overrides: Partial, calls: string[]): NewProjectActions { + return { + exists: (path) => { + calls.push(`exists:${path}`); + return overrides.exists?.(path) ?? false; + }, + confirm: async (message) => { + calls.push(`confirm:${message}`); + return overrides.confirm?.(message) ?? true; + }, + run: async (name, cwd) => { + calls.push(`run:${name}@${cwd}`); + return overrides.run?.(name, cwd) ?? 0; + }, + openFolder: async (path) => { + calls.push(`openFolder:${path}`); + await overrides.openFolder?.(path); + }, + showError: (message) => { + calls.push(`showError:${message}`); + }, + }; +} + +test("目标路径已存在时报错且不确认、不创建、不打开", async () => { + const calls: string[] = []; + const outcome = await runNewProjectFlow( + "demo", + "/parent", + "/parent/demo", + recordingActions({ exists: () => true }, calls), + ); + assert.equal(outcome, "exists"); + assert.deepEqual(calls, [ + "exists:/parent/demo", + "showError:目标路径已存在:/parent/demo。请更换项目名或位置。", + ]); +}); + +test("用户取消确认时不创建、不打开", async () => { + const calls: string[] = []; + const outcome = await runNewProjectFlow( + "demo", + "/parent", + "/parent/demo", + recordingActions({ confirm: async () => false }, calls), + ); + assert.equal(outcome, "declined"); + assert.deepEqual(calls.map((call) => call.split(":", 1)[0]), ["exists", "confirm"]); +}); + +test("mcpp new 失败时报错且不打开", async () => { + const calls: string[] = []; + const outcome = await runNewProjectFlow( + "demo", + "/parent", + "/parent/demo", + recordingActions({ run: async () => 2 }, calls), + ); + assert.equal(outcome, "failed"); + assert.deepEqual(calls, [ + "exists:/parent/demo", + "confirm:将在 /parent 执行 “mcpp new demo”,创建项目文件夹 /parent/demo 并打开它。", + "run:demo@/parent", + "showError:mcpp new demo 失败(退出码 2)。请查看 mcpp 输出频道。", + ]); +}); + +test("创建成功后只打开项目文件夹,不自动构建", async () => { + const calls: string[] = []; + const outcome = await runNewProjectFlow( + "demo", + "/parent", + "/parent/demo", + recordingActions({}, calls), + ); + assert.equal(outcome, "opened"); + assert.deepEqual(calls, [ + "exists:/parent/demo", + "confirm:将在 /parent 执行 “mcpp new demo”,创建项目文件夹 /parent/demo 并打开它。", + "run:demo@/parent", + "openFolder:/parent/demo", + ]); +}); From bfbaffe585e1a3339c9f58605aca04d1577e0f78 Mon Sep 17 00:00:00 2001 From: wellwei Date: Sat, 8 Aug 2026 15:38:44 +0800 Subject: [PATCH 5/5] fix: reject unsafe new-project names --- src/newProject.ts | 7 ++++++- test/newProject.test.ts | 13 +++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/newProject.ts b/src/newProject.ts index e57543a..07cdead 100644 --- a/src/newProject.ts +++ b/src/newProject.ts @@ -42,7 +42,8 @@ export async function runNewProjectFlow( const CONTROL_CHARS = /[\u0000-\u001F\u007F]/; const WINDOWS_RESERVED_CHARS = /[<>:"|?*]/; -const WINDOWS_DEVICE_NAMES = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])$/i; +const WINDOWS_DEVICE_NAMES = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i; +const MCPP_BUILTIN_TEMPLATE_MARKER = "PROJECT"; /** * 新建工程的项目名校验。返回错误提示;undefined 表示合法。 @@ -69,6 +70,10 @@ export function validateNewProjectName(input: string): string | undefined { if (name === "." || name === "..") { return "项目名不能是 . 或 .."; } + // mcpp#380:当前内置模板会重复扫描替换结果,名称包含该标记时不会终止。 + if (name.includes(MCPP_BUILTIN_TEMPLATE_MARKER)) { + return "项目名不能包含 PROJECT,否则会触发当前 mcpp 模板替换缺陷"; + } if (CONTROL_CHARS.test(name)) { return "项目名不能包含控制字符"; } diff --git a/test/newProject.test.ts b/test/newProject.test.ts index 9b2f82c..420c642 100644 --- a/test/newProject.test.ts +++ b/test/newProject.test.ts @@ -37,6 +37,13 @@ test("拒绝双引号和控制字符,避免破坏 mcpp 生成的 TOML 和 C++ } }); +test("拒绝会触发 mcpp 内置模板无限替换的项目名", () => { + // mcpp 当前会循环替换字面量 PROJECT;插入值仍包含该标记时不会终止。 + for (const name of ["PROJECT", "myPROJECTname", "demo-PROJECT-app"]) { + assert.ok(validateNewProjectName(name) !== undefined, `should reject: ${name}`); + } +}); + test("按跨平台策略拒绝 Windows 保留字符、设备名和尾随点", () => { for (const name of ["ab", "a:b", "a|b", "a?b", "a*b", "name."]) { assert.ok(validateNewProjectName(name) !== undefined, `should reject: ${name}`); @@ -46,6 +53,12 @@ test("按跨平台策略拒绝 Windows 保留字符、设备名和尾随点", () } }); +test("Windows 保留设备名添加扩展后仍然拒绝", () => { + for (const name of ["CON.txt", "con.json", "AUX.md", "LPT1.log", "COM9.tar.gz"]) { + assert.ok(validateNewProjectName(name) !== undefined, `should reject: ${name}`); + } +}); + test("接受常规项目名,前后空白忽略", () => { for (const name of ["hello", "hello-mcpp", "my_project", "a.b.c", "项目", "console", "com10", " padded "]) { assert.equal(validateNewProjectName(name), undefined, `should accept: ${name}`);