[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
710
electron/bridges/shellDiscovery.cjs
Normal file
710
electron/bridges/shellDiscovery.cjs
Normal file
@@ -0,0 +1,710 @@
|
||||
/**
|
||||
* Shell Discovery — cross-platform shell detection
|
||||
*
|
||||
* Detects available shells on Windows (CMD, PowerShell, WSL, Git Bash, Cygwin)
|
||||
* and Unix/macOS (via /etc/shells). Registry access on Windows uses `reg.exe`
|
||||
* via child_process — no native npm dependency.
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
const { isWindowsAppExecutionAliasPath } = require("../../lib/localShell.cjs");
|
||||
|
||||
const EXEC_OPTS = { encoding: "utf8", timeout: 5000, windowsHide: true };
|
||||
|
||||
/** Module-level cache for later use by the unified discoverShells() (Task 3). */
|
||||
let cachedShells = null;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper utilities
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Query a specific value from a Windows registry key.
|
||||
* Returns the value string, or `null` on failure.
|
||||
*
|
||||
* @param {string} keyPath e.g. "HKLM\\SOFTWARE\\GitForWindows"
|
||||
* @param {string} valueName e.g. "InstallPath"
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function regQueryValue(keyPath, valueName) {
|
||||
try {
|
||||
// /ve queries the default (unnamed) value; /v queries a named value.
|
||||
const args =
|
||||
valueName === "" || valueName == null
|
||||
? ["query", keyPath, "/ve"]
|
||||
: ["query", keyPath, "/v", valueName];
|
||||
const output = execFileSync("reg", args, EXEC_OPTS);
|
||||
// Output format:
|
||||
// HKEY_LOCAL_MACHINE\SOFTWARE\GitForWindows
|
||||
// InstallPath REG_SZ C:\Program Files\Git
|
||||
const lines = output.split(/\r?\n/).filter(Boolean);
|
||||
for (const line of lines) {
|
||||
const match = line.match(/^\s+.+?\s+REG_\w+\s+(.+)$/);
|
||||
if (match) {
|
||||
return match[1].trim();
|
||||
}
|
||||
}
|
||||
} catch (_err) {
|
||||
// Key or value not found — expected on many systems.
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enumerate immediate subkey names under a registry key.
|
||||
* Returns an array of full subkey paths, or an empty array on failure.
|
||||
*
|
||||
* @param {string} keyPath e.g. "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Lxss"
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function regEnumSubkeys(keyPath) {
|
||||
try {
|
||||
const output = execFileSync(
|
||||
"reg",
|
||||
["query", keyPath],
|
||||
EXEC_OPTS,
|
||||
);
|
||||
// `reg query <key>` prints the key itself, then each subkey on its own line
|
||||
// prefixed with the full path. Values appear with leading whitespace.
|
||||
const lines = output.split(/\r?\n/).filter(Boolean);
|
||||
const subkeys = [];
|
||||
const normalizedParent = keyPath.toLowerCase();
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
// Subkeys start with "HK" and are longer than the parent key.
|
||||
if (
|
||||
trimmed.toLowerCase().startsWith("hk") &&
|
||||
trimmed.toLowerCase() !== normalizedParent &&
|
||||
trimmed.toLowerCase().startsWith(normalizedParent + "\\")
|
||||
) {
|
||||
subkeys.push(trimmed);
|
||||
}
|
||||
}
|
||||
return subkeys;
|
||||
} catch (_err) {
|
||||
// Key not found or access denied.
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate an executable on the system PATH using `where.exe`.
|
||||
* Returns the first valid path, or `null` if not found.
|
||||
*
|
||||
* Windows App Execution Aliases (MSIX/Store installs, e.g. winget PowerShell 7)
|
||||
* are kept as a last-resort candidate: they are reparse points that fail
|
||||
* `fs.existsSync()`, but spawning the alias path launches the packaged app
|
||||
* just fine. A regular executable always wins when one is on the PATH.
|
||||
*
|
||||
* @param {string} name Executable name, e.g. "pwsh"
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function findExecutableOnPath(name) {
|
||||
try {
|
||||
const result = execFileSync("where.exe", [name], EXEC_OPTS);
|
||||
const candidates = result
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
let aliasCandidate = null;
|
||||
for (const candidate of candidates) {
|
||||
if (isWindowsAppExecutionAliasPath(candidate)) {
|
||||
aliasCandidate ??= candidate;
|
||||
continue;
|
||||
}
|
||||
if (!fs.existsSync(candidate)) continue;
|
||||
return candidate;
|
||||
}
|
||||
if (aliasCandidate) return aliasCandidate;
|
||||
} catch (_err) {
|
||||
// Not found on PATH.
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a WSL distro name to an icon identifier for SVG lookup.
|
||||
*
|
||||
* @param {string} distroName e.g. "Ubuntu-22.04", "Debian", "kali-linux"
|
||||
* @returns {string}
|
||||
*/
|
||||
function mapWslDistroIcon(distroName) {
|
||||
const lower = (distroName || "").toLowerCase();
|
||||
|
||||
if (lower.includes("ubuntu")) return "ubuntu";
|
||||
if (lower.includes("debian")) return "debian";
|
||||
if (lower.includes("kali")) return "kali";
|
||||
if (lower.includes("alpine")) return "alpine";
|
||||
if (lower.includes("opensuse") || lower.includes("suse")) return "opensuse";
|
||||
if (lower.includes("fedora")) return "fedora";
|
||||
if (lower.includes("arch")) return "arch";
|
||||
if (lower.includes("oracle")) return "oracle";
|
||||
|
||||
return "linux";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Individual shell detectors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Detect CMD.
|
||||
* @returns {object|null} DiscoveredShell or null
|
||||
*/
|
||||
function detectCmd() {
|
||||
try {
|
||||
const comSpec = process.env.ComSpec;
|
||||
const cmdPath = comSpec || "cmd.exe";
|
||||
// Verify the path actually exists when ComSpec provides a full path.
|
||||
if (comSpec && !fs.existsSync(comSpec)) {
|
||||
// Fallback to bare name — Windows will resolve it.
|
||||
return {
|
||||
id: "cmd",
|
||||
name: "CMD",
|
||||
command: "cmd.exe",
|
||||
args: [],
|
||||
icon: "cmd",
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: "cmd",
|
||||
name: "CMD",
|
||||
command: cmdPath,
|
||||
args: [],
|
||||
icon: "cmd",
|
||||
};
|
||||
} catch (_err) {
|
||||
// Should never fail, but guard anyway.
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect Windows PowerShell 5.1.
|
||||
* @returns {object|null}
|
||||
*/
|
||||
function detectPowerShell() {
|
||||
try {
|
||||
// Try where.exe first.
|
||||
const found = findExecutableOnPath("powershell");
|
||||
if (found) {
|
||||
return {
|
||||
id: "powershell",
|
||||
name: "Windows PowerShell",
|
||||
command: found,
|
||||
args: ["-NoLogo"],
|
||||
icon: "powershell",
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback: well-known path.
|
||||
const systemRoot = process.env.SystemRoot || "C:\\Windows";
|
||||
const fallback = path.join(
|
||||
systemRoot,
|
||||
"System32",
|
||||
"WindowsPowerShell",
|
||||
"v1.0",
|
||||
"powershell.exe",
|
||||
);
|
||||
if (fs.existsSync(fallback)) {
|
||||
return {
|
||||
id: "powershell",
|
||||
name: "Windows PowerShell",
|
||||
command: fallback,
|
||||
args: ["-NoLogo"],
|
||||
icon: "powershell",
|
||||
};
|
||||
}
|
||||
} catch (_err) {
|
||||
// Detection failed — not critical.
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect PowerShell Core (pwsh 7+).
|
||||
* @returns {object|null}
|
||||
*/
|
||||
function detectPwsh() {
|
||||
try {
|
||||
// 1. where.exe
|
||||
const found = findExecutableOnPath("pwsh");
|
||||
if (found) {
|
||||
return {
|
||||
id: "pwsh",
|
||||
name: "PowerShell 7",
|
||||
command: found,
|
||||
args: ["-NoLogo"],
|
||||
icon: "pwsh",
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Registry App Paths
|
||||
const regPath = regQueryValue(
|
||||
"HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\pwsh.exe",
|
||||
"",
|
||||
);
|
||||
if (regPath && fs.existsSync(regPath)) {
|
||||
return {
|
||||
id: "pwsh",
|
||||
name: "PowerShell 7",
|
||||
command: regPath,
|
||||
args: ["-NoLogo"],
|
||||
icon: "pwsh",
|
||||
};
|
||||
}
|
||||
|
||||
// 3. Common fallback path.
|
||||
const fallback = path.join(
|
||||
process.env.ProgramFiles || "C:\\Program Files",
|
||||
"PowerShell",
|
||||
"7",
|
||||
"pwsh.exe",
|
||||
);
|
||||
if (fs.existsSync(fallback) && fallback.toLowerCase().includes("powershell")) {
|
||||
return {
|
||||
id: "pwsh",
|
||||
name: "PowerShell 7",
|
||||
command: fallback,
|
||||
args: ["-NoLogo"],
|
||||
icon: "pwsh",
|
||||
};
|
||||
}
|
||||
} catch (_err) {
|
||||
// Detection failed.
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect installed WSL distributions via the registry.
|
||||
* @returns {object[]} Array of DiscoveredShell objects (may be empty).
|
||||
*/
|
||||
function detectWslDistros() {
|
||||
const systemRoot = process.env.SystemRoot || "C:\\Windows";
|
||||
const wslExe = path.join(systemRoot, "System32", "wsl.exe");
|
||||
if (
|
||||
!fs.existsSync(wslExe) ||
|
||||
!path.dirname(wslExe).toLowerCase().endsWith("system32")
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const distros = [];
|
||||
|
||||
// Primary: use `wsl.exe -l -q` which lists installed distros one per line.
|
||||
// More reliable than registry parsing across Windows versions.
|
||||
// Note: wsl.exe outputs UTF-16LE on some builds, so we read as buffer and decode.
|
||||
try {
|
||||
const buf = execFileSync(wslExe, ["-l", "-q"], {
|
||||
timeout: 5000,
|
||||
windowsHide: true,
|
||||
maxBuffer: 1024 * 64,
|
||||
});
|
||||
// wsl.exe outputs UTF-16LE on most Windows builds (has NUL bytes between chars).
|
||||
// Detect by checking for NUL bytes in the raw buffer; if present → UTF-16LE, else UTF-8.
|
||||
const isUtf16 = buf.length >= 2 && buf.includes(0x00);
|
||||
const output = buf.toString(isUtf16 ? "utf16le" : "utf8");
|
||||
const names = output
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.replace(/\0/g, "").trim())
|
||||
.filter(Boolean);
|
||||
|
||||
for (const distroName of names) {
|
||||
distros.push({
|
||||
id: `wsl-${distroName.toLowerCase().replace(/[^a-z0-9-]/g, "-")}`,
|
||||
name: `${distroName} (WSL)`,
|
||||
command: wslExe,
|
||||
args: ["-d", distroName],
|
||||
icon: mapWslDistroIcon(distroName),
|
||||
});
|
||||
}
|
||||
if (distros.length > 0) return distros;
|
||||
} catch (_err) {
|
||||
// wsl.exe -l -q failed, fall through to registry method.
|
||||
}
|
||||
|
||||
// Fallback: enumerate registry subkeys under Lxss
|
||||
try {
|
||||
const lxssKey = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Lxss";
|
||||
const subkeys = regEnumSubkeys(lxssKey);
|
||||
|
||||
for (const subkey of subkeys) {
|
||||
try {
|
||||
const distroName = regQueryValue(subkey, "DistributionName");
|
||||
if (!distroName) continue;
|
||||
|
||||
distros.push({
|
||||
id: `wsl-${distroName.toLowerCase().replace(/[^a-z0-9-]/g, "-")}`,
|
||||
name: `${distroName} (WSL)`,
|
||||
command: wslExe,
|
||||
args: ["-d", distroName],
|
||||
icon: mapWslDistroIcon(distroName),
|
||||
});
|
||||
} catch (_err) {
|
||||
// Skip this distro but continue with others.
|
||||
}
|
||||
}
|
||||
} catch (_err) {
|
||||
// WSL not installed or registry not accessible.
|
||||
}
|
||||
return distros;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect Git Bash (from Git for Windows).
|
||||
* @returns {object|null}
|
||||
*/
|
||||
function detectGitBash() {
|
||||
try {
|
||||
// Try registry first.
|
||||
const installPath = regQueryValue(
|
||||
"HKLM\\SOFTWARE\\GitForWindows",
|
||||
"InstallPath",
|
||||
);
|
||||
if (installPath) {
|
||||
const bashExe = path.join(installPath, "bin", "bash.exe");
|
||||
if (fs.existsSync(bashExe)) {
|
||||
return {
|
||||
id: "git-bash",
|
||||
name: "Git Bash",
|
||||
command: bashExe,
|
||||
args: ["--login", "-i"],
|
||||
icon: "git-bash",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: common installation path.
|
||||
const fallbackPaths = [
|
||||
path.join(
|
||||
process.env.ProgramFiles || "C:\\Program Files",
|
||||
"Git",
|
||||
"bin",
|
||||
"bash.exe",
|
||||
),
|
||||
path.join(
|
||||
process.env["ProgramFiles(x86)"] || "C:\\Program Files (x86)",
|
||||
"Git",
|
||||
"bin",
|
||||
"bash.exe",
|
||||
),
|
||||
];
|
||||
for (const p of fallbackPaths) {
|
||||
if (fs.existsSync(p)) {
|
||||
return {
|
||||
id: "git-bash",
|
||||
name: "Git Bash",
|
||||
command: p,
|
||||
args: ["--login", "-i"],
|
||||
icon: "git-bash",
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (_err) {
|
||||
// Git Bash not installed.
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect Cygwin bash.
|
||||
* @returns {object|null}
|
||||
*/
|
||||
function detectCygwin() {
|
||||
try {
|
||||
// Try 64-bit registry key first, then 32-bit (WOW6432Node).
|
||||
const rootDir =
|
||||
regQueryValue("HKLM\\SOFTWARE\\Cygwin\\setup", "rootdir") ||
|
||||
regQueryValue("HKLM\\SOFTWARE\\WOW6432Node\\Cygwin\\setup", "rootdir");
|
||||
|
||||
if (rootDir) {
|
||||
const bashExe = path.join(rootDir, "bin", "bash.exe");
|
||||
if (fs.existsSync(bashExe)) {
|
||||
return {
|
||||
id: "cygwin",
|
||||
name: "Cygwin",
|
||||
command: bashExe,
|
||||
args: ["--login", "-i"],
|
||||
icon: "cygwin",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: common path.
|
||||
const fallback = "C:\\cygwin64\\bin\\bash.exe";
|
||||
if (fs.existsSync(fallback)) {
|
||||
return {
|
||||
id: "cygwin",
|
||||
name: "Cygwin",
|
||||
command: fallback,
|
||||
args: ["--login", "-i"],
|
||||
icon: "cygwin",
|
||||
};
|
||||
}
|
||||
} catch (_err) {
|
||||
// Cygwin not installed.
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main discovery entry point for Windows
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Discover all available shells on a Windows system.
|
||||
* Returns an array of DiscoveredShell objects. Exactly one shell will have
|
||||
* `isDefault: true` based on priority: pwsh > powershell > cmd.
|
||||
*
|
||||
* @returns {Array<{id: string, name: string, command: string, args: string[], icon: string, isDefault?: boolean}>}
|
||||
*/
|
||||
function discoverWindowsShells() {
|
||||
const shells = [];
|
||||
|
||||
// Detect each shell type independently — failures are isolated.
|
||||
const cmd = detectCmd();
|
||||
if (cmd) shells.push(cmd);
|
||||
|
||||
const powershell = detectPowerShell();
|
||||
if (powershell) shells.push(powershell);
|
||||
|
||||
const pwsh = detectPwsh();
|
||||
if (pwsh) shells.push(pwsh);
|
||||
|
||||
const wslDistros = detectWslDistros();
|
||||
shells.push(...wslDistros);
|
||||
|
||||
const gitBash = detectGitBash();
|
||||
if (gitBash) shells.push(gitBash);
|
||||
|
||||
const cygwin = detectCygwin();
|
||||
if (cygwin) shells.push(cygwin);
|
||||
|
||||
// Assign default: pwsh > powershell > cmd
|
||||
const defaultShell =
|
||||
shells.find((s) => s.id === "pwsh") ||
|
||||
shells.find((s) => s.id === "powershell") ||
|
||||
shells.find((s) => s.id === "cmd");
|
||||
if (defaultShell) {
|
||||
defaultShell.isDefault = true;
|
||||
}
|
||||
|
||||
return shells;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unix shell detection helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Map a Unix shell binary basename to a human-readable display name.
|
||||
*
|
||||
* @param {string} basename e.g. "zsh", "bash", "nu"
|
||||
* @returns {string}
|
||||
*/
|
||||
function mapUnixShellName(basename) {
|
||||
const map = {
|
||||
zsh: "Zsh",
|
||||
bash: "Bash",
|
||||
fish: "Fish",
|
||||
sh: "sh",
|
||||
ksh: "Ksh",
|
||||
tcsh: "Tcsh",
|
||||
csh: "Csh",
|
||||
dash: "Dash",
|
||||
nu: "Nushell",
|
||||
pwsh: "PowerShell",
|
||||
};
|
||||
return map[basename] || basename;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a Unix shell binary basename to an icon identifier.
|
||||
*
|
||||
* @param {string} basename e.g. "zsh", "fish", "nu"
|
||||
* @returns {string}
|
||||
*/
|
||||
function mapUnixShellIcon(basename) {
|
||||
const map = {
|
||||
zsh: "zsh",
|
||||
bash: "bash",
|
||||
fish: "fish",
|
||||
sh: "terminal",
|
||||
ksh: "terminal",
|
||||
tcsh: "terminal",
|
||||
csh: "terminal",
|
||||
dash: "terminal",
|
||||
nu: "nushell",
|
||||
pwsh: "pwsh",
|
||||
};
|
||||
return map[basename] || "terminal";
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true for shells that should be launched with the `-l` (login) flag.
|
||||
*
|
||||
* @param {string} basename
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isLoginShell(basename) {
|
||||
return ["bash", "zsh", "fish", "ksh", "sh"].includes(basename);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main discovery entry point for Unix
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Discover all available shells on a Unix/macOS system by reading /etc/shells.
|
||||
* The shell referenced by $SHELL is marked as default. If $SHELL is not in
|
||||
* /etc/shells it is prepended to the list.
|
||||
*
|
||||
* @returns {Array<{id: string, name: string, command: string, args: string[], icon: string, isDefault?: boolean}>}
|
||||
*/
|
||||
function discoverUnixShells() {
|
||||
const shells = [];
|
||||
const seen = new Set();
|
||||
|
||||
// Read /etc/shells — each non-comment line is an absolute path.
|
||||
let etcShellPaths = [];
|
||||
try {
|
||||
const content = fs.readFileSync("/etc/shells", "utf8");
|
||||
etcShellPaths = content
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l && !l.startsWith("#"));
|
||||
} catch (_err) {
|
||||
// /etc/shells not readable — fall through to $SHELL only.
|
||||
}
|
||||
|
||||
// Filter to existing files and deduplicate by real path.
|
||||
const validPaths = [];
|
||||
for (const shellPath of etcShellPaths) {
|
||||
try {
|
||||
if (!fs.existsSync(shellPath)) continue;
|
||||
const real = fs.realpathSync(shellPath);
|
||||
if (seen.has(real)) continue;
|
||||
seen.add(real);
|
||||
validPaths.push(shellPath);
|
||||
} catch (_err) {
|
||||
// Skip unresolvable paths.
|
||||
}
|
||||
}
|
||||
|
||||
// Build DiscoveredShell objects.
|
||||
// Track basename counts to detect duplicates (e.g., /bin/bash vs /usr/local/bin/bash)
|
||||
const baseCount = new Map();
|
||||
for (const shellPath of validPaths) {
|
||||
const base = path.basename(shellPath);
|
||||
baseCount.set(base, (baseCount.get(base) || 0) + 1);
|
||||
}
|
||||
|
||||
for (const shellPath of validPaths) {
|
||||
const base = path.basename(shellPath);
|
||||
const args = isLoginShell(base) ? ["-l"] : [];
|
||||
// Use basename as id when unique, otherwise use path slug to guarantee uniqueness
|
||||
const needsDisambiguation = baseCount.get(base) > 1;
|
||||
const id = needsDisambiguation
|
||||
? shellPath.replace(/^\/+/, "").replace(/[/\\]+/g, "-")
|
||||
: base;
|
||||
const name = needsDisambiguation
|
||||
? `${mapUnixShellName(base)} (${shellPath})`
|
||||
: mapUnixShellName(base);
|
||||
shells.push({
|
||||
id,
|
||||
name,
|
||||
command: shellPath,
|
||||
args,
|
||||
icon: mapUnixShellIcon(base),
|
||||
});
|
||||
}
|
||||
|
||||
// Ensure $SHELL is present — prepend it if missing.
|
||||
const envShell = process.env.SHELL;
|
||||
if (envShell) {
|
||||
try {
|
||||
const envReal = fs.realpathSync(envShell);
|
||||
if (!seen.has(envReal) && fs.existsSync(envShell)) {
|
||||
const base = path.basename(envShell);
|
||||
const args = isLoginShell(base) ? ["-l"] : [];
|
||||
// Check if basename already exists in the list to disambiguate
|
||||
const hasDuplicate = shells.some((s) => path.basename(s.command) === base);
|
||||
const id = hasDuplicate
|
||||
? envShell.replace(/^\/+/, "").replace(/[/\\]+/g, "-")
|
||||
: base;
|
||||
const name = hasDuplicate
|
||||
? `${mapUnixShellName(base)} (${envShell})`
|
||||
: mapUnixShellName(base);
|
||||
shells.unshift({
|
||||
id,
|
||||
name,
|
||||
command: envShell,
|
||||
args,
|
||||
icon: mapUnixShellIcon(base),
|
||||
});
|
||||
}
|
||||
} catch (_err) {
|
||||
// $SHELL path invalid — ignore.
|
||||
}
|
||||
}
|
||||
|
||||
// Mark $SHELL as default (match by command path or basename).
|
||||
if (envShell) {
|
||||
const defaultShell =
|
||||
shells.find((s) => s.command === envShell) ||
|
||||
shells.find((s) => s.id === path.basename(envShell));
|
||||
if (defaultShell) {
|
||||
defaultShell.isDefault = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: mark first shell as default if none matched.
|
||||
if (shells.length > 0 && !shells.some((s) => s.isDefault)) {
|
||||
shells[0].isDefault = true;
|
||||
}
|
||||
|
||||
return shells;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unified shell discovery entry point
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Discover all available shells for the current platform.
|
||||
* Results are cached after the first call.
|
||||
*
|
||||
* @returns {Array<{id: string, name: string, command: string, args: string[], icon: string, isDefault?: boolean}>}
|
||||
*/
|
||||
function discoverShells() {
|
||||
if (cachedShells) return cachedShells;
|
||||
|
||||
if (process.platform === "win32") {
|
||||
cachedShells = discoverWindowsShells();
|
||||
} else {
|
||||
cachedShells = discoverUnixShells();
|
||||
}
|
||||
|
||||
return cachedShells;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Exports
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
module.exports = {
|
||||
discoverShells,
|
||||
discoverWindowsShells,
|
||||
discoverUnixShells,
|
||||
mapUnixShellName,
|
||||
mapUnixShellIcon,
|
||||
isLoginShell,
|
||||
regQueryValue,
|
||||
regEnumSubkeys,
|
||||
findExecutableOnPath,
|
||||
mapWslDistroIcon,
|
||||
};
|
||||
Reference in New Issue
Block a user