@technicalshree/auto-fix 1.2.1 → 1.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +50 -0
- package/dist/cli.js +14 -9
- package/dist/config/loadConfig.js +39 -1
- package/dist/core/run.js +1 -1
- package/dist/core/undo.js +7 -1
- package/dist/subsystems/checks.js +95 -0
- package/dist/subsystems/environment.js +13 -6
- package/dist/subsystems/node.js +23 -3
- package/dist/subsystems/python.js +7 -3
- package/dist/utils/process.js +24 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -166,6 +166,56 @@ auto-fix clear-yarn-cache
|
|
|
166
166
|
auto-fix clear-pnpm-cache
|
|
167
167
|
```
|
|
168
168
|
|
|
169
|
+
## v1.2 Features
|
|
170
|
+
|
|
171
|
+
### Environment Variable Synchronization
|
|
172
|
+
|
|
173
|
+
`auto-fix` detects `.env.example` files and helps keep your local `.env` in sync:
|
|
174
|
+
|
|
175
|
+
- **Missing `.env`**: If `.env.example` exists but `.env` does not, auto-fix will copy it automatically.
|
|
176
|
+
- **Missing keys**: If both files exist, auto-fix compares the keys and appends any missing keys from `.env.example` to your `.env` with empty values.
|
|
177
|
+
- Changes to `.env` are snapshotted for undo coverage.
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
# See env sync in action
|
|
181
|
+
auto-fix plan
|
|
182
|
+
# Output: ● Copy .env.example to .env
|
|
183
|
+
# Output: ● Append 2 missing key(s) to .env
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
### Runtime Engine Version Checks
|
|
187
|
+
|
|
188
|
+
`auto-fix` validates that your local Node.js and Python versions match what the project expects:
|
|
189
|
+
|
|
190
|
+
- **Node**: Reads `.nvmrc` or `.node-version` and compares the major version against `process.version`. Emits a warning with suggested action (`nvm use`).
|
|
191
|
+
- **Python**: Reads `.python-version` and flags a version drift warning.
|
|
192
|
+
- These checks run **before** dependency installations to catch mismatches early.
|
|
193
|
+
- Engine checks fire based on version file existence alone — no `package.json` or `pyproject.toml` required.
|
|
194
|
+
|
|
195
|
+
```bash
|
|
196
|
+
auto-fix plan
|
|
197
|
+
# Output: ● Node version drift detected: expected ~18, running v22.x — Run nvm use
|
|
198
|
+
# Output: ● Python version drift: project expects 3.11
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
### VS Code Python Integration
|
|
202
|
+
|
|
203
|
+
When a Python virtual environment (`.venv`) exists or is being created, `auto-fix` automatically configures VS Code to use it:
|
|
204
|
+
|
|
205
|
+
- Sets `python.defaultInterpreterPath` in `.vscode/settings.json`
|
|
206
|
+
- Prevents false-positive Pylance/Pyright linting errors for new developers
|
|
207
|
+
- The change is snapshotted for undo coverage
|
|
208
|
+
|
|
209
|
+
### EPERM/EBUSY Error Guidance
|
|
210
|
+
|
|
211
|
+
When `node_modules` cleanup or dependency installation fails due to file locks (common on Windows and macOS), `auto-fix` now provides specific, actionable error messages:
|
|
212
|
+
|
|
213
|
+
```
|
|
214
|
+
✖ Permission/lock error (EPERM/EBUSY). Close your IDE, dev servers, or file watchers and retry.
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
This replaces the previous generic "One or more commands failed" message.
|
|
218
|
+
|
|
169
219
|
## Commands
|
|
170
220
|
|
|
171
221
|
`auto-fix [command] [flags]`
|
package/dist/cli.js
CHANGED
|
@@ -78,15 +78,20 @@ function parseArgs(argv) {
|
|
|
78
78
|
if (hasHelp)
|
|
79
79
|
return { command: "run", flags: defaultFlags(), help: true };
|
|
80
80
|
const [first, ...rest] = argv;
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
first === "
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
81
|
+
const knownCommands = ["doctor", "plan", "report", "undo", "clear-npm-cache", "clear-yarn-cache", "clear-pnpm-cache", "run"];
|
|
82
|
+
let command;
|
|
83
|
+
if (first === "doctor" || first === "plan" || first === "report" || first === "undo" ||
|
|
84
|
+
first === "clear-npm-cache" || first === "clear-yarn-cache" || first === "clear-pnpm-cache") {
|
|
85
|
+
command = first;
|
|
86
|
+
}
|
|
87
|
+
else if (first === "run" || first.startsWith("-")) {
|
|
88
|
+
command = "run";
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
// REL-001: Unknown command — fail explicitly instead of silent fallback to `run`
|
|
92
|
+
console.error(`Unknown command: "${first}"\nAvailable commands: ${knownCommands.join(", ")}\nRun 'auto-fix --help' for usage information.`);
|
|
93
|
+
process.exit(1);
|
|
94
|
+
}
|
|
90
95
|
const args = command === "run" ? argv : rest;
|
|
91
96
|
const flags = defaultFlags();
|
|
92
97
|
for (let i = 0; i < args.length; i += 1) {
|
|
@@ -34,11 +34,49 @@ async function findConfigPath(cwd) {
|
|
|
34
34
|
current = parent;
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
|
+
/** Allowlist pattern for safe characters in config command/path strings */
|
|
38
|
+
const SAFE_CHARS_RE = /^[a-zA-Z0-9._\-/@:=*\s]+$/;
|
|
39
|
+
/** Known dev tool binaries that are safe to auto-execute */
|
|
40
|
+
const KNOWN_TOOL_BINARIES = new Set([
|
|
41
|
+
"ruff", "black", "autopep8", "yapf", "isort", "pyink",
|
|
42
|
+
"flake8", "pylint", "pyflakes", "pydocstyle", "pycodestyle", "bandit", "vulture",
|
|
43
|
+
"mypy", "pyright", "pytype", "pyre",
|
|
44
|
+
"pytest", "unittest", "nose2", "tox", "nox", "coverage",
|
|
45
|
+
"pip", "uv", "poetry", "pipenv", "pdm",
|
|
46
|
+
"python", "python3", "pre-commit", "sphinx-build",
|
|
47
|
+
"npm", "npx", "pnpm", "yarn",
|
|
48
|
+
]);
|
|
49
|
+
function isSafeConfigCommand(cmd) {
|
|
50
|
+
if (!SAFE_CHARS_RE.test(cmd))
|
|
51
|
+
return false;
|
|
52
|
+
const binary = cmd.trim().split(/\s+/)[0].replace(/^.*\//, "");
|
|
53
|
+
return KNOWN_TOOL_BINARIES.has(binary);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* REL-004: Defense-in-depth — strip unsafe values from config at load time.
|
|
57
|
+
* Uses character allowlist + known binary allowlist to prevent arbitrary command execution.
|
|
58
|
+
*/
|
|
59
|
+
function sanitizeConfig(config) {
|
|
60
|
+
// Sanitize python tool command arrays
|
|
61
|
+
if (config.python?.tools) {
|
|
62
|
+
config.python.tools.format = config.python.tools.format.filter(isSafeConfigCommand);
|
|
63
|
+
config.python.tools.lint = config.python.tools.lint.filter(isSafeConfigCommand);
|
|
64
|
+
config.python.tools.test = config.python.tools.test.filter(isSafeConfigCommand);
|
|
65
|
+
}
|
|
66
|
+
// Sanitize node cache directory names (path-only, no binary check needed)
|
|
67
|
+
if (config.node?.caches?.directories) {
|
|
68
|
+
config.node.caches.directories = config.node.caches.directories.filter((d) => SAFE_CHARS_RE.test(d) && !d.includes(".."));
|
|
69
|
+
}
|
|
70
|
+
return config;
|
|
71
|
+
}
|
|
37
72
|
export async function loadConfig(cwd) {
|
|
38
73
|
const cfgPath = await findConfigPath(cwd);
|
|
39
74
|
if (!cfgPath)
|
|
40
75
|
return { config: defaultConfig, path: null };
|
|
41
76
|
const raw = await readFile(cfgPath, "utf8");
|
|
42
77
|
const user = parse(raw);
|
|
43
|
-
|
|
78
|
+
if (!user || typeof user !== "object")
|
|
79
|
+
return { config: defaultConfig, path: cfgPath };
|
|
80
|
+
const merged = mergeDeep(defaultConfig, user);
|
|
81
|
+
return { config: sanitizeConfig(merged), path: cfgPath };
|
|
44
82
|
}
|
package/dist/core/run.js
CHANGED
|
@@ -94,7 +94,7 @@ export async function runAutoFix(ctx, config, reportDir, callbacks) {
|
|
|
94
94
|
guarded.warnings.push("Added .autofix/ to .gitignore");
|
|
95
95
|
if (!writable)
|
|
96
96
|
guarded.warnings.push(`.autofix not writable; using temp snapshot dir: ${snapshotDir}`);
|
|
97
|
-
if (config.docker.safe_down && guarded.steps.some((s) => s.checkKind === "test")) {
|
|
97
|
+
if (detection.docker.detected && config.docker.safe_down && guarded.steps.some((s) => s.checkKind === "test")) {
|
|
98
98
|
guarded.warnings.push("Tests may require services; run `docker compose up -d` and re-run tests.");
|
|
99
99
|
}
|
|
100
100
|
const report = {
|
package/dist/core/undo.js
CHANGED
|
@@ -33,7 +33,13 @@ export async function undoLatest(reportPath, cwd) {
|
|
|
33
33
|
// Extract just the filename portion (after the stepId directory) and reverse the encoding.
|
|
34
34
|
const base = path.basename(snap);
|
|
35
35
|
const relativePath = base.replace(/_/g, path.sep);
|
|
36
|
-
const target = path.
|
|
36
|
+
const target = path.resolve(cwd, relativePath);
|
|
37
|
+
// SEC-002: Containment check — reject any path that escapes project root
|
|
38
|
+
const normalizedCwd = path.resolve(cwd) + path.sep;
|
|
39
|
+
if (!target.startsWith(normalizedCwd) && target !== path.resolve(cwd)) {
|
|
40
|
+
failed.push(`${target} (blocked: path traversal outside project root)`);
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
37
43
|
try {
|
|
38
44
|
const targetDir = path.dirname(target);
|
|
39
45
|
await mkdir(targetDir, { recursive: true });
|
|
@@ -50,12 +50,71 @@ function nodeChecks(detection, selected) {
|
|
|
50
50
|
}
|
|
51
51
|
return steps;
|
|
52
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* REL-004: Known tool binary allowlist.
|
|
55
|
+
* Only commands starting with a recognized dev tool will be auto-executed.
|
|
56
|
+
* Unrecognized binaries (touch, rm, curl, wget, etc.) are rejected.
|
|
57
|
+
*/
|
|
58
|
+
const KNOWN_TOOL_BINARIES = new Set([
|
|
59
|
+
// Python formatters
|
|
60
|
+
"ruff", "black", "autopep8", "yapf", "isort", "pyink",
|
|
61
|
+
// Python linters
|
|
62
|
+
"flake8", "pylint", "pyflakes", "pydocstyle", "pycodestyle", "bandit", "vulture",
|
|
63
|
+
// Python type checkers
|
|
64
|
+
"mypy", "pyright", "pytype", "pyre",
|
|
65
|
+
// Python test runners
|
|
66
|
+
"pytest", "unittest", "nose2", "tox", "nox", "coverage",
|
|
67
|
+
// Python package managers (used as runner)
|
|
68
|
+
"pip", "uv", "poetry", "pipenv", "pdm",
|
|
69
|
+
// Python misc
|
|
70
|
+
"python", "python3", "pre-commit", "sphinx-build",
|
|
71
|
+
// Node tools (in case used in python context)
|
|
72
|
+
"npm", "npx", "pnpm", "yarn",
|
|
73
|
+
]);
|
|
74
|
+
/**
|
|
75
|
+
* REL-004: Validate a config-sourced command string.
|
|
76
|
+
* Two checks:
|
|
77
|
+
* 1. Character allowlist — rejects shell metacharacters
|
|
78
|
+
* 2. Binary allowlist — first token must be a known dev tool
|
|
79
|
+
*/
|
|
80
|
+
function isSafeCommand(cmd) {
|
|
81
|
+
if (!cmd || cmd.trim().length === 0)
|
|
82
|
+
return { safe: false, reason: "Empty command" };
|
|
83
|
+
// Check 1: Character-level safety (no shell metacharacters)
|
|
84
|
+
if (!/^[a-zA-Z0-9._\-/@:=*\s]+$/.test(cmd)) {
|
|
85
|
+
return { safe: false, reason: `contains shell metacharacters` };
|
|
86
|
+
}
|
|
87
|
+
// Check 2: First token must be a known tool binary
|
|
88
|
+
const binary = cmd.trim().split(/\s+/)[0].replace(/^.*\//, ""); // strip leading path
|
|
89
|
+
if (!KNOWN_TOOL_BINARIES.has(binary)) {
|
|
90
|
+
return { safe: false, reason: `unrecognized tool '${binary}'` };
|
|
91
|
+
}
|
|
92
|
+
return { safe: true, reason: "" };
|
|
93
|
+
}
|
|
53
94
|
function pythonChecks(detection, config, selected) {
|
|
54
95
|
if (!detection.python.detected)
|
|
55
96
|
return [];
|
|
56
97
|
const steps = [];
|
|
57
98
|
if (selected.includes("format")) {
|
|
58
99
|
for (const cmd of config.python.tools.format) {
|
|
100
|
+
const validation = isSafeCommand(cmd);
|
|
101
|
+
if (!validation.safe) {
|
|
102
|
+
steps.push({
|
|
103
|
+
id: `check-python-format-${cmd.replace(/[^a-z0-9]/gi, "-")}`,
|
|
104
|
+
title: `SKIPPED: Rejected format command: ${cmd}`,
|
|
105
|
+
subsystem: "checks",
|
|
106
|
+
phase: "checks",
|
|
107
|
+
checkKind: "format",
|
|
108
|
+
rationale: `Config command rejected: ${validation.reason}.`,
|
|
109
|
+
commands: [],
|
|
110
|
+
destructive: false,
|
|
111
|
+
irreversible: false,
|
|
112
|
+
undoable: false,
|
|
113
|
+
status: "proposed",
|
|
114
|
+
proposedReason: `Command '${cmd}' was rejected (${validation.reason}). Only known dev tools are auto-executed. Run manually if needed.`,
|
|
115
|
+
});
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
59
118
|
steps.push({
|
|
60
119
|
id: `check-python-format-${cmd.replace(/[^a-z0-9]/gi, "-")}`,
|
|
61
120
|
title: `Run Python format: ${cmd}`,
|
|
@@ -73,6 +132,24 @@ function pythonChecks(detection, config, selected) {
|
|
|
73
132
|
}
|
|
74
133
|
if (selected.includes("lint")) {
|
|
75
134
|
for (const cmd of config.python.tools.lint) {
|
|
135
|
+
const validation = isSafeCommand(cmd);
|
|
136
|
+
if (!validation.safe) {
|
|
137
|
+
steps.push({
|
|
138
|
+
id: `check-python-lint-${cmd.replace(/[^a-z0-9]/gi, "-")}`,
|
|
139
|
+
title: `SKIPPED: Rejected lint command: ${cmd}`,
|
|
140
|
+
subsystem: "checks",
|
|
141
|
+
phase: "checks",
|
|
142
|
+
checkKind: "lint",
|
|
143
|
+
rationale: `Config command rejected: ${validation.reason}.`,
|
|
144
|
+
commands: [],
|
|
145
|
+
destructive: false,
|
|
146
|
+
irreversible: false,
|
|
147
|
+
undoable: false,
|
|
148
|
+
status: "proposed",
|
|
149
|
+
proposedReason: `Command '${cmd}' was rejected (${validation.reason}). Only known dev tools are auto-executed. Run manually if needed.`,
|
|
150
|
+
});
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
76
153
|
steps.push({
|
|
77
154
|
id: `check-python-lint-${cmd.replace(/[^a-z0-9]/gi, "-")}`,
|
|
78
155
|
title: `Run Python lint: ${cmd}`,
|
|
@@ -90,6 +167,24 @@ function pythonChecks(detection, config, selected) {
|
|
|
90
167
|
}
|
|
91
168
|
if (selected.includes("test")) {
|
|
92
169
|
for (const cmd of config.python.tools.test) {
|
|
170
|
+
const validation = isSafeCommand(cmd);
|
|
171
|
+
if (!validation.safe) {
|
|
172
|
+
steps.push({
|
|
173
|
+
id: `check-python-test-${cmd.replace(/[^a-z0-9]/gi, "-")}`,
|
|
174
|
+
title: `SKIPPED: Rejected test command: ${cmd}`,
|
|
175
|
+
subsystem: "checks",
|
|
176
|
+
phase: "checks",
|
|
177
|
+
checkKind: "test",
|
|
178
|
+
rationale: `Config command rejected: ${validation.reason}.`,
|
|
179
|
+
commands: [],
|
|
180
|
+
destructive: false,
|
|
181
|
+
irreversible: false,
|
|
182
|
+
undoable: false,
|
|
183
|
+
status: "proposed",
|
|
184
|
+
proposedReason: `Command '${cmd}' was rejected (${validation.reason}). Only known dev tools are auto-executed. Run manually if needed.`,
|
|
185
|
+
});
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
93
188
|
steps.push({
|
|
94
189
|
id: `check-python-test-${cmd.replace(/[^a-z0-9]/gi, "-")}`,
|
|
95
190
|
title: `Run Python tests: ${cmd}`,
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { shellQuote } from "../utils/process.js";
|
|
4
|
+
/** Strict env key sanitizer: only allow [A-Z0-9_] */
|
|
5
|
+
function sanitizeEnvKey(key) {
|
|
6
|
+
return key.replace(/[^A-Z0-9_]/gi, "");
|
|
7
|
+
}
|
|
3
8
|
async function parseEnvKeys(filePath) {
|
|
4
9
|
try {
|
|
5
10
|
const raw = await readFile(filePath, "utf8");
|
|
@@ -10,7 +15,9 @@ async function parseEnvKeys(filePath) {
|
|
|
10
15
|
continue;
|
|
11
16
|
const idx = trimmed.indexOf("=");
|
|
12
17
|
if (idx > 0) {
|
|
13
|
-
|
|
18
|
+
const key = sanitizeEnvKey(trimmed.substring(0, idx).trim());
|
|
19
|
+
if (key)
|
|
20
|
+
keys.push(key);
|
|
14
21
|
}
|
|
15
22
|
}
|
|
16
23
|
return keys;
|
|
@@ -37,20 +44,20 @@ export async function buildEnvSteps(cwd, detection, config, flags) {
|
|
|
37
44
|
subsystem: "environment",
|
|
38
45
|
phase: "environment",
|
|
39
46
|
rationale: ".env is missing but .env.example exists.",
|
|
40
|
-
commands: [`cp ${envExamplePath} ${envPath}`],
|
|
47
|
+
commands: [`cp ${shellQuote(envExamplePath)} ${shellQuote(envPath)}`],
|
|
41
48
|
destructive: false,
|
|
42
49
|
irreversible: false,
|
|
43
50
|
undoable: true,
|
|
44
|
-
snapshotPaths: [".env.example"],
|
|
45
|
-
undoHints: [{ action: "Delete .env", command: `rm -f ${envPath}` }],
|
|
51
|
+
snapshotPaths: [".env.example"],
|
|
52
|
+
undoHints: [{ action: "Delete .env", command: `rm -f ${shellQuote(envPath)}` }],
|
|
46
53
|
status: "planned",
|
|
47
54
|
});
|
|
48
55
|
}
|
|
49
56
|
else {
|
|
50
57
|
const missingKeys = await findMissingKeys(envPath, envExamplePath);
|
|
51
58
|
if (missingKeys.length > 0) {
|
|
52
|
-
//
|
|
53
|
-
const appends = missingKeys.map((k) => `echo
|
|
59
|
+
// Keys are already sanitized to [A-Z0-9_] only — safe for shell interpolation
|
|
60
|
+
const appends = missingKeys.map((k) => `echo ${shellQuote(`${k}=`)} >> ${shellQuote(envPath)}`).join(" && ");
|
|
54
61
|
steps.push({
|
|
55
62
|
id: "env-sync-append-missing",
|
|
56
63
|
title: `Append ${missingKeys.length} missing key(s) to .env`,
|
package/dist/subsystems/node.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
+
import { isSafePath, shellQuote } from "../utils/process.js";
|
|
2
|
+
const ALLOWED_PMS = new Set(["npm", "pnpm", "yarn"]);
|
|
1
3
|
function choosePm(detected, configured) {
|
|
2
|
-
if (configured !== "auto")
|
|
3
|
-
|
|
4
|
+
if (configured !== "auto") {
|
|
5
|
+
// SEC-001: Validate package_manager from config against strict allowlist
|
|
6
|
+
return ALLOWED_PMS.has(configured) ? configured : "npm";
|
|
7
|
+
}
|
|
4
8
|
if (detected === "unknown")
|
|
5
9
|
return "npm";
|
|
6
10
|
return detected;
|
|
@@ -91,13 +95,29 @@ export function buildNodeSteps(detection, config, flags) {
|
|
|
91
95
|
});
|
|
92
96
|
}
|
|
93
97
|
for (const cacheDir of config.node.caches.directories) {
|
|
98
|
+
if (!isSafePath(cacheDir)) {
|
|
99
|
+
steps.push({
|
|
100
|
+
id: `node-clean-cache-${cacheDir.replace(/[^a-z0-9]/gi, "-")}`,
|
|
101
|
+
title: `SKIPPED: Unsafe cache directory name: ${cacheDir}`,
|
|
102
|
+
subsystem: "node",
|
|
103
|
+
phase: "node",
|
|
104
|
+
rationale: "Cache directory name contains suspicious characters and was rejected for safety.",
|
|
105
|
+
commands: [],
|
|
106
|
+
destructive: false,
|
|
107
|
+
irreversible: false,
|
|
108
|
+
undoable: false,
|
|
109
|
+
status: "proposed",
|
|
110
|
+
proposedReason: `Directory name '${cacheDir}' contains shell metacharacters. Rename it or clean manually.`,
|
|
111
|
+
});
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
94
114
|
steps.push({
|
|
95
115
|
id: `node-clean-cache-${cacheDir.replace(/[^a-z0-9]/gi, "-")}`,
|
|
96
116
|
title: `Clean cache directory ${cacheDir}`,
|
|
97
117
|
subsystem: "node",
|
|
98
118
|
phase: "node",
|
|
99
119
|
rationale: "Configured cache directory cleanup.",
|
|
100
|
-
commands: [`rm -rf ${cacheDir}`],
|
|
120
|
+
commands: [`rm -rf ${shellQuote(cacheDir)}`],
|
|
101
121
|
destructive: false,
|
|
102
122
|
irreversible: false,
|
|
103
123
|
undoable: false,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { shellQuote } from "../utils/process.js";
|
|
1
2
|
function installCommand(prefer) {
|
|
2
3
|
switch (prefer) {
|
|
3
4
|
case "uv":
|
|
@@ -16,6 +17,7 @@ export function buildPythonSteps(detection, config, flags) {
|
|
|
16
17
|
if (!detection.python.detected)
|
|
17
18
|
return [];
|
|
18
19
|
const steps = [];
|
|
20
|
+
const quotedVenv = shellQuote(config.python.venv_path);
|
|
19
21
|
if (!detection.python.venvExists) {
|
|
20
22
|
steps.push({
|
|
21
23
|
id: "python-create-venv",
|
|
@@ -23,7 +25,7 @@ export function buildPythonSteps(detection, config, flags) {
|
|
|
23
25
|
subsystem: "python",
|
|
24
26
|
phase: "python",
|
|
25
27
|
rationale: "Python project detected without configured virtual environment.",
|
|
26
|
-
commands: [`python3 -m venv ${
|
|
28
|
+
commands: [`python3 -m venv ${quotedVenv}`],
|
|
27
29
|
destructive: false,
|
|
28
30
|
irreversible: false,
|
|
29
31
|
undoable: false,
|
|
@@ -51,7 +53,7 @@ export function buildPythonSteps(detection, config, flags) {
|
|
|
51
53
|
subsystem: "python",
|
|
52
54
|
phase: "python",
|
|
53
55
|
rationale: "Deep cleanup for persistent Python environment drift.",
|
|
54
|
-
commands: [`rm -rf ${
|
|
56
|
+
commands: [`rm -rf ${quotedVenv}`, `python3 -m venv ${quotedVenv}`],
|
|
55
57
|
destructive: true,
|
|
56
58
|
irreversible: true,
|
|
57
59
|
irreversibleReason: "cannot restore environment state fully",
|
|
@@ -62,8 +64,10 @@ export function buildPythonSteps(detection, config, flags) {
|
|
|
62
64
|
}
|
|
63
65
|
// PRD v1.2: IDE Integration Auto-Configuration for VS Code
|
|
64
66
|
// Only sync when .venv exists or is being created by a preceding step
|
|
67
|
+
// REL-002: Skip write if existing settings.json can't be parsed (JSONC) to avoid clobbering
|
|
65
68
|
const venvWillExist = detection.python.venvExists || steps.some((s) => s.id === "python-create-venv");
|
|
66
69
|
if (venvWillExist) {
|
|
70
|
+
const escapedVenv = config.python.venv_path.replace(/'/g, "\\'");
|
|
67
71
|
steps.push({
|
|
68
72
|
id: "python-vscode-sync",
|
|
69
73
|
title: "Sync Python virtual environment with VS Code",
|
|
@@ -71,7 +75,7 @@ export function buildPythonSteps(detection, config, flags) {
|
|
|
71
75
|
phase: "python",
|
|
72
76
|
rationale: "VS Code needs to know the correct virtual environment path to prevent linting errors.",
|
|
73
77
|
commands: [
|
|
74
|
-
`mkdir -p .vscode && node -e "const fs=require('fs');const p='.vscode/settings.json';let s
|
|
78
|
+
`mkdir -p .vscode && node -e "const fs=require('fs');const p='.vscode/settings.json';let s;try{s=JSON.parse(fs.readFileSync(p,'utf8'))}catch(e){if(fs.existsSync(p)){console.log('SKIP: settings.json exists but is not valid JSON (may contain comments). Skipping to preserve your config.');process.exit(0)}s={}}s['python.defaultInterpreterPath']='${escapedVenv}';fs.writeFileSync(p,JSON.stringify(s,null,2))"`
|
|
75
79
|
],
|
|
76
80
|
destructive: false,
|
|
77
81
|
irreversible: false,
|
package/dist/utils/process.js
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
/**
|
|
3
|
+
* Run a command safely through the system shell.
|
|
4
|
+
* Uses execFile with explicit ["-c", command] to avoid direct shell string injection
|
|
5
|
+
* while still supporting piped/chained commands that subsystems require.
|
|
6
|
+
*/
|
|
2
7
|
export function runShellCommand(command, cwd) {
|
|
8
|
+
const shell = process.platform === "win32" ? "cmd.exe" : "/bin/sh";
|
|
9
|
+
const shellArgs = process.platform === "win32" ? ["/c", command] : ["-c", command];
|
|
3
10
|
return new Promise((resolve) => {
|
|
4
|
-
|
|
11
|
+
execFile(shell, shellArgs, { cwd, maxBuffer: 1024 * 1024 * 4, encoding: "utf8" }, (error, stdout, stderr) => {
|
|
5
12
|
if (error) {
|
|
6
13
|
resolve({
|
|
7
14
|
success: false,
|
|
@@ -15,3 +22,18 @@ export function runShellCommand(command, cwd) {
|
|
|
15
22
|
});
|
|
16
23
|
});
|
|
17
24
|
}
|
|
25
|
+
/**
|
|
26
|
+
* Shell-quote a string value for safe interpolation into shell commands.
|
|
27
|
+
* Wraps in single quotes and escapes embedded single quotes.
|
|
28
|
+
*/
|
|
29
|
+
export function shellQuote(value) {
|
|
30
|
+
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Validate a string against a strict safe-path pattern.
|
|
34
|
+
* Only allows alphanumeric, dots, dashes, underscores, and forward slashes.
|
|
35
|
+
* Rejects shell metacharacters like ;|&`$(){}
|
|
36
|
+
*/
|
|
37
|
+
export function isSafePath(value) {
|
|
38
|
+
return /^[a-zA-Z0-9._\-/]+$/.test(value) && !value.includes("..");
|
|
39
|
+
}
|