@bacnh85/pi-windows-tools 0.1.0
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 +47 -0
- package/docs/windows-tools.md +191 -0
- package/extensions/index.ts +96 -0
- package/extensions/lib/audit.ts +34 -0
- package/extensions/lib/doctor.ts +58 -0
- package/extensions/lib/path-utils.ts +104 -0
- package/extensions/lib/prompts.ts +115 -0
- package/extensions/lib/safety.ts +107 -0
- package/extensions/lib/shell-detect.ts +73 -0
- package/extensions/lib/shell-exec.ts +159 -0
- package/extensions/package.json +3 -0
- package/extensions/test/audit.test.ts +47 -0
- package/extensions/test/doctor.test.ts +89 -0
- package/extensions/test/path-utils.test.ts +144 -0
- package/extensions/test/safety.test.ts +96 -0
- package/extensions/test/shell-detect.test.ts +53 -0
- package/extensions/test/shell-exec.test.ts +76 -0
- package/package.json +58 -0
- package/skills/pi-windows-tools/SKILL.md +53 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
export type CommandRisk = "safe" | "confirm";
|
|
2
|
+
|
|
3
|
+
export interface RiskResult {
|
|
4
|
+
risk: CommandRisk;
|
|
5
|
+
reasons: string[];
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// ── Dangerous command patterns (case-insensitive prefix/path match) ──
|
|
9
|
+
|
|
10
|
+
const DESTRUCTIVE_COMMANDS: { pattern: RegExp; reason: string }[] = [
|
|
11
|
+
// PowerShell destructive
|
|
12
|
+
{ pattern: /remove-item\s+-recurse\s+-force/i, reason: "Recursive force delete" },
|
|
13
|
+
{ pattern: /rm\s+-[rf]+\s+/i, reason: "Recursive force remove" },
|
|
14
|
+
{ pattern: /rmdir\s+\/s\s+\/q/i, reason: "Recursive quiet directory remove" },
|
|
15
|
+
{ pattern: /del\s+\/s\s+\/q/i, reason: "Recursive quiet file delete" },
|
|
16
|
+
|
|
17
|
+
// Disk operations
|
|
18
|
+
{ pattern: /^format\s+/i, reason: "Format disk" },
|
|
19
|
+
{ pattern: /^diskpart\b/i, reason: "Disk partition tool" },
|
|
20
|
+
|
|
21
|
+
// Registry
|
|
22
|
+
{ pattern: /reg\s+delete/i, reason: "Registry key delete" },
|
|
23
|
+
|
|
24
|
+
// Service management
|
|
25
|
+
{ pattern: /sc\s+delete/i, reason: "Service delete" },
|
|
26
|
+
|
|
27
|
+
// Network
|
|
28
|
+
{ pattern: /netsh\s+advfirewall\s+reset/i, reason: "Firewall reset" },
|
|
29
|
+
|
|
30
|
+
// Git destructive
|
|
31
|
+
{ pattern: /git\s+clean\s+-fdx/i, reason: "Force clean git ignored files" },
|
|
32
|
+
{ pattern: /git\s+reset\s+--hard/i, reason: "Hard git reset" },
|
|
33
|
+
{ pattern: /git\s+push\s+--force/i, reason: "Force git push" },
|
|
34
|
+
{ pattern: /git\s+push\s+--force-with-lease/i, reason: "Force git push" },
|
|
35
|
+
|
|
36
|
+
// Package publish
|
|
37
|
+
{ pattern: /npm\s+publish/i, reason: "npm package publish" },
|
|
38
|
+
{ pattern: /pnpm\s+publish/i, reason: "pnpm package publish" },
|
|
39
|
+
{ pattern: /yarn\s+publish/i, reason: "yarn package publish" },
|
|
40
|
+
|
|
41
|
+
// System commands
|
|
42
|
+
{ pattern: /stop-computer/i, reason: "Shutdown computer" },
|
|
43
|
+
{ pattern: /restart-computer/i, reason: "Restart computer" },
|
|
44
|
+
{ pattern: /^shutdown\s+/i, reason: "Shutdown/restart computer" },
|
|
45
|
+
|
|
46
|
+
// Ownership/permissions (destructive)
|
|
47
|
+
{ pattern: /takeown\s+\/f/i, reason: "Take ownership of file" },
|
|
48
|
+
{ pattern: /icacls\s+\/grant/i, reason: "Grant file permissions" },
|
|
49
|
+
|
|
50
|
+
// WSL destructive
|
|
51
|
+
{ pattern: /wsl\s+--unregister/i, reason: "Unregister WSL distro" },
|
|
52
|
+
{ pattern: /wsl\s+--terminate/i, reason: "Terminate WSL distro" },
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
// ── Sensitive file / path patterns (case-insensitive) ──
|
|
56
|
+
|
|
57
|
+
const SENSITIVE_FILE_PATTERNS: { pattern: RegExp; reason: string }[] = [
|
|
58
|
+
// Environment / secrets
|
|
59
|
+
{ pattern: /(?:^|[\s\\/])\.env(?:\.[a-z0-9]+)?$/i, reason: "Environment file" },
|
|
60
|
+
{ pattern: /\.pem$/i, reason: "Private key (PEM)" },
|
|
61
|
+
{ pattern: /\.key$/i, reason: "Private key file" },
|
|
62
|
+
{ pattern: /id_rsa$/i, reason: "SSH private key (RSA)" },
|
|
63
|
+
{ pattern: /id_ed25519$/i, reason: "SSH private key (Ed25519)" },
|
|
64
|
+
{ pattern: /[\\/]\.ssh[\\/]/i, reason: "SSH directory" },
|
|
65
|
+
{ pattern: /[\\/]\.aws[\\/]/i, reason: "AWS credentials" },
|
|
66
|
+
{ pattern: /[\\/]\.azure[\\/]/i, reason: "Azure credentials" },
|
|
67
|
+
{ pattern: /[\\/]\.kube[\\/]/i, reason: "Kubernetes config" },
|
|
68
|
+
{ pattern: /[\\/]\.gcloud[\\/]/i, reason: "Google Cloud credentials" },
|
|
69
|
+
{ pattern: /npmrc$/i, reason: "npm config (may contain tokens)" },
|
|
70
|
+
{ pattern: /\.pypirc$/i, reason: "Python package config (may contain tokens)" },
|
|
71
|
+
{ pattern: /[\\/]AppData[\\/]Roaming[\\/]Microsoft[\\/]Credentials/i, reason: "Windows stored credentials" },
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Classify a command string for risk.
|
|
76
|
+
* Returns the highest risk level found along with matched rules.
|
|
77
|
+
*/
|
|
78
|
+
export function classifyCommand(command: string): RiskResult {
|
|
79
|
+
const reasons: string[] = [];
|
|
80
|
+
|
|
81
|
+
// Check destructive patterns
|
|
82
|
+
for (const rule of DESTRUCTIVE_COMMANDS) {
|
|
83
|
+
if (rule.pattern.test(command)) {
|
|
84
|
+
reasons.push(rule.reason);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Check sensitive file paths in the command
|
|
89
|
+
for (const rule of SENSITIVE_FILE_PATTERNS) {
|
|
90
|
+
if (rule.pattern.test(command)) {
|
|
91
|
+
reasons.push(rule.reason);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (reasons.length > 0) {
|
|
96
|
+
return { risk: "confirm", reasons };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { risk: "safe", reasons: [] };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Check if a path matches any sensitive file pattern.
|
|
104
|
+
*/
|
|
105
|
+
export function isSensitivePath(path: string): boolean {
|
|
106
|
+
return SENSITIVE_FILE_PATTERNS.some(rule => rule.pattern.test(path));
|
|
107
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
|
|
4
|
+
export type WindowsShellKind = "pwsh" | "powershell" | "cmd" | "git-bash" | "wsl";
|
|
5
|
+
|
|
6
|
+
export interface ShellInfo {
|
|
7
|
+
kind: WindowsShellKind;
|
|
8
|
+
displayName: string;
|
|
9
|
+
executable: string;
|
|
10
|
+
available: boolean;
|
|
11
|
+
version?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function where(cmd: string): string | null {
|
|
15
|
+
try {
|
|
16
|
+
return execFileSync("where", [cmd], { encoding: "utf8", timeout: 3000 })
|
|
17
|
+
.split(/\r?\n/)[0]?.trim() || null;
|
|
18
|
+
} catch { return null; }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function getVersion(cmd: string, args: string[]): string | undefined {
|
|
22
|
+
try {
|
|
23
|
+
return execFileSync(cmd, args, { encoding: "utf8", timeout: 3000 })
|
|
24
|
+
.split(/\r?\n/)[0]?.trim();
|
|
25
|
+
} catch { return undefined; }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function detectShell(kind: WindowsShellKind): ShellInfo {
|
|
29
|
+
switch (kind) {
|
|
30
|
+
case "pwsh": {
|
|
31
|
+
const exe = where("pwsh");
|
|
32
|
+
return { kind, displayName: "PowerShell 7+", executable: exe || "pwsh.exe", available: !!exe, version: exe ? getVersion(exe, ["--version"]) : undefined };
|
|
33
|
+
}
|
|
34
|
+
case "powershell": {
|
|
35
|
+
const exe = where("powershell");
|
|
36
|
+
return { kind, displayName: "Windows PowerShell", executable: exe || "powershell.exe", available: !!exe, version: exe ? getVersion(exe, ["-Command", "$PSVersionTable.PSVersion.ToString()"]) : undefined };
|
|
37
|
+
}
|
|
38
|
+
case "cmd": {
|
|
39
|
+
const comspec = process.env.ComSpec || "cmd.exe";
|
|
40
|
+
const exe = existsSync(comspec) ? comspec : where("cmd");
|
|
41
|
+
return { kind, displayName: "Command Prompt", executable: exe || "cmd.exe", available: !!exe || process.platform === "win32" };
|
|
42
|
+
}
|
|
43
|
+
case "git-bash": {
|
|
44
|
+
const candidates = ["C:\\Program Files\\Git\\bin\\bash.exe", "C:\\Program Files (x86)\\Git\\bin\\bash.exe", process.env.PI_GIT_BASH_PATH || ""].filter(Boolean);
|
|
45
|
+
const fromPath = where("bash");
|
|
46
|
+
const exe = candidates.find(c => existsSync(c)) || (fromPath ? fromPath : null);
|
|
47
|
+
const isGitBash = exe && (exe.toLowerCase().includes("git") || exe.toLowerCase().includes("program files") || exe.toLowerCase().includes("scoop") || exe.toLowerCase().includes("chocolatey") || getVersion(exe, ["--version"])?.toLowerCase().includes("gnu bash") === true);
|
|
48
|
+
return { kind, displayName: "Git Bash", executable: exe || "bash.exe", available: !!exe && !!isGitBash, version: exe ? getVersion(exe, ["--version"]) : undefined };
|
|
49
|
+
}
|
|
50
|
+
case "wsl": {
|
|
51
|
+
const exe = where("wsl");
|
|
52
|
+
return { kind, displayName: "WSL", executable: "wsl.exe", available: !!exe, version: exe ? getVersion("wsl.exe", ["--status"]) : undefined };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function detectAllShells(): ShellInfo[] {
|
|
58
|
+
return (["pwsh", "powershell", "cmd", "git-bash", "wsl"] as WindowsShellKind[]).map(detectShell);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function getAvailableShells(): ShellInfo[] {
|
|
62
|
+
return detectAllShells().filter(s => s.available);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function getDefaultShell(): ShellInfo {
|
|
66
|
+
const envShell = process.env.PI_WINDOWS_SHELL as WindowsShellKind | undefined;
|
|
67
|
+
if (envShell && detectShell(envShell).available) return detectShell(envShell);
|
|
68
|
+
for (const kind of ["pwsh", "powershell", "git-bash", "cmd", "wsl"] as WindowsShellKind[]) {
|
|
69
|
+
const info = detectShell(kind);
|
|
70
|
+
if (info.available) return info;
|
|
71
|
+
}
|
|
72
|
+
return detectShell("cmd");
|
|
73
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import type { WindowsShellKind } from "./shell-detect";
|
|
3
|
+
import { getDefaultShell, detectShell } from "./shell-detect";
|
|
4
|
+
|
|
5
|
+
export interface ExecOptions {
|
|
6
|
+
shell?: WindowsShellKind;
|
|
7
|
+
cwd?: string;
|
|
8
|
+
env?: Record<string, string>;
|
|
9
|
+
timeoutMs?: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ExecResult {
|
|
13
|
+
command: string;
|
|
14
|
+
shell: WindowsShellKind;
|
|
15
|
+
cwd: string;
|
|
16
|
+
exitCode: number | null;
|
|
17
|
+
stdout: string;
|
|
18
|
+
stderr: string;
|
|
19
|
+
timedOut: boolean;
|
|
20
|
+
cancelled: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Merge custom env with process.env deduplicating case-insensitively.
|
|
25
|
+
* On Windows, "PATH" and "Path" are the same variable — passing both
|
|
26
|
+
* causes duplicate entries. This keeps the custom value when keys
|
|
27
|
+
* differ only in case.
|
|
28
|
+
*/
|
|
29
|
+
export function mergeEnv(customEnv: Record<string, string>): Record<string, string> {
|
|
30
|
+
const merged: Record<string, string> = {};
|
|
31
|
+
|
|
32
|
+
// Build set of lowercase custom keys for O(1) lookup
|
|
33
|
+
const customLower = new Set<string>();
|
|
34
|
+
for (const key of Object.keys(customEnv)) {
|
|
35
|
+
customLower.add(key.toLowerCase());
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Copy process.env keys, skipping any that match a custom key case-insensitively
|
|
39
|
+
for (const key of Object.keys(process.env)) {
|
|
40
|
+
if (customLower.has(key.toLowerCase())) continue;
|
|
41
|
+
const val = process.env[key];
|
|
42
|
+
if (val !== undefined) {
|
|
43
|
+
merged[key] = val;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Add custom keys (they win)
|
|
48
|
+
for (const [key, val] of Object.entries(customEnv)) {
|
|
49
|
+
merged[key] = val;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return merged;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Build the arg array for a given shell kind.
|
|
57
|
+
*/
|
|
58
|
+
export function buildShellArgs(kind: WindowsShellKind, command: string, distro?: string): { exe: string; args: string[] } {
|
|
59
|
+
switch (kind) {
|
|
60
|
+
case "pwsh":
|
|
61
|
+
case "powershell": {
|
|
62
|
+
const info = detectShell(kind);
|
|
63
|
+
return {
|
|
64
|
+
exe: info.executable,
|
|
65
|
+
args: [
|
|
66
|
+
"-NoLogo",
|
|
67
|
+
"-NoProfile",
|
|
68
|
+
"-NonInteractive",
|
|
69
|
+
"-ExecutionPolicy", "Bypass",
|
|
70
|
+
"-Command", command,
|
|
71
|
+
],
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
case "cmd": {
|
|
75
|
+
const comspec = process.env.ComSpec || "cmd.exe";
|
|
76
|
+
return {
|
|
77
|
+
exe: comspec,
|
|
78
|
+
args: ["/c", command],
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
case "git-bash": {
|
|
82
|
+
const info = detectShell(kind);
|
|
83
|
+
return {
|
|
84
|
+
exe: info.executable,
|
|
85
|
+
args: ["-lc", command],
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
case "wsl": {
|
|
89
|
+
const distroFlag = distro ? ["-d", distro] : [];
|
|
90
|
+
return {
|
|
91
|
+
exe: "wsl.exe",
|
|
92
|
+
args: [...distroFlag, "--", "bash", "-lc", command],
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Execute a command through the specified Windows shell.
|
|
100
|
+
* Returns stdout, stderr, exit code, and cancellation/timeout info.
|
|
101
|
+
*/
|
|
102
|
+
export function executeCommand(command: string, options: ExecOptions = {}): Promise<ExecResult> {
|
|
103
|
+
const shellKind = options.shell || getDefaultShell().kind;
|
|
104
|
+
const cwd = options.cwd || process.cwd();
|
|
105
|
+
const env = options.env || {};
|
|
106
|
+
|
|
107
|
+
// Use WSL_DISTRO from env or config if user set it
|
|
108
|
+
const distro = process.env.PI_WSL_DISTRO || undefined;
|
|
109
|
+
const { exe, args } = buildShellArgs(shellKind, command, distro);
|
|
110
|
+
|
|
111
|
+
return new Promise((resolve) => {
|
|
112
|
+
const child = spawn(exe, args, {
|
|
113
|
+
cwd,
|
|
114
|
+
env: mergeEnv(env),
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
let stdout = "";
|
|
118
|
+
let stderr = "";
|
|
119
|
+
let timedOut = false;
|
|
120
|
+
|
|
121
|
+
const timer = options.timeoutMs
|
|
122
|
+
? setTimeout(() => {
|
|
123
|
+
timedOut = true;
|
|
124
|
+
child.kill("SIGTERM");
|
|
125
|
+
}, options.timeoutMs)
|
|
126
|
+
: null;
|
|
127
|
+
|
|
128
|
+
child.stdout?.on("data", (chunk: Buffer) => { stdout += chunk.toString("utf8"); });
|
|
129
|
+
child.stderr?.on("data", (chunk: Buffer) => { stderr += chunk.toString("utf8"); });
|
|
130
|
+
|
|
131
|
+
child.on("close", (exitCode, signal) => {
|
|
132
|
+
if (timer) clearTimeout(timer);
|
|
133
|
+
resolve({
|
|
134
|
+
command,
|
|
135
|
+
shell: shellKind,
|
|
136
|
+
cwd,
|
|
137
|
+
exitCode: timedOut ? null : exitCode,
|
|
138
|
+
stdout: stdout.replace(/\r\n/g, "\n"),
|
|
139
|
+
stderr: stderr.replace(/\r\n/g, "\n"),
|
|
140
|
+
timedOut,
|
|
141
|
+
cancelled: signal !== null && !timedOut,
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
child.on("error", () => {
|
|
146
|
+
if (timer) clearTimeout(timer);
|
|
147
|
+
resolve({
|
|
148
|
+
command,
|
|
149
|
+
shell: shellKind,
|
|
150
|
+
cwd,
|
|
151
|
+
exitCode: 1,
|
|
152
|
+
stdout,
|
|
153
|
+
stderr: stderr || "Failed to spawn process",
|
|
154
|
+
timedOut: false,
|
|
155
|
+
cancelled: false,
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, it } from "mocha";
|
|
2
|
+
import { expect } from "chai";
|
|
3
|
+
import { record, entries, clear, format } from "../lib/audit";
|
|
4
|
+
|
|
5
|
+
describe("audit", () => {
|
|
6
|
+
beforeEach(() => clear());
|
|
7
|
+
|
|
8
|
+
it("starts empty", () => {
|
|
9
|
+
expect(format()).to.equal("No commands executed yet.");
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("records an entry", () => {
|
|
13
|
+
record({ timestamp: "a", shell: "pwsh", command: "echo hi", cwd: "/", exitCode: 0, timedOut: false, cancelled: false });
|
|
14
|
+
expect(entries()).to.have.length(1);
|
|
15
|
+
expect(entries()[0].command).to.equal("echo hi");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("clear removes all entries", () => {
|
|
19
|
+
record({ timestamp: "a", shell: "pwsh", command: "c1", cwd: "/", exitCode: 0, timedOut: false, cancelled: false });
|
|
20
|
+
clear();
|
|
21
|
+
expect(entries()).to.have.length(0);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("format shows command and exit code", () => {
|
|
25
|
+
record({ timestamp: "t", shell: "pwsh", command: "echo hi", cwd: "/", exitCode: 0, timedOut: false, cancelled: false });
|
|
26
|
+
const out = format();
|
|
27
|
+
expect(out).to.include("echo hi");
|
|
28
|
+
expect(out).to.include("exit:0");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("format shows timed out flag", () => {
|
|
32
|
+
record({ timestamp: "a", shell: "cmd", command: "sleep 10", cwd: "/", exitCode: null, timedOut: true, cancelled: false });
|
|
33
|
+
const out = format();
|
|
34
|
+
expect(out).to.include("TIMED OUT");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("format shows cancelled flag", () => {
|
|
38
|
+
record({ timestamp: "a", shell: "cmd", command: "x", cwd: "/", exitCode: null, timedOut: false, cancelled: true });
|
|
39
|
+
const out = format();
|
|
40
|
+
expect(out).to.include("CANCELLED");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("truncates long commands", () => {
|
|
44
|
+
record({ timestamp: "a", shell: "pwsh", command: "x".repeat(500), cwd: "/", exitCode: 0, timedOut: false, cancelled: false });
|
|
45
|
+
expect(format()).to.include("\u2026");
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { describe, it } from "mocha";
|
|
2
|
+
import { expect } from "chai";
|
|
3
|
+
import { formatDoctorReport } from "../lib/doctor";
|
|
4
|
+
import type { DoctorReport } from "../lib/doctor";
|
|
5
|
+
|
|
6
|
+
describe("doctor", () => {
|
|
7
|
+
|
|
8
|
+
const sampleReport: DoctorReport = {
|
|
9
|
+
os: "Windows_NT",
|
|
10
|
+
osVersion: "10.0.22631",
|
|
11
|
+
architecture: "x64",
|
|
12
|
+
defaultShell: "pwsh",
|
|
13
|
+
tools: [
|
|
14
|
+
{ name: "pwsh", found: true, path: "C:\\Program Files\\PowerShell\\7\\pwsh.exe", version: "7.4.0" },
|
|
15
|
+
{ name: "powershell", found: true, path: "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", version: "5.1.22621" },
|
|
16
|
+
{ name: "cmd", found: true, path: "C:\\Windows\\System32\\cmd.exe" },
|
|
17
|
+
{ name: "git", found: true, path: "C:\\Program Files\\Git\\cmd\\git.exe", version: "2.42.0.windows.2" },
|
|
18
|
+
{ name: "bash (Git Bash)", found: true, path: "C:\\Program Files\\Git\\bin\\bash.exe", version: "5.2.15" },
|
|
19
|
+
{ name: "wsl", found: true, path: "C:\\Windows\\System32\\wsl.exe" },
|
|
20
|
+
{ name: "node", found: true, path: "C:\\Program Files\\nodejs\\node.exe", version: "v22.0.0" },
|
|
21
|
+
{ name: "npm", found: true, path: "C:\\Program Files\\nodejs\\npm.cmd", version: "10.5.0" },
|
|
22
|
+
{ name: "pnpm", found: false },
|
|
23
|
+
{ name: "yarn", found: false },
|
|
24
|
+
{ name: "python", found: true, path: "C:\\Python312\\python.exe", version: "3.12.0" },
|
|
25
|
+
{ name: "py launcher", found: true, path: "C:\\Windows\\py.exe", version: "3.12" },
|
|
26
|
+
{ name: "dotnet", found: true, path: "C:\\Program Files\\dotnet\\dotnet.exe", version: "8.0.100" },
|
|
27
|
+
{ name: "cmake", found: false },
|
|
28
|
+
{ name: "ninja", found: false },
|
|
29
|
+
{ name: "winget", found: true, path: "C:\\Users\\me\\AppData\\Local\\Microsoft\\WindowsApps\\winget.exe" },
|
|
30
|
+
{ name: "choco", found: false },
|
|
31
|
+
{ name: "scoop", found: false },
|
|
32
|
+
{ name: "ssh", found: true, path: "C:\\Windows\\System32\\OpenSSH\\ssh.exe" },
|
|
33
|
+
{ name: "msbuild", found: false },
|
|
34
|
+
{ name: "cl", found: false },
|
|
35
|
+
{ name: "devenv", found: false },
|
|
36
|
+
{ name: "reg", found: true, path: "C:\\Windows\\System32\\reg.exe" },
|
|
37
|
+
{ name: "sc", found: true, path: "C:\\Windows\\System32\\sc.exe" },
|
|
38
|
+
{ name: "netsh", found: true, path: "C:\\Windows\\System32\\netsh.exe" },
|
|
39
|
+
],
|
|
40
|
+
wslDistros: ["Ubuntu-24.04", "Debian"],
|
|
41
|
+
longPathsEnabled: true,
|
|
42
|
+
developerMode: true,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
it("formatDoctorReport produces expected header", () => {
|
|
46
|
+
const output = formatDoctorReport(sampleReport);
|
|
47
|
+
expect(output).to.include("Windows Tools Doctor");
|
|
48
|
+
expect(output).to.include("OS: Windows_NT 10.0.22631");
|
|
49
|
+
expect(output).to.include("Architecture: x64");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("includes tool status with ✓ and ✗", () => {
|
|
53
|
+
const output = formatDoctorReport(sampleReport);
|
|
54
|
+
expect(output).to.include("✓ pwsh");
|
|
55
|
+
expect(output).to.include("✗ pnpm");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("includes WSL distros", () => {
|
|
59
|
+
const output = formatDoctorReport(sampleReport);
|
|
60
|
+
expect(output).to.include("Ubuntu-24.04");
|
|
61
|
+
expect(output).to.include("Debian");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("includes long paths and dev mode", () => {
|
|
65
|
+
const output = formatDoctorReport(sampleReport);
|
|
66
|
+
expect(output).to.include("Long paths: enabled");
|
|
67
|
+
expect(output).to.include("Developer Mode: enabled");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("handles empty tools list", () => {
|
|
71
|
+
const empty: DoctorReport = {
|
|
72
|
+
os: "Windows_NT",
|
|
73
|
+
osVersion: "",
|
|
74
|
+
architecture: "x64",
|
|
75
|
+
defaultShell: "cmd",
|
|
76
|
+
tools: [],
|
|
77
|
+
wslDistros: [],
|
|
78
|
+
longPathsEnabled: null,
|
|
79
|
+
developerMode: null,
|
|
80
|
+
};
|
|
81
|
+
const output = formatDoctorReport(empty);
|
|
82
|
+
expect(output).to.include("Long paths: unknown");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("includes version when present", () => {
|
|
86
|
+
const output = formatDoctorReport(sampleReport);
|
|
87
|
+
expect(output).to.include("7.4.0");
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { describe, it } from "mocha";
|
|
2
|
+
import { expect } from "chai";
|
|
3
|
+
import {
|
|
4
|
+
toPosixPath,
|
|
5
|
+
toWindowsPath,
|
|
6
|
+
toWslPath,
|
|
7
|
+
toGitBashPath,
|
|
8
|
+
normalizeWindowsPath,
|
|
9
|
+
isWindowsAbsolutePath,
|
|
10
|
+
quoteForShell,
|
|
11
|
+
} from "../lib/path-utils";
|
|
12
|
+
import type { WindowsShellKind } from "../lib/shell-detect";
|
|
13
|
+
|
|
14
|
+
describe("path-utils", () => {
|
|
15
|
+
|
|
16
|
+
describe("toPosixPath", () => {
|
|
17
|
+
it("converts C:\\foo\\bar to /c/foo/bar", () => {
|
|
18
|
+
expect(toPosixPath("C:\\foo\\bar")).to.equal("/c/foo/bar");
|
|
19
|
+
});
|
|
20
|
+
it("converts C:/foo/bar to /c/foo/bar", () => {
|
|
21
|
+
expect(toPosixPath("C:/foo/bar")).to.equal("/c/foo/bar");
|
|
22
|
+
});
|
|
23
|
+
it("converts D:\\path with spaces to /d/path with spaces", () => {
|
|
24
|
+
expect(toPosixPath("D:\\path with spaces")).to.equal("/d/path with spaces");
|
|
25
|
+
});
|
|
26
|
+
it("handles lowercase drive letter", () => {
|
|
27
|
+
expect(toPosixPath("c:\\windows\\system32")).to.equal("/c/windows/system32");
|
|
28
|
+
});
|
|
29
|
+
it("strips \\\\?\\ long-path prefix", () => {
|
|
30
|
+
expect(toPosixPath("\\\\?\\C:\\foo")).to.equal("/c/foo");
|
|
31
|
+
});
|
|
32
|
+
it("handles drive-relative path C:foo\\bar", () => {
|
|
33
|
+
expect(toPosixPath("C:foo\\bar")).to.equal("/c/foo/bar");
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("toWindowsPath", () => {
|
|
38
|
+
it("converts /c/foo/bar to C:\\foo\\bar", () => {
|
|
39
|
+
expect(toWindowsPath("/c/foo/bar")).to.equal("C:\\foo\\bar");
|
|
40
|
+
});
|
|
41
|
+
it("converts /mnt/c/foo/bar to C:\\foo\\bar", () => {
|
|
42
|
+
expect(toWindowsPath("/mnt/c/foo/bar")).to.equal("C:\\foo\\bar");
|
|
43
|
+
});
|
|
44
|
+
it("preserves relative paths", () => {
|
|
45
|
+
expect(toWindowsPath("relative/path")).to.equal("relative\\path");
|
|
46
|
+
});
|
|
47
|
+
it("handles lowercase drive", () => {
|
|
48
|
+
expect(toWindowsPath("/d/work/repo")).to.equal("D:\\work\\repo");
|
|
49
|
+
});
|
|
50
|
+
it("preserves Windows-native paths already in C:\\ format", () => {
|
|
51
|
+
expect(toWindowsPath("C:\\Users\\me")).to.equal("C:\\Users\\me");
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("toWslPath", () => {
|
|
56
|
+
it("converts C:\\Users\\me to /mnt/c/Users/me", () => {
|
|
57
|
+
expect(toWslPath("C:\\Users\\me")).to.equal("/mnt/c/Users/me");
|
|
58
|
+
});
|
|
59
|
+
it("converts D:\\work to /mnt/d/work", () => {
|
|
60
|
+
expect(toWslPath("D:\\work")).to.equal("/mnt/d/work");
|
|
61
|
+
});
|
|
62
|
+
it("does not double-wrap already-WSL paths", () => {
|
|
63
|
+
expect(toWslPath("/mnt/c/Users/me")).to.equal("/mnt/c/Users/me");
|
|
64
|
+
});
|
|
65
|
+
it("wraps Git Bash paths to /mnt/", () => {
|
|
66
|
+
expect(toWslPath("/c/foo")).to.equal("/mnt/c/foo");
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("toGitBashPath", () => {
|
|
71
|
+
it("converts C:\\Users to /c/Users", () => {
|
|
72
|
+
expect(toGitBashPath("C:\\Users")).to.equal("/c/Users");
|
|
73
|
+
});
|
|
74
|
+
it("same as toPosixPath", () => {
|
|
75
|
+
expect(toGitBashPath("D:\\temp")).to.equal(toPosixPath("D:\\temp"));
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("normalizeWindowsPath", () => {
|
|
80
|
+
it("converts forward slashes to backslashes", () => {
|
|
81
|
+
expect(normalizeWindowsPath("C:/foo/bar")).to.equal("C:\\foo\\bar");
|
|
82
|
+
});
|
|
83
|
+
it("uppercases drive letter", () => {
|
|
84
|
+
expect(normalizeWindowsPath("c:\\windows")).to.equal("C:\\windows");
|
|
85
|
+
});
|
|
86
|
+
it("resolves . and ..", () => {
|
|
87
|
+
expect(normalizeWindowsPath("C:\\foo\\.\\bar\\..\\baz")).to.equal("C:\\foo\\baz");
|
|
88
|
+
});
|
|
89
|
+
it("deduplicates backslashes", () => {
|
|
90
|
+
expect(normalizeWindowsPath("C:\\foo\\\\\\bar")).to.equal("C:\\foo\\bar");
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("isWindowsAbsolutePath", () => {
|
|
95
|
+
it("detects C:\\ as absolute", () => {
|
|
96
|
+
expect(isWindowsAbsolutePath("C:\\foo")).to.be.true;
|
|
97
|
+
});
|
|
98
|
+
it("detects C:/ as absolute", () => {
|
|
99
|
+
expect(isWindowsAbsolutePath("C:/foo")).to.be.true;
|
|
100
|
+
});
|
|
101
|
+
it("detects UNC as absolute", () => {
|
|
102
|
+
expect(isWindowsAbsolutePath("\\\\server\\share")).to.be.true;
|
|
103
|
+
});
|
|
104
|
+
it("detects \\\\?\\ long path prefix as absolute", () => {
|
|
105
|
+
expect(isWindowsAbsolutePath("\\\\?\\C:\\foo")).to.be.true;
|
|
106
|
+
});
|
|
107
|
+
it("detects \\\\.\\ device paths as absolute", () => {
|
|
108
|
+
expect(isWindowsAbsolutePath("\\\\.\\COM1")).to.be.true;
|
|
109
|
+
});
|
|
110
|
+
it("rejects relative paths", () => {
|
|
111
|
+
expect(isWindowsAbsolutePath("relative\\path")).to.be.false;
|
|
112
|
+
});
|
|
113
|
+
it("rejects POSIX absolute paths", () => {
|
|
114
|
+
expect(isWindowsAbsolutePath("/c/foo")).to.be.false;
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe("quoteForShell", () => {
|
|
119
|
+
const shells: WindowsShellKind[] = ["pwsh", "powershell", "cmd", "git-bash", "wsl"];
|
|
120
|
+
|
|
121
|
+
it("does not quote paths without spaces", () => {
|
|
122
|
+
for (const s of shells) {
|
|
123
|
+
expect(quoteForShell("C:\\foo\\bar", s)).to.equal("C:\\foo\\bar");
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
it("quotes paths with spaces for PowerShell", () => {
|
|
127
|
+
expect(quoteForShell("C:\\Program Files\\Git", "pwsh")).to.include("'");
|
|
128
|
+
});
|
|
129
|
+
it("quotes paths with spaces for cmd", () => {
|
|
130
|
+
expect(quoteForShell("C:\\Program Files\\Git", "cmd")).to.include('"');
|
|
131
|
+
});
|
|
132
|
+
it("quotes paths with spaces for git-bash", () => {
|
|
133
|
+
expect(quoteForShell("/c/Program Files", "git-bash")).to.include("'");
|
|
134
|
+
});
|
|
135
|
+
it("quotes paths with spaces for wsl", () => {
|
|
136
|
+
expect(quoteForShell("/mnt/c/Program Files", "wsl")).to.include("'");
|
|
137
|
+
});
|
|
138
|
+
it("handles empty path", () => {
|
|
139
|
+
for (const s of shells) {
|
|
140
|
+
expect(quoteForShell("", s)).to.equal('""');
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
});
|