|
import json |
|
import os |
|
import subprocess |
|
from typing import Dict, List, Tuple |
|
import shlex |
|
import argparse |
|
|
|
import ctypes |
|
from ctypes import wintypes |
|
from pathlib import Path |
|
|
|
# comtypes is only required for the "shortcut" mode (script B style) |
|
try: |
|
import comtypes.client |
|
except ImportError: |
|
comtypes = None |
|
|
|
|
|
# ============================== |
|
# Path conversion utilities |
|
# ============================== |
|
|
|
def convert_windows_path_list_to_msys(path_value: str) -> str: |
|
""" |
|
Convert a Windows ';'-separated PATH string into ':'-separated |
|
MSYS-style paths (e.g. /c/...). |
|
""" |
|
path_unix = subprocess.check_output( |
|
["C:\\msys64\\usr\\bin\\cygpath.exe", "-u", "-p", path_value], |
|
text=True, |
|
) |
|
path_unix = path_unix.rstrip() |
|
return path_unix |
|
|
|
|
|
# ============================== |
|
# Shell utilities |
|
# ============================== |
|
|
|
def shell_quote(value: str) -> str: |
|
""" |
|
Safely quote a string for use in a POSIX shell. |
|
Example: |
|
foo'bar -> 'foo'"'"'bar' |
|
""" |
|
return shlex.quote(value) |
|
|
|
|
|
# ============================== |
|
# Env diff → bash script generation |
|
# ============================== |
|
|
|
IGNORED_ENV_KEYS = {"__VSCMD_PREINIT_PATH"} |
|
|
|
|
|
def parse_env_snapshot_json(data_str: str) -> Tuple[Dict[str, str], Dict[str, str]]: |
|
""" |
|
Parse a JSON string passed from PowerShell and return |
|
the 'before' and 'after' environment snapshots as dictionaries. |
|
""" |
|
data = json.loads(data_str) |
|
env_before = data.get("before", {}) or {} |
|
env_after = data.get("after", {}) or {} |
|
return env_before, env_after |
|
|
|
|
|
def compute_env_changes( |
|
env_before: Dict[str, str], |
|
env_after: Dict[str, str], |
|
) -> Tuple[List[Tuple[str, str]], List[str]]: |
|
""" |
|
Compute the difference between two environment snapshots. |
|
|
|
Returns: |
|
added_or_changed: list of (key, value) for added or changed variables |
|
removed: list of keys that were removed |
|
""" |
|
keys_before = set(env_before.keys()) |
|
keys_after = set(env_after.keys()) |
|
|
|
added_or_changed: List[Tuple[str, str]] = [] |
|
removed: List[str] = [] |
|
|
|
# Added or changed variables |
|
for key in sorted(keys_after): |
|
if key in IGNORED_ENV_KEYS: |
|
continue |
|
|
|
value_after = env_after[key] |
|
value_before = env_before.get(key) |
|
|
|
if value_before != value_after: |
|
added_or_changed.append((key, value_after)) |
|
|
|
# Removed variables |
|
for key in sorted(keys_before - keys_after): |
|
if key in IGNORED_ENV_KEYS: |
|
continue |
|
removed.append(key) |
|
|
|
return added_or_changed, removed |
|
|
|
|
|
def compute_path_additions( |
|
env_before: Dict[str, str], |
|
env_after: Dict[str, str], |
|
) -> List[str]: |
|
""" |
|
Return only the newly added elements from the Windows 'Path' environment |
|
variable difference. |
|
""" |
|
before_path_raw = env_before.get("Path") |
|
after_path_raw = env_after.get("Path") |
|
|
|
if after_path_raw is None: |
|
return [] |
|
|
|
after_elems = [elem for elem in after_path_raw.split(";") if elem.strip()] |
|
before_elems = [elem for elem in (before_path_raw or "").split(";") if elem.strip()] |
|
before_set = set(before_elems) |
|
|
|
return [elem for elem in after_elems if elem not in before_set] |
|
|
|
|
|
def generate_bash_env_script(data_str: str) -> str: |
|
""" |
|
Generate a bash script that applies export/unset operations |
|
based on the environment diff JSON captured from PowerShell. |
|
""" |
|
env_before, env_after = parse_env_snapshot_json(data_str) |
|
added_or_changed, removed = compute_env_changes(env_before, env_after) |
|
path_additions = compute_path_additions(env_before, env_after) |
|
|
|
lines: List[str] = [] |
|
|
|
# 1. Export all non-Path variables |
|
for key, value in added_or_changed: |
|
if key.lower() == "path": |
|
# Handle Path separately |
|
continue |
|
lines.append(f"export {key}={shell_quote(value)}") |
|
|
|
# 2. Convert Path additions to MSYS format and append to PATH |
|
if path_additions: |
|
converted_path = convert_windows_path_list_to_msys(";".join(path_additions)) |
|
# On Windows the variable is 'Path' but in bash it's 'PATH' |
|
lines.append(f'PATH="$PATH":{shell_quote(converted_path)}') |
|
|
|
# 3. Unset removed variables |
|
for key in removed: |
|
# Even if Path is removed on Windows side, we already have PATH on the MSYS side, |
|
# so unsetting PATH would be dangerous. Skip it. |
|
if key.lower() == "path": |
|
continue |
|
lines.append(f"unset {key}") |
|
|
|
return "\n".join(lines) + ("\n" if lines else "") |
|
|
|
|
|
# ============================== |
|
# VS DevShell invocation (Mode A: vswhere) |
|
# ============================== |
|
|
|
def build_vs_dev_shell_pwsh_script_vswhere(instance_id: str, dev_shell_dll_path: str) -> str: |
|
""" |
|
Build a PowerShell script string that calls VS DevShell and |
|
outputs the before/after environment as JSON. |
|
(Script A style) |
|
""" |
|
# DevShell invocation part |
|
dev_shell_call = ( |
|
f"&{{ Import-Module {json.dumps(dev_shell_dll_path)}; " |
|
f"Enter-VsDevShell {instance_id} -Arch amd64 -DevCmdArguments -no_logo }};" |
|
) |
|
|
|
# Capture before / after environments into Hashtables and convert to JSON |
|
pwsh_script = ( |
|
"function Get-EnvAsHashtable { " |
|
"$envTable = @{}; " |
|
"Get-ChildItem Env: | ForEach-Object { $envTable[$_.Name] = $_.Value }; " |
|
"return $envTable " |
|
"}; " |
|
"$before = Get-EnvAsHashtable; " |
|
f"{dev_shell_call} " |
|
"$after = Get-EnvAsHashtable; " |
|
"[pscustomobject]@{ before = $before; after = $after } | ConvertTo-Json" |
|
) |
|
return pwsh_script |
|
|
|
|
|
def query_latest_vs_buildtools_instance() -> Tuple[str, str]: |
|
""" |
|
Use vswhere to obtain the latest Visual Studio Build Tools instance and |
|
return (instance_id, dev_shell_dll_path). |
|
""" |
|
vswhere_output = subprocess.check_output( |
|
[ |
|
os.path.join(os.environ["PROGRAMFILES(X86)"], "Microsoft Visual Studio\\Installer\\vswhere.exe"), |
|
"-latest", |
|
"-products", |
|
"Microsoft.VisualStudio.Product.BuildTools", |
|
"-format", |
|
"json", |
|
], |
|
text=True, |
|
) |
|
|
|
vs_instances = json.loads(vswhere_output) |
|
if not vs_instances: |
|
raise RuntimeError("Visual Studio Build Tools not found.") |
|
|
|
vs0 = vs_instances[0] |
|
instance_id = vs0["instanceId"] |
|
product_path = vs0["productPath"] |
|
dev_shell_dll_path = os.path.join( |
|
os.path.dirname(product_path), |
|
"Microsoft.VisualStudio.DevShell.dll", |
|
) |
|
return instance_id, dev_shell_dll_path |
|
|
|
|
|
# ============================== |
|
# VS DevShell invocation (Mode B: Developer PowerShell shortcut) |
|
# ============================== |
|
|
|
# Type definitions (used in script B style) |
|
CommandLineToArgvW = ctypes.windll.shell32.CommandLineToArgvW |
|
CommandLineToArgvW.argtypes = [wintypes.LPCWSTR, ctypes.POINTER(ctypes.c_int)] |
|
CommandLineToArgvW.restype = ctypes.POINTER(wintypes.LPWSTR) |
|
|
|
LocalFree = ctypes.windll.kernel32.LocalFree |
|
LocalFree.argtypes = [wintypes.HLOCAL] |
|
LocalFree.restype = wintypes.HLOCAL |
|
|
|
|
|
def parse_command_line(cmdline: str) -> list[str]: |
|
""" |
|
Parse a Windows command line string using CommandLineToArgvW. |
|
(From script B) |
|
""" |
|
argc = ctypes.c_int() |
|
argv_ptr = CommandLineToArgvW(cmdline, ctypes.byref(argc)) |
|
if not argv_ptr: |
|
raise OSError("CommandLineToArgvW failed") |
|
|
|
try: |
|
args = [argv_ptr[i] for i in range(argc.value)] |
|
finally: |
|
# Free memory |
|
LocalFree(argv_ptr) |
|
|
|
return args |
|
|
|
|
|
def find_developer_powershell_lnk() -> Path: |
|
""" |
|
Search under |
|
C:\\ProgramData\\Microsoft\\Windows\\Start Menu\\Programs |
|
for a single .lnk that contains both 'Developer' and 'PowerShell' in its file name. |
|
(Based on script B) |
|
""" |
|
base_dir = Path(os.path.join(os.environ["PROGRAMDATA"], "Microsoft\\Windows\\Start Menu\\Programs")) |
|
|
|
target_files: List[Path] = [] |
|
|
|
for path in base_dir.rglob("*.lnk"): |
|
name = path.name |
|
if "Developer" in name and "PowerShell" in name: |
|
target_files.append(path) |
|
|
|
if len(target_files) == 0: |
|
raise RuntimeError("Developer PowerShell .lnk not found.") |
|
if len(target_files) > 1: |
|
raise RuntimeError( |
|
"Multiple Developer PowerShell .lnk files were found. " |
|
"You may need to tighten the search criteria." |
|
) |
|
|
|
return target_files[0] |
|
|
|
|
|
def build_vs_dev_shell_pwsh_script_from_shortcut() -> str: |
|
""" |
|
Follow the Developer PowerShell .lnk, extract the Enter-VsDevShell invocation, |
|
modify it to add -DevCmdArguments -no_logo, and wrap it so that it outputs |
|
environment before/after as JSON. |
|
|
|
This is essentially script B's approach turned into a function that returns |
|
a PowerShell script string. |
|
""" |
|
if comtypes is None: |
|
raise RuntimeError( |
|
"comtypes is not installed. Please 'pip install comtypes' to use --mode shortcut." |
|
) |
|
|
|
lnk_path = str(find_developer_powershell_lnk()) |
|
wsh = comtypes.client.CreateObject("WScript.Shell", dynamic=True) |
|
shortcut = wsh.CreateShortcut(lnk_path) |
|
arguments = shortcut.Arguments |
|
|
|
arguments_list = parse_command_line(arguments) |
|
# Same assumption as script B: arguments_list[2] is the PowerShell script text |
|
if len(arguments_list) < 3: |
|
raise RuntimeError("Failed to parse Developer PowerShell shortcut arguments.") |
|
pwsh_script = arguments_list[2] |
|
|
|
# Insert "-DevCmdArguments -no_logo" right after "Enter-VsDevShell" |
|
enter_vsdevshell_str = "Enter-VsDevShell" |
|
pos = pwsh_script.rfind(enter_vsdevshell_str) |
|
if pos == -1: |
|
raise RuntimeError("'Enter-VsDevShell' not found in shortcut script.") |
|
|
|
insert_pos = pos + len(enter_vsdevshell_str) |
|
pwsh_script = ( |
|
pwsh_script[:insert_pos] |
|
+ " -DevCmdArguments -no_logo" |
|
+ pwsh_script[insert_pos:] |
|
) |
|
|
|
# Wrap with before/after environment capture and JSON output |
|
wrapped_pwsh = ( |
|
"function Get-EnvAsHashtable { " |
|
"$envTable = @{}; " |
|
"Get-ChildItem Env: | ForEach-Object { $envTable[$_.Name] = $_.Value }; " |
|
"return $envTable " |
|
"}; " |
|
"$before = Get-EnvAsHashtable; " |
|
f"{pwsh_script}; " |
|
"$after = Get-EnvAsHashtable; " |
|
"[pscustomobject]@{ before = $before; after = $after } | ConvertTo-Json" |
|
) |
|
|
|
return wrapped_pwsh |
|
|
|
|
|
# ============================== |
|
# PowerShell execution |
|
# ============================== |
|
|
|
def run_pwsh_and_capture_env_diff_json(pwsh_script: str) -> str: |
|
""" |
|
Run the given PowerShell script via powershell.exe and return the JSON output. |
|
Raise an exception with stderr if execution fails. |
|
""" |
|
# Follow script A: run DevShell under as clean an environment as possible |
|
env = dict() |
|
env["Path"] = "" |
|
env["SYSTEMROOT"] = os.environ["SYSTEMROOT"] |
|
env["DRIVERDATA"] = os.environ.get("DRIVERDATA", "") |
|
env["PROGRAMDATA"] = os.environ.get("PROGRAMDATA", "") |
|
|
|
result = subprocess.run( |
|
["powershell.exe", "-NoLogo", "-c", pwsh_script], |
|
text=True, |
|
env=env, |
|
capture_output=True, |
|
) |
|
|
|
if result.returncode != 0: |
|
raise RuntimeError( |
|
"Error occurred while running powershell.exe.\n" |
|
f"returncode: {result.returncode}\n" |
|
f"STDOUT:\n{result.stdout}\n" |
|
f"STDERR:\n{result.stderr}\n" |
|
) |
|
|
|
if len(result.stderr) > 0: |
|
raise RuntimeError(result.stderr) |
|
|
|
return result.stdout |
|
|
|
|
|
# ============================== |
|
# Main |
|
# ============================== |
|
|
|
def main() -> None: |
|
parser = argparse.ArgumentParser( |
|
description="Generate bash exports/unsets for VS DevShell (for MSYS)." |
|
) |
|
parser.add_argument( |
|
"--mode", |
|
choices=["vswhere", "shortcut"], |
|
default="vswhere", |
|
help=( |
|
"Choose how to invoke VS DevShell: " |
|
"'vswhere' = Visual Studio Build Tools (DevShell.dll), " |
|
"'shortcut' = Developer PowerShell shortcut (.lnk)." |
|
), |
|
) |
|
args = parser.parse_args() |
|
|
|
if args.mode == "vswhere": |
|
# Mode A: vswhere + DevShell.dll |
|
instance_id, dev_shell_dll_path = query_latest_vs_buildtools_instance() |
|
pwsh_script = build_vs_dev_shell_pwsh_script_vswhere( |
|
instance_id, dev_shell_dll_path |
|
) |
|
else: |
|
# Mode B: Developer PowerShell .lnk |
|
pwsh_script = build_vs_dev_shell_pwsh_script_from_shortcut() |
|
|
|
env_diff_json = run_pwsh_and_capture_env_diff_json(pwsh_script) |
|
bash_script = generate_bash_env_script(env_diff_json) |
|
# Avoid printing an extra trailing newline |
|
print(bash_script, end="") |
|
|
|
|
|
if __name__ == "__main__": |
|
main() |