Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .changeset/clean-spawn-patch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
"yeoman-ui": patch
---

Replace shell-based path expansion with native Node.js resolution

The `ApplicationWizard.installationLocation` setting is now resolved
entirely in Node.js. Tilde (`~`), `$HOME`, and `%USERPROFILE%` prefixes
are expanded without spawning a shell process, preserving backward
compatibility with existing configurations.

Generator install and uninstall operations now use `spawn()` with explicit
argument arrays instead of building shell command strings, eliminating
the shell as an intermediary for those operations. A shared `shellQuotePath`
helper is used for the remaining elevated commands that require a shell
(`icacls`, `chown`). The real username is resolved in Node.js before being
passed to `sudo.exec` to ensure correct ownership on Linux and macOS.

Additional fixes:

- Prevent a crash when `installationLocation` is unset (default state)
- Fix cross-platform test failures caused by hardcoded POSIX path separators
- Fix `NODE_OPTIONS` assignment in npm scripts using `cross-env` for Windows compatibility
19 changes: 18 additions & 1 deletion pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions projects/yeoman-ui/packages/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,11 @@
"clean:frontend": "cd ./dist && shx rm -rf ./media",
"compile": "tsc",
"compile:watch": "tsc -watch",
"coverage": "NODE_OPTIONS='--max-old-space-size=8192 --no-experimental-strip-types' c8 mocha",
"coverage": "cross-env NODE_OPTIONS=\"--max-old-space-size=8192 --no-experimental-strip-types\" c8 mocha",
"coverage:copy": "shx mkdir -p ../../../../coverage && shx cp -u ./coverage/lcov.info ../../../../coverage/lcov_backend.info",
"frontend:copy": "npm-run-all clean:frontend && shx cp -r ../frontend/dist/. ./dist/media/",
"package": "vsce package --no-dependencies",
"test": "NODE_OPTIONS='--no-experimental-strip-types' mocha",
"test": "cross-env NODE_OPTIONS=\"--no-experimental-strip-types\" mocha",
"webpack": "webpack --config webpack.config.cjs --mode development",
"webpack-dev:watch": "webpack --config webpack.config.cjs --mode development --watch",
"ws:egRun": "node ./dist/src/webSocketServer/exploregens.js",
Expand Down Expand Up @@ -214,6 +214,7 @@
"@vscode/vsce": "2.24.0",
"c8": "11.0.0",
"copy-webpack-plugin": "^12.0.2",
"cross-env": "^10.1.0",
"lcov-result-merger": "5.0.0",
"sinon": "^18.0.1",
"string-replace-loader": "3.0.3",
Expand Down
33 changes: 24 additions & 9 deletions projects/yeoman-ui/packages/backend/src/utils/customLocation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,27 +3,39 @@ import { existsSync, mkdirSync } from "fs";
import { homedir } from "os";
import lodash from "lodash";
import { vscode } from "./vscodeProxy.js";
import { execSync } from "child_process";

const { isEmpty, trim } = lodash;

export const GLOBAL_CONFIG_KEY = "ApplicationWizard.installationLocation";

// Matches leading home-dir tokens: ~, $HOME, %USERPROFILE% (case-insensitive).
// The token must be followed by / \ or end-of-string — not a longer word.
const HOME_TOKEN_RE = /^(~|\$HOME|%USERPROFILE%)(\/|\\|$)/i;

// Expand leading ~ and common home-dir env vars to their absolute paths.
// Handles ~ / ~/path / ~\path, $HOME/path, %USERPROFILE%/path (case-insensitive).
// This keeps backward compatibility with configs that used shell-expanded values.
const normaliseToAbsolute = (filePath: string): string => {
const home = homedir();
const match = HOME_TOKEN_RE.exec(filePath);
if (match) {
filePath = home + filePath.slice(match[1].length);
}
if (!path.isAbsolute(filePath)) {
filePath = path.resolve(home, filePath);
}
return filePath;
};

const getAbsoluteCustomPath = (): string | undefined => {
let customPath = trim(
const customPath = trim(
vscode.workspace.getConfiguration().get(GLOBAL_CONFIG_KEY)
);
if (isEmpty(customPath)) {
return;
}

customPath = trim(execSync(`echo ${customPath}`).toString());

if (!path.isAbsolute(customPath)) {
customPath = path.resolve(homedir(), customPath);
}

return customPath;
return normaliseToAbsolute(customPath);
};

const isCustomPathExist = (customPath: string) => {
Expand All @@ -33,6 +45,9 @@ const isCustomPathExist = (customPath: string) => {

export const getPath = (): string => {
const customPath = getAbsoluteCustomPath();
if (!customPath) {
return undefined;
}
return isCustomPathExist(customPath) ? trim(customPath) : undefined;
};

Expand Down
58 changes: 46 additions & 12 deletions projects/yeoman-ui/packages/backend/src/utils/npm.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { exec } from "child_process";
import { exec, execSync } from "child_process";
import { promisify } from "util";
import { platform } from "os";
import _ from "lodash";
Expand Down Expand Up @@ -41,6 +41,19 @@ const SEARCH_QUERY_SUFFIX =
const CANCELED = "Action cancelled";
const HAS_ACCESS = "Has Access";

// Wrap a filesystem path for safe interpolation into a shell command string.
// Use only for commands that must go through a shell (e.g. sudo-prompt).
// Prefer spawn() with an argument array over this whenever possible.
const shellQuotePath = (p: string): string => {
if (isWin32) {
// cmd.exe: wrap in double-quotes, escape any embedded double-quotes as ""
return `"${p.replace(/"/g, '""')}"`;
}
// POSIX sh: single-quote the entire path; a single-quote inside is
// closed, escaped with \', then reopened: 'it'\''s safe'
return `'${p.replace(/'/g, "'\\''")}'`;
};

class Command {
private globalNodeModulesPathPromise: Promise<string>;
private readonly SET_DEFAULT_LOCATION;
Expand All @@ -62,11 +75,11 @@ class Command {
);
}

private getGenLocationParams(): string {
private getGenLocationArgs(): string[] {
const customInstallationPath = customLocation.getPath();
return _.isEmpty(customInstallationPath)
? "-g"
: `--prefix ${customInstallationPath}`;
? ["-g"]
: ["--prefix", customInstallationPath];
}

private async execCommand(arg: string): Promise<string> {
Expand Down Expand Up @@ -139,9 +152,28 @@ class Command {

private async grantAccessForGlobalNodeModulesPath() {
const globalNodeModulesPath = await this.getGlobalNodeModulesPath();
// Resolve the real username in Node.js so $USER is not evaluated inside
// the elevated shell where env_reset typically sets it to "root".
const realUser = isWin32
? undefined
: (() => {
try {
return execSync("logname", { encoding: "utf8" }).trim();
} catch {
/* fall through */
}
try {
return execSync("id -un", { encoding: "utf8" }).trim();
} catch {
/* fall through */
}
return os.userInfo().username;
})();
const changeOwnerCommand = isWin32
? `icacls ${globalNodeModulesPath} /grant Users:(OI)(CI)F`
: `chown -R $USER ${globalNodeModulesPath}`;
? `icacls ${shellQuotePath(globalNodeModulesPath)} /grant Users:(OI)(CI)F`
: `chown -R ${shellQuotePath(realUser)} ${shellQuotePath(
globalNodeModulesPath
)}`;
const globalPath = await this.getGlobalPath();
const statusBarMessage = vscode.window.setStatusBarMessage(
messages.changing_owner_permissions(globalPath)
Expand Down Expand Up @@ -224,15 +256,17 @@ class Command {
}

public async install(packageName: string): Promise<any> {
const locationParams = this.getGenLocationParams();
const command = `${NPM} install ${locationParams} ${packageName}@latest`;
return this.execCommand(command);
const locationArgs = this.getGenLocationArgs();
return this.spawnCommand(NPM, [
"install",
...locationArgs,
`${packageName}@latest`,
]);
}

public async uninstall(packageName: string): Promise<any> {
const locationParams = this.getGenLocationParams();
const command = `${NPM} uninstall ${locationParams} ${packageName}`;
return this.execCommand(command);
const locationArgs = this.getGenLocationArgs();
return this.spawnCommand(NPM, ["uninstall", ...locationArgs, packageName]);
}

public async getNodeProcessVersions(): Promise<NodeJS.ProcessVersions> {
Expand Down
Loading
Loading